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:
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -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,
|
||||
|
||||
+306
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -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",
|
||||
"数字师傅",
|
||||
|
||||
+20
-3
@@ -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);
|
||||
|
||||
+210
@@ -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));
|
||||
}
|
||||
}
|
||||
+30
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
+104
-7
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user