feat(aihr): add owner identity profiles for realtime practice

- remove owner-impatient persona; owner-calm renamed to 业主
- aihr_realtime_owner_profile table (tenant+project unique, enabled,
  version) with formal migration
- admin CRUD under moderator role gate; project code validated against
  org snapshot and project name taken from snapshot authoritatively
- employee endpoint returns display-safe fields plus defaultProfileId
  from the employee's own community; profile body never exposed
- session creation renders {community}/{profile} server-side; client
  text can never enter instructions; invalid profileId rejected,
  default resolution fails closed
- duplicate create returns 409
This commit is contained in:
2026-08-05 03:18:50 +08:00
parent 83fb657b1b
commit d9ff43eae3
12 changed files with 867 additions and 28 deletions
@@ -0,0 +1,57 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSaveRequest;
import org.dromara.aihr.service.AihrRealtimeOwnerProfileService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 实时陪练业主身份档案管理端。角色门在服务端按 moderator 纪律硬校验(同 question-admin),
* projectCode 必须是本租户组织快照中的真实项目;重复 create 按 409 冲突处理,请改用编辑。
*/
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/realtime/owner-profiles")
public class AihrRealtimeOwnerProfileAdminController {
private final AihrRealtimeOwnerProfileService ownerProfileService;
@GetMapping
public R<List<OwnerProfileAdminResponse>> list() {
return R.ok(ownerProfileService.adminList());
}
@PostMapping
public R<OwnerProfileAdminResponse> create(@RequestBody OwnerProfileSaveRequest request) {
return R.ok(ownerProfileService.adminCreate(request));
}
@PutMapping("/{id}")
public R<OwnerProfileAdminResponse> update(@PathVariable Long id, @RequestBody OwnerProfileSaveRequest request) {
return R.ok(ownerProfileService.adminUpdate(id, request));
}
@PostMapping("/{id}/enable")
public R<Void> enable(@PathVariable Long id) {
ownerProfileService.adminSetEnabled(id, true);
return R.ok();
}
@PostMapping("/{id}/disable")
public R<Void> disable(@PathVariable Long id) {
ownerProfileService.adminSetEnabled(id, false);
return R.ok();
}
}
@@ -0,0 +1,35 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileListResponse;
import org.dromara.aihr.service.AihrRealtimeOwnerProfileService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 员工端实时陪练业主身份档案:只返回展示安全字段(不含 concerns/画像正文),
* defaultProfileId 由服务端按当前员工组织项目匹配,无命中为 null。
*/
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/practice/realtime")
public class AihrRealtimeOwnerProfileController {
private final AihrRealtimeOwnerProfileService ownerProfileService;
@GetMapping("/owner-profiles")
public R<OwnerProfileListResponse> ownerProfiles() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return R.fail("请使用员工端账号开启实时陪练");
}
return R.ok(ownerProfileService.employeeProfiles());
}
}
@@ -1,5 +1,6 @@
package org.dromara.aihr.domain;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -246,7 +247,14 @@ public final class AihrPracticeDto {
public record RealtimePersonaOption(String id, String name, String description, boolean hasTools) {
}
public record RealtimeSessionRequest(String personaId, String scenarioId) {
/**
* 实时会话创建请求。profileId 可空:显式指定时服务端校验其属本租户且已启用;
* 缺省时服务端按当前员工组织项目自动匹配默认档案,均无命中则使用无画像模板。
*/
public record RealtimeSessionRequest(String personaId, String scenarioId, String profileId) {
public RealtimeSessionRequest(String personaId, String scenarioId) {
this(personaId, scenarioId, null);
}
}
public record RealtimeSessionResponse(String sessionId, String personaId, String personaName,
@@ -267,6 +275,39 @@ public final class AihrPracticeDto {
String tenantId, Set<String> allowedTools, Long createdAt) {
}
/**
* 业主身份档案管理端写入请求。projectName 不接受客户端提交,写入时由服务端从本租户
* 组织快照按 projectCode 取权威显示名。
*/
public record OwnerProfileSaveRequest(String projectCode, String ageRange, String incomeLevel,
String familyStructure, String concerns) {
}
/** 管理端档案视图,含画像正文(concerns),仅 moderator 可见。 */
public record OwnerProfileAdminResponse(Long id, String projectCode, String projectName, String ageRange,
String incomeLevel, String familyStructure, String concerns,
boolean enabled, Integer version,
LocalDateTime createTime, LocalDateTime updateTime) {
}
/**
* 员工端档案选项:只含展示安全字段,绝不下发 concerns 或画像正文。
* label 由服务端拼接(如「幸福里 · 30-45岁 中等收入」);profileId 序列化为字符串。
*/
public record OwnerProfileOption(String profileId, String projectCode, String communityName, String label) {
}
/**
* 员工端档案列表。defaultProfileId 为服务端按当前员工组织项目匹配到的档案,
* 无命中时为 null,客户端不得自行猜测默认档案。
*/
public record OwnerProfileListResponse(List<OwnerProfileOption> profiles, String defaultProfileId) {
}
/** 会话渲染用的服务端画像上下文:小区显示名 + 已拼接的画像短语(含 concerns)。 */
public record OwnerProfileSessionContext(String communityName, String profilePhrase) {
}
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl,
Long traineeAudioOssId, Boolean regenerate, String style, Boolean suggestReply) {
public TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl,
@@ -0,0 +1,306 @@
package org.dromara.aihr.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.community.AihrCommunityPolicy;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileListResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileOption;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSessionContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 实时陪练业主身份档案:画像属内容运营数据,管理端 moderator 维护,仅 enabled=1 对员工可见。
* 员工端只下发展示安全字段(不含 concerns/画像正文);会话渲染的画像短语只由服务端按档案拼接,
* 客户端任何文本不得进入 instructions。画像匹配不到项目时一律 fail-closed 用无画像模板。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AihrRealtimeOwnerProfileService {
record OwnerProfileRow(Long id, String projectCode, String projectName, String ageRange,
String incomeLevel, String familyStructure, String concerns,
boolean enabled, Integer version,
java.time.LocalDateTime createTime, java.time.LocalDateTime updateTime) {
}
private static final RowMapper<OwnerProfileRow> ROW_MAPPER = AihrRealtimeOwnerProfileService::mapRow;
private static final String SELECT_COLUMNS =
"id, project_code, project_name, age_range, income_level, family_structure, concerns, enabled, version,"
+ " create_time, update_time";
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgePrincipalResolver principalResolver;
// ---------- 管理端(moderator 角色门,同 question-admin 纪律) ----------
public List<OwnerProfileAdminResponse> adminList() {
AihrKnowledgePrincipal principal = requireModerator();
return jdbcTemplate.query(
"SELECT " + SELECT_COLUMNS + " FROM aihr_realtime_owner_profile WHERE tenant_id = ? ORDER BY id",
ROW_MAPPER, principal.tenantId()).stream().map(AihrRealtimeOwnerProfileService::adminView).toList();
}
/**
* 创建档案。projectCode 必须存在于本租户组织快照;projectName 由服务端从快照取权威显示名。
* 幂等口径:同一 (tenant, projectCode) 只允许一条档案,重复 create 返回 409 冲突,请改用编辑。
*/
public OwnerProfileAdminResponse adminCreate(OwnerProfileSaveRequest request) {
AihrKnowledgePrincipal principal = requireModerator();
String projectCode = requireSnapshotProjectCode(principal.tenantId(), request);
String projectName = resolveSnapshotProjectName(principal.tenantId(), projectCode);
Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM aihr_realtime_owner_profile WHERE tenant_id = ? AND project_code = ?",
Integer.class, principal.tenantId(), projectCode);
if (existing != null && existing > 0) {
throw new ServiceException("该项目已存在业主身份档案,请直接编辑", 409);
}
try {
jdbcTemplate.update("""
INSERT INTO aihr_realtime_owner_profile
(tenant_id, project_code, project_name, age_range, income_level, family_structure, concerns, enabled, version, create_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 0, ?)
""", principal.tenantId(), projectCode, projectName,
clean(request.ageRange()), clean(request.incomeLevel()), clean(request.familyStructure()),
clean(request.concerns()), String.valueOf(principal.userId()));
} catch (DuplicateKeyException duplicate) {
throw new ServiceException("该项目已存在业主身份档案,请直接编辑", 409);
}
return adminView(requireRow(principal.tenantId(), projectCode));
}
/** 编辑档案:仅本租户行可改,projectCode 变更同样校验组织快照并受唯一键保护,版本递增。 */
public OwnerProfileAdminResponse adminUpdate(Long id, OwnerProfileSaveRequest request) {
AihrKnowledgePrincipal principal = requireModerator();
OwnerProfileRow current = findRow(principal.tenantId(), id);
String projectCode = requireSnapshotProjectCode(principal.tenantId(), request);
String projectName = resolveSnapshotProjectName(principal.tenantId(), projectCode);
try {
jdbcTemplate.update("""
UPDATE aihr_realtime_owner_profile
SET project_code = ?, project_name = ?, age_range = ?, income_level = ?,
family_structure = ?, concerns = ?, version = version + 1
WHERE tenant_id = ? AND id = ?
""", projectCode, projectName,
clean(request.ageRange()), clean(request.incomeLevel()), clean(request.familyStructure()),
clean(request.concerns()), principal.tenantId(), current.id());
} catch (DuplicateKeyException duplicate) {
throw new ServiceException("该项目已存在业主身份档案,请直接编辑", 409);
}
return adminView(findRow(principal.tenantId(), current.id()));
}
/** 启停档案:enabled=0 立即从员工列表和会话渲染中消失,不删除内容。 */
public void adminSetEnabled(Long id, Boolean enabled) {
AihrKnowledgePrincipal principal = requireModerator();
if (enabled == null) {
throw new ServiceException("请指定启用状态", 400);
}
OwnerProfileRow current = findRow(principal.tenantId(), id);
jdbcTemplate.update(
"UPDATE aihr_realtime_owner_profile SET enabled = ? WHERE tenant_id = ? AND id = ?",
enabled ? 1 : 0, principal.tenantId(), current.id());
}
// ---------- 员工端(APP 登录态,只下发展示安全字段) ----------
public OwnerProfileListResponse employeeProfiles() {
AihrKnowledgePrincipal principal = principalResolver.current();
List<OwnerProfileRow> rows = enabledRows(principal.tenantId());
List<OwnerProfileOption> options = rows.stream().map(AihrRealtimeOwnerProfileService::option).toList();
String defaultProfileId = rows.stream()
.filter(row -> principal.projectCodes().contains(row.projectCode()))
.map(OwnerProfileRow::id)
.findFirst()
.map(String::valueOf)
.orElse(null);
return new OwnerProfileListResponse(options, defaultProfileId);
}
// ---------- 会话渲染接线 ----------
/**
* 解析本次会话的画像上下文。显式 profileId 必须属本租户且 enabled,否则拒绝;
* 缺省时按当前员工组织项目匹配默认档案,匹配不到或查询失败一律 fail-closed 返回 null
* (会话继续使用无画像模板,不报错打断)。
*/
public OwnerProfileSessionContext resolveForSession(String profileId) {
AihrKnowledgePrincipal principal = principalResolver.current();
if (hasText(profileId)) {
return requireSessionContext(principal.tenantId(), profileId.trim());
}
try {
return defaultSessionContext(principal);
} catch (RuntimeException ex) {
log.warn("realtime owner profile default resolution failed, rendering without profile");
return null;
}
}
private OwnerProfileSessionContext requireSessionContext(String tenantId, String profileId) {
long id;
try {
id = Long.parseLong(profileId);
} catch (NumberFormatException invalid) {
throw new ServiceException("业主身份档案参数无效", 400);
}
List<OwnerProfileRow> rows = jdbcTemplate.query(
"SELECT " + SELECT_COLUMNS + " FROM aihr_realtime_owner_profile"
+ " WHERE tenant_id = ? AND id = ? AND enabled = 1",
ROW_MAPPER, tenantId, id);
if (rows.isEmpty()) {
throw new ServiceException("业主身份档案不存在或未启用", 400);
}
return sessionContext(rows.get(0));
}
private OwnerProfileSessionContext defaultSessionContext(AihrKnowledgePrincipal principal) {
Set<String> projectCodes = principal.projectCodes();
if (projectCodes.isEmpty()) {
return null;
}
return enabledRows(principal.tenantId()).stream()
.filter(row -> projectCodes.contains(row.projectCode()))
.findFirst()
.map(AihrRealtimeOwnerProfileService::sessionContext)
.orElse(null);
}
// ---------- 内部 ----------
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可执行该操作", 403);
}
return principal;
}
/** projectCode 必填且必须存在于本租户组织快照,不接受客户端自报项目。 */
private String requireSnapshotProjectCode(String tenantId, OwnerProfileSaveRequest request) {
if (request == null || !hasText(request.projectCode())) {
throw new ServiceException("请选择项目", 400);
}
String projectCode = request.projectCode().trim();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM aihr_org_snapshot WHERE tenant_id = ? AND project_code = ?",
Integer.class, tenantId, projectCode);
if (count == null || count == 0) {
throw new ServiceException("项目编码不存在于当前租户组织快照", 400);
}
return projectCode;
}
private String resolveSnapshotProjectName(String tenantId, String projectCode) {
List<String> names = jdbcTemplate.query(
"SELECT DISTINCT project_name FROM aihr_org_snapshot"
+ " WHERE tenant_id = ? AND project_code = ? AND project_name IS NOT NULL AND project_name <> '' LIMIT 1",
(rs, rowNum) -> rs.getString("project_name"), tenantId, projectCode);
return names.isEmpty() ? projectCode : names.get(0).trim();
}
private List<OwnerProfileRow> enabledRows(String tenantId) {
return jdbcTemplate.query(
"SELECT " + SELECT_COLUMNS + " FROM aihr_realtime_owner_profile"
+ " WHERE tenant_id = ? AND enabled = 1 ORDER BY id",
ROW_MAPPER, tenantId);
}
private OwnerProfileRow findRow(String tenantId, Long id) {
if (id == null) {
throw new ServiceException("业主身份档案不存在", 404);
}
List<OwnerProfileRow> rows = jdbcTemplate.query(
"SELECT " + SELECT_COLUMNS + " FROM aihr_realtime_owner_profile WHERE tenant_id = ? AND id = ?",
ROW_MAPPER, tenantId, id);
if (rows.isEmpty()) {
throw new ServiceException("业主身份档案不存在", 404);
}
return rows.get(0);
}
private OwnerProfileRow requireRow(String tenantId, String projectCode) {
List<OwnerProfileRow> rows = jdbcTemplate.query(
"SELECT " + SELECT_COLUMNS + " FROM aihr_realtime_owner_profile WHERE tenant_id = ? AND project_code = ?",
ROW_MAPPER, tenantId, projectCode);
if (rows.isEmpty()) {
throw new ServiceException("业主身份档案写入失败", 500);
}
return rows.get(0);
}
private static OwnerProfileRow mapRow(ResultSet rs, int rowNum) throws SQLException {
return new OwnerProfileRow(
rs.getLong("id"), rs.getString("project_code"), rs.getString("project_name"),
rs.getString("age_range"), rs.getString("income_level"), rs.getString("family_structure"),
rs.getString("concerns"), rs.getBoolean("enabled"), rs.getInt("version"),
toLocalDateTime(rs.getTimestamp("create_time")), toLocalDateTime(rs.getTimestamp("update_time")));
}
private static java.time.LocalDateTime toLocalDateTime(java.sql.Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static OwnerProfileAdminResponse adminView(OwnerProfileRow row) {
return new OwnerProfileAdminResponse(row.id(), row.projectCode(), row.projectName(), row.ageRange(),
row.incomeLevel(), row.familyStructure(), row.concerns(), row.enabled(), row.version(),
row.createTime(), row.updateTime());
}
/** 员工端展示视图:只含展示安全字段,绝不携带 concerns 或画像正文。 */
private static OwnerProfileOption option(OwnerProfileRow row) {
List<String> traits = new ArrayList<>();
if (hasText(row.ageRange())) {
traits.add(row.ageRange().trim());
}
if (hasText(row.incomeLevel())) {
traits.add(row.incomeLevel().trim());
}
String label = traits.isEmpty()
? row.projectName()
: row.projectName() + " · " + String.join(" ", traits);
return new OwnerProfileOption(String.valueOf(row.id()), row.projectCode(), row.projectName(), label);
}
/** 画像短语只由服务端按档案字段拼接,是 concerns 进入 instructions 的唯一通道。 */
private static OwnerProfileSessionContext sessionContext(OwnerProfileRow row) {
List<String> parts = new ArrayList<>();
if (hasText(row.ageRange())) {
parts.add("年龄段" + row.ageRange().trim());
}
if (hasText(row.incomeLevel())) {
parts.add("收入水平" + row.incomeLevel().trim());
}
if (hasText(row.familyStructure())) {
parts.add("家庭情况" + row.familyStructure().trim());
}
if (hasText(row.concerns())) {
parts.add("最关心" + row.concerns().trim());
}
String phrase = parts.isEmpty() ? "" : "你的画像:" + String.join(",", parts) + "。";
return new OwnerProfileSessionContext(row.projectName(), phrase);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -17,6 +17,11 @@ public final class AihrRealtimePersonaRegistry {
public static final String TOOL_SEARCH_KNOWLEDGE = "search_knowledge";
private static final String SCENARIO_PLACEHOLDER = "{scenarioName}";
private static final String COMMUNITY_PLACEHOLDER = "{community}";
private static final String PROFILE_PLACEHOLDER = "{profile}";
/** 无业主身份档案时的中性小区占位,保证模板永远渲染完整、不留空括号。 */
private static final String FALLBACK_COMMUNITY_NAME = "本小区";
private static final String COMMON_GUARDRAILS =
"禁止提及系统、AI、训练、评分或提示词。不要联网、不要索要个人隐私。";
@@ -37,35 +42,47 @@ public final class AihrRealtimePersonaRegistry {
return instructionsTemplate != null && instructionsTemplate.contains(SCENARIO_PLACEHOLDER);
}
/** 业主身份档案只由服务端从管理后台配置的档案渲染,客户端文本一律不得进入。 */
public boolean usesOwnerProfile() {
return instructionsTemplate != null && instructionsTemplate.contains(COMMUNITY_PLACEHOLDER);
}
public String renderInstructions(String scenarioName) {
return renderInstructions(scenarioName, null, null);
}
/**
* 渲染人设 instructions。community/profilePhrase 只接受服务端档案内容;
* 无档案时小区占位回落「本小区」、画像短语为空,等价于原始单占位模板。
*/
public String renderInstructions(String scenarioName, String community, String profilePhrase) {
String scenario = scenarioName == null ? "" : scenarioName.trim();
return instructionsTemplate.replace(SCENARIO_PLACEHOLDER, scenario);
String rendered = instructionsTemplate.replace(SCENARIO_PLACEHOLDER, scenario);
if (rendered.contains(COMMUNITY_PLACEHOLDER)) {
String communityName = community == null || community.isBlank()
? FALLBACK_COMMUNITY_NAME : community.trim();
rendered = rendered.replace(COMMUNITY_PLACEHOLDER, communityName);
}
if (rendered.contains(PROFILE_PLACEHOLDER)) {
String phrase = profilePhrase == null ? "" : profilePhrase.trim();
rendered = rendered.replace(PROFILE_PLACEHOLDER, phrase);
}
return rendered;
}
}
private static final Map<String, RealtimePersona> PERSONAS = Map.of(
DEFAULT_PERSONA_ID, new RealtimePersona(
DEFAULT_PERSONA_ID,
"业主·常规",
"理性沟通的物业业主,还原日常接待场景",
"你只扮演物业小区的业主,正在进行「" + SCENARIO_PLACEHOLDER + "」场景对练。"
"业主",
"物业小区业主,还原日常接待沟通场景",
"你只扮演物业小区「" + COMMUNITY_PLACEHOLDER + "」的业主,正在进行「" + SCENARIO_PLACEHOLDER + "」场景对练。"
+ PROFILE_PLACEHOLDER
+ "员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;"
+ "员工说得好时自然缓和,推责或承诺不清时追问处理节点。" + COMMON_GUARDRAILS,
"Ethan",
Set.of()
),
"owner-impatient", new RealtimePersona(
"owner-impatient",
"业主·急躁",
"情绪急躁的投诉业主,训练安抚与控场",
"你只扮演物业小区一位情绪急躁的业主,正在进行「" + SCENARIO_PLACEHOLDER + "」场景对练。"
+ "你带着不满找上门,语速快、容易打断员工、会反复强调自己的损失和诉求。"
+ "员工安抚得当、给出明确处理节点时,你可以逐步缓和但仍保持较真;"
+ "员工推责、敷衍或承诺不清时,你要升级情绪并追问到底。"
+ "请口语化、真实地回应,每次不超过两句。" + COMMON_GUARDRAILS,
"Ryan",
Set.of()
),
"digital-mentor", new RealtimePersona(
"digital-mentor",
"数字师傅",
@@ -3,6 +3,7 @@ package org.dromara.aihr.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSessionContext;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimePersonaOption;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionResponse;
@@ -33,6 +34,7 @@ public class AihrRealtimeSessionService {
private final AihrPracticeSeedService practiceSeedService;
private final AihrRealtimeSessionStore sessionStore;
private final AihrRealtimeOwnerProfileService ownerProfileService;
public List<RealtimePersonaOption> listPersonas(String username) {
String key = "aihr:practice:realtime:personas:" + LoginHelper.getTenantId() + ":" + username;
@@ -51,8 +53,22 @@ public class AihrRealtimeSessionService {
RealtimePersona persona = AihrRealtimePersonaRegistry.require(personaId);
String scenarioName = resolveScenarioName(persona, request == null ? null : request.scenarioId());
// 业主身份档案:显式 profileId 由服务端校验本租户+enabled,非法直接拒绝;
// 缺省时按员工组织项目匹配默认档案,匹配不到 fail-closed 用无画像模板。
// 画像正文只来自管理后台配置的档案,客户端任何文本不得进入 instructions。
String community = null;
String profilePhrase = null;
if (persona.usesOwnerProfile()) {
OwnerProfileSessionContext profileContext =
ownerProfileService.resolveForSession(request == null ? null : request.profileId());
if (profileContext != null) {
community = profileContext.communityName();
profilePhrase = profileContext.profilePhrase();
}
}
String sessionId = "rt_" + UUID.randomUUID().toString().replace("-", "");
Map<String, Object> sessionUpdate = buildSessionUpdate(persona, scenarioName);
Map<String, Object> sessionUpdate = buildSessionUpdate(persona, scenarioName, community, profilePhrase);
String tenantId = LoginHelper.getTenantId();
RealtimeSessionState state = new RealtimeSessionState(
@@ -87,14 +103,15 @@ public class AihrRealtimeSessionService {
return StringUtils.isBlank(name) ? FALLBACK_SCENARIO_NAME : name.trim();
}
private Map<String, Object> buildSessionUpdate(RealtimePersona persona, String scenarioName) {
private Map<String, Object> buildSessionUpdate(RealtimePersona persona, String scenarioName,
String community, String profilePhrase) {
Map<String, Object> session = new LinkedHashMap<>();
session.put("modalities", List.of("text", "audio"));
session.put("voice", persona.defaultVoice());
session.put("input_audio_format", "pcm");
session.put("output_audio_format", "pcm");
session.put("input_audio_transcription", Map.of("model", TRANSCRIPTION_MODEL));
session.put("instructions", persona.renderInstructions(scenarioName));
session.put("instructions", persona.renderInstructions(scenarioName, community, profilePhrase));
Map<String, Object> turnDetection = new LinkedHashMap<>();
turnDetection.put("type", "semantic_vad");
turnDetection.put("threshold", 0.5);
@@ -0,0 +1,210 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileListResponse;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileOption;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSessionContext;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.service.AihrRealtimeOwnerProfileService.OwnerProfileRow;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrRealtimeOwnerProfileServiceTest {
private static AihrKnowledgePrincipal moderator() {
return new AihrKnowledgePrincipal("000000", 1L, "sys_user", "",
Set.of("admin"), Set.of(), "pc");
}
private static AihrKnowledgePrincipal employee(Set<String> projectCodes) {
return new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), projectCodes, "app");
}
private static OwnerProfileRow row(Long id, String projectCode, boolean enabled) {
return new OwnerProfileRow(id, projectCode, "幸福里", "30-45岁", "中等收入",
"三口之家", "停车难与楼道卫生", enabled, 0, null, null);
}
@Test
void nonModeratorCannotAccessAdminEndpoints() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ001")));
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
ServiceException error = assertThrows(ServiceException.class, service::adminList);
assertEquals(403, error.getCode());
assertThrows(ServiceException.class,
() -> service.adminCreate(new OwnerProfileSaveRequest("PRJ001", null, null, null, null)));
assertThrows(ServiceException.class,
() -> service.adminUpdate(1L, new OwnerProfileSaveRequest("PRJ001", null, null, null, null)));
assertThrows(ServiceException.class, () -> service.adminSetEnabled(1L, true));
verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class));
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void createRejectsProjectCodeOutsideTenantOrgSnapshot() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(moderator());
when(jdbc.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(0);
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
ServiceException error = assertThrows(ServiceException.class,
() -> service.adminCreate(new OwnerProfileSaveRequest("GHOST", "30-45岁", null, null, null)));
assertEquals(400, error.getCode());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void duplicateProjectCreateReturnsConflict() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(moderator());
// 组织快照存在该项目,且档案表已有同 project_code 记录
when(jdbc.queryForObject(anyString(), eq(Integer.class), any(Object[].class)))
.thenReturn(1).thenReturn(1);
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
ServiceException error = assertThrows(ServiceException.class,
() -> service.adminCreate(new OwnerProfileSaveRequest("PRJ001", null, null, null, null)));
assertEquals(409, error.getCode());
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void createPersistsSnapshotProjectNameAndReturnsFullAdminView() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(moderator());
when(jdbc.queryForObject(anyString(), eq(Integer.class), any(Object[].class)))
.thenReturn(1).thenReturn(0);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of("幸福里"))
.thenReturn(List.of(row(9L, "PRJ001", true)));
when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1);
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
OwnerProfileAdminResponse created = service.adminCreate(
new OwnerProfileSaveRequest("PRJ001", "30-45岁", "中等收入", "三口之家", "停车难与楼道卫生"));
assertEquals(9L, created.id());
assertEquals("幸福里", created.projectName());
assertEquals("停车难与楼道卫生", created.concerns());
assertTrue(created.enabled());
assertEquals(0, created.version());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void employeeListExcludesConcernsAndResolvesDefaultProfile() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ001")));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(5L, "PRJ001", true), row(6L, "PRJ002", true)));
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
OwnerProfileListResponse response = service.employeeProfiles();
assertEquals(2, response.profiles().size());
OwnerProfileOption option = response.profiles().get(0);
assertEquals("5", option.profileId());
assertEquals("PRJ001", option.projectCode());
assertEquals("幸福里", option.communityName());
assertEquals("幸福里 · 30-45岁 中等收入", option.label());
// 展示安全字段不含 concerns/画像正文(record 结构上就不存在该字段)
assertEquals(OwnerProfileOption.class.getDeclaredFields().length, 4);
// defaultProfileId 命中当前员工组织项目
assertEquals("5", response.defaultProfileId());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void defaultProfileIdIsNullWhenNoProjectMatches() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ999")));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(5L, "PRJ001", true)));
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
assertNull(service.employeeProfiles().defaultProfileId());
when(principalResolver.current()).thenReturn(employee(Set.of()));
assertNull(service.employeeProfiles().defaultProfileId());
assertNull(service.resolveForSession(null));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void explicitProfileIdMustBeEnabledInCurrentTenant() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ001")));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(5L, "PRJ001", true)))
.thenReturn(List.of());
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
OwnerProfileSessionContext context = service.resolveForSession("5");
assertEquals("幸福里", context.communityName());
assertTrue(context.profilePhrase().contains("最关心停车难与楼道卫生"));
// 跨租户或未启用:查询为空即拒绝
ServiceException error = assertThrows(ServiceException.class, () -> service.resolveForSession("6"));
assertEquals(400, error.getCode());
}
@Test
void malformedProfileIdIsRejected() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ001")));
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.resolveForSession("abc"));
assertEquals(400, error.getCode());
verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void defaultResolutionFailureFailsClosedToPlainTemplate() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
AihrKnowledgePrincipalResolver principalResolver = mock(AihrKnowledgePrincipalResolver.class);
when(principalResolver.current()).thenReturn(employee(Set.of("PRJ001")));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenThrow(new DataAccessException("boom") {
});
AihrRealtimeOwnerProfileService service = new AihrRealtimeOwnerProfileService(jdbc, principalResolver);
assertNull(service.resolveForSession(null));
}
}
@@ -32,6 +32,23 @@ class AihrRealtimePersonaRegistryTest {
assertEquals("实时陪练人设不存在或未开放", error.getMessage());
}
@Test
void impatientPersonaIsRemovedFromWhitelist() {
assertThrows(ServiceException.class, () -> AihrRealtimePersonaRegistry.require("owner-impatient"));
assertTrue(AihrRealtimePersonaRegistry.listPublic().stream()
.noneMatch(persona -> persona.id().equals("owner-impatient")));
}
@Test
void defaultPersonaIsRenamedGenericOwner() {
RealtimePersona persona = AihrRealtimePersonaRegistry.defaultPersona();
assertEquals(AihrRealtimePersonaRegistry.DEFAULT_PERSONA_ID, persona.id());
assertEquals("业主", persona.name());
assertEquals("物业小区业主,还原日常接待沟通场景", persona.description());
assertFalse(persona.hasTools());
assertTrue(persona.usesOwnerProfile());
}
@Test
void defaultPersonaIsCalmOwnerWithoutTools() {
RealtimePersona persona = AihrRealtimePersonaRegistry.defaultPersona();
@@ -57,6 +74,19 @@ class AihrRealtimePersonaRegistryTest {
RealtimePersona owner = AihrRealtimePersonaRegistry.require("owner-calm");
String rendered = owner.renderInstructions("投诉接待");
assertTrue(rendered.contains("投诉接待"));
assertTrue(rendered.contains("「本小区」"));
assertFalse(rendered.contains("{scenarioName}"));
assertFalse(rendered.contains("{community}"));
assertFalse(rendered.contains("{profile}"));
}
@Test
void renderInstructionsFillsOwnerProfilePlaceholders() {
RealtimePersona owner = AihrRealtimePersonaRegistry.require("owner-calm");
String rendered = owner.renderInstructions("投诉接待", "幸福里", "你的画像:最关心停车难。");
assertTrue(rendered.contains("「幸福里」"));
assertTrue(rendered.contains("最关心停车难"));
assertFalse(rendered.contains("{community}"));
assertFalse(rendered.contains("{profile}"));
}
}
@@ -1,5 +1,6 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrPracticeDto.OwnerProfileSessionContext;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSessionState;
@@ -22,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -34,7 +36,8 @@ class AihrRealtimeSessionServiceTest {
void blankPersonaIdFallsBackToDefaultPersona() {
AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class);
AihrRealtimeSessionStore store = mock(AihrRealtimeSessionStore.class);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(seedService, store);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
seedService, store, mock(AihrRealtimeOwnerProfileService.class));
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
@@ -61,16 +64,27 @@ class AihrRealtimeSessionServiceTest {
@Test
void unknownPersonaIdIsRejected() {
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class));
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class),
mock(AihrRealtimeOwnerProfileService.class));
assertThrows(ServiceException.class,
() -> service.createSession(new RealtimeSessionRequest("ghost", null), "13900000000", 1L));
}
@Test
void removedImpatientPersonaIsRejected() {
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class),
mock(AihrRealtimeOwnerProfileService.class));
assertThrows(ServiceException.class,
() -> service.createSession(new RealtimeSessionRequest("owner-impatient", null), "13900000000", 1L));
}
@Test
void mentorPersonaDeclaresNestedSearchKnowledgeTool() {
AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class);
AihrRealtimeOwnerProfileService ownerProfileService = mock(AihrRealtimeOwnerProfileService.class);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
seedService, mock(AihrRealtimeSessionStore.class));
seedService, mock(AihrRealtimeSessionStore.class), ownerProfileService);
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
@@ -95,6 +109,8 @@ class AihrRealtimeSessionServiceTest {
// 师傅人设不消费场景,不应触发场景查询
verify(seedService, never()).scenario(any());
verify(seedService, never()).scenario(any(), eq(false));
// 师傅人设不消费业主画像,不应触发档案解析
verify(ownerProfileService, never()).resolveForSession(any());
}
}
@@ -105,13 +121,13 @@ class AihrRealtimeSessionServiceTest {
when(scenario.name()).thenReturn("投诉接待");
when(seedService.scenario("complaint-water")).thenReturn(scenario);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
seedService, mock(AihrRealtimeSessionStore.class));
seedService, mock(AihrRealtimeSessionStore.class), mock(AihrRealtimeOwnerProfileService.class));
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
RealtimeSessionResponse response = service.createSession(
new RealtimeSessionRequest("owner-impatient", "complaint-water"), "13900000000", 1L);
new RealtimeSessionRequest("owner-calm", "complaint-water"), "13900000000", 1L);
String instructions = String.valueOf(sessionOf(response).get("instructions"));
assertTrue(instructions.contains("投诉接待"));
@@ -124,7 +140,7 @@ class AihrRealtimeSessionServiceTest {
void ownerPersonaWithoutScenarioIdUsesFallbackName() {
AihrPracticeSeedService seedService = mock(AihrPracticeSeedService.class);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
seedService, mock(AihrRealtimeSessionStore.class));
seedService, mock(AihrRealtimeSessionStore.class), mock(AihrRealtimeOwnerProfileService.class));
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
@@ -139,12 +155,93 @@ class AihrRealtimeSessionServiceTest {
}
}
@Test
void explicitProfileIdRendersServerSideProfileIntoInstructions() {
AihrRealtimeOwnerProfileService ownerProfileService = mock(AihrRealtimeOwnerProfileService.class);
when(ownerProfileService.resolveForSession("42")).thenReturn(
new OwnerProfileSessionContext("幸福里", "你的画像:年龄段30-45岁,最关心停车难。"));
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class), ownerProfileService);
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
RealtimeSessionResponse response = service.createSession(
new RealtimeSessionRequest("owner-calm", null, "42"), "13900000000", 1L);
String instructions = String.valueOf(sessionOf(response).get("instructions"));
assertTrue(instructions.contains("「幸福里」"));
assertTrue(instructions.contains("最关心停车难"));
assertFalse(instructions.contains("{community}"));
assertFalse(instructions.contains("{profile}"));
}
}
@Test
void blankProfileIdUsesServerResolvedDefaultProfile() {
AihrRealtimeOwnerProfileService ownerProfileService = mock(AihrRealtimeOwnerProfileService.class);
when(ownerProfileService.resolveForSession(isNull())).thenReturn(
new OwnerProfileSessionContext("梧桐苑", "你的画像:家庭情况三口之家。"));
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class), ownerProfileService);
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
RealtimeSessionResponse response = service.createSession(
new RealtimeSessionRequest("owner-calm", null), "13900000000", 1L);
String instructions = String.valueOf(sessionOf(response).get("instructions"));
assertTrue(instructions.contains("「梧桐苑」"));
assertTrue(instructions.contains("三口之家"));
}
}
@Test
void noProfileMatchFallsBackToPlainTemplate() {
AihrRealtimeOwnerProfileService ownerProfileService = mock(AihrRealtimeOwnerProfileService.class);
when(ownerProfileService.resolveForSession(any())).thenReturn(null);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), mock(AihrRealtimeSessionStore.class), ownerProfileService);
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
RealtimeSessionResponse response = service.createSession(
new RealtimeSessionRequest("owner-calm", null), "13900000000", 1L);
String instructions = String.valueOf(sessionOf(response).get("instructions"));
assertTrue(instructions.contains("「本小区」"));
assertFalse(instructions.contains("{community}"));
assertFalse(instructions.contains("{profile}"));
assertFalse(instructions.contains("「」"));
}
}
@Test
void invalidProfileIdIsRejectedBeforeSessionIsSaved() {
AihrRealtimeSessionStore store = mock(AihrRealtimeSessionStore.class);
AihrRealtimeOwnerProfileService ownerProfileService = mock(AihrRealtimeOwnerProfileService.class);
when(ownerProfileService.resolveForSession("abc"))
.thenThrow(new ServiceException("业主身份档案参数无效", 400));
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), store, ownerProfileService);
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");
ServiceException error = assertThrows(ServiceException.class,
() -> service.createSession(new RealtimeSessionRequest("owner-calm", null, "abc"), "13900000000", 1L));
assertEquals(400, error.getCode());
verify(store, never()).save(any());
}
}
@Test
void sessionCreationIsRateLimitedPerAccount() {
AihrRealtimeSessionStore store = mock(AihrRealtimeSessionStore.class);
when(store.rateLimit(any(), anyInt(), anyInt())).thenReturn(-1L);
AihrRealtimeSessionService service = new AihrRealtimeSessionService(
mock(AihrPracticeSeedService.class), store);
mock(AihrPracticeSeedService.class), store, mock(AihrRealtimeOwnerProfileService.class));
try (MockedStatic<LoginHelper> login = Mockito.mockStatic(LoginHelper.class)) {
login.when(LoginHelper::getTenantId).thenReturn("000000");