diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileAdminController.java new file mode 100644 index 00000000..eb8bc518 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileAdminController.java @@ -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() { + return R.ok(ownerProfileService.adminList()); + } + + @PostMapping + public R create(@RequestBody OwnerProfileSaveRequest request) { + return R.ok(ownerProfileService.adminCreate(request)); + } + + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody OwnerProfileSaveRequest request) { + return R.ok(ownerProfileService.adminUpdate(id, request)); + } + + @PostMapping("/{id}/enable") + public R enable(@PathVariable Long id) { + ownerProfileService.adminSetEnabled(id, true); + return R.ok(); + } + + @PostMapping("/{id}/disable") + public R disable(@PathVariable Long id) { + ownerProfileService.adminSetEnabled(id, false); + return R.ok(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileController.java new file mode 100644 index 00000000..832fb00c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrRealtimeOwnerProfileController.java @@ -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 ownerProfiles() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + return R.fail("请使用员工端账号开启实时陪练"); + } + return R.ok(ownerProfileService.employeeProfiles()); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 211d99ee..9a5915e6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -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 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 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, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileService.java new file mode 100644 index 00000000..a516f283 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileService.java @@ -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 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 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 rows = enabledRows(principal.tenantId()); + List 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 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 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 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 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 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 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 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 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(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java index 88537b3b..774285ad 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java @@ -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 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", "数字师傅", diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java index bf488d36..2e731682 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java @@ -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 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 sessionUpdate = buildSessionUpdate(persona, scenarioName); + Map 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 buildSessionUpdate(RealtimePersona persona, String scenarioName) { + private Map buildSessionUpdate(RealtimePersona persona, String scenarioName, + String community, String profilePhrase) { Map 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 turnDetection = new LinkedHashMap<>(); turnDetection.put("type", "semantic_vad"); turnDetection.put("threshold", 0.5); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileServiceTest.java new file mode 100644 index 00000000..2b6e66be --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeOwnerProfileServiceTest.java @@ -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 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)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java index 3e26ce0d..021db817 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePersonaRegistryTest.java @@ -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}")); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java index 1531d6d8..c00a33db 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimeSessionServiceTest.java @@ -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 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 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 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 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 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 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 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 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 login = Mockito.mockStatic(LoginHelper.class)) { login.when(LoginHelper::getTenantId).thenReturn("000000"); diff --git a/backend/script/sql/update/aihr_20260823_realtime_owner_profile_mysql8.sql b/backend/script/sql/update/aihr_20260823_realtime_owner_profile_mysql8.sql new file mode 100644 index 00000000..eb33a638 --- /dev/null +++ b/backend/script/sql/update/aihr_20260823_realtime_owner_profile_mysql8.sql @@ -0,0 +1,24 @@ +-- Realtime practice owner identity profiles (content operations data). +-- Additive and idempotent on MySQL 8. Profiles are authored by tenant moderators from the +-- admin console; only enabled=1 rows are visible to employees and eligible for realtime +-- session rendering. The unique key keeps one profile per (tenant, project_code). + +CREATE TABLE IF NOT EXISTS `aihr_realtime_owner_profile` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL COMMENT '租户ID', + `project_code` varchar(50) NOT NULL COMMENT '项目编码(小区=项目编码,须存在于本租户组织快照)', + `project_name` varchar(100) NOT NULL COMMENT '项目名称冗余显示名,写入时取自组织快照', + `age_range` varchar(50) DEFAULT NULL COMMENT '业主年龄段,如 30-45岁', + `income_level` varchar(50) DEFAULT NULL COMMENT '收入水平描述,如 中等收入', + `family_structure` varchar(100) DEFAULT NULL COMMENT '家庭结构描述,如 三口之家有学龄儿童', + `concerns` varchar(500) DEFAULT NULL COMMENT '核心关注点;只进服务端画像渲染,不下发员工端列表', + `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '内容运营开关;仅 enabled=1 对员工可见并可渲染进会话', + `version` int NOT NULL DEFAULT 0 COMMENT '内容版本,编辑递增', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_realtime_owner_profile` (`tenant_id`, `project_code`), + KEY `idx_aihr_realtime_owner_profile_enabled` (`tenant_id`, `enabled`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='实时陪练业主身份档案;画像属内容运营数据,enabled=1 才对员工可见'; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 5bd0c4a9..37cec241 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -15,7 +15,8 @@ | M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | ASR 按供应商分流:SiliconFlow 等 OpenAI audio 兼容服务继续走 `/audio/transcriptions` multipart;阿里云百炼 `dashscope/qianwen + qwen3-asr-flash` 走工作空间 `/compatible-mode/v1/chat/completions`,将短音频编码为 Base64 `input_audio` 并读取 `choices[0].message.content`,请求上限仍由本接口收窄为 5MB。生产 TTS 优先阿里 `qwen-audio-3.0-tts-flash`,旧 SiliconFlow CosyVoice2 保留兼容;按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 | -| 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | H5 浏览器及 App-Plus renderjs WebView 内的 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,客户端原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时客户端回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;H5 与 App-Plus 均支持,App-Plus 在 renderjs 视图层执行媒体/WebRTC 并通过逻辑层复用受认证 SDP 与工具接口;需 APP 员工登录态,不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | +| 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | H5 浏览器及 App-Plus renderjs WebView 内的 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主(默认)、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,客户端原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时客户端回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;H5 与 App-Plus 均支持,App-Plus 在 renderjs 视图层执行媒体/WebRTC 并通过逻辑层复用受认证 SDP 与工具接口;需 APP 员工登录态,不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | +| 实时对练业主身份档案 | 员工 `GET /api/aihr/practice/realtime/owner-profiles`;管理 `GET/POST /api/aihr/realtime/owner-profiles`、`PUT /api/aihr/realtime/owner-profiles/{id}`、`POST /api/aihr/realtime/owner-profiles/{id}/enable`、`POST /api/aihr/realtime/owner-profiles/{id}/disable`;会话接线 `POST /api/train/practice/realtime/session` 请求体新增可空 `profileId` | 画像属内容运营数据,表 `aihr_realtime_owner_profile`(迁移 `aihr_20260823_realtime_owner_profile_mysql8.sql`,`(tenant_id, project_code)` 唯一),仅 `enabled=1` 对员工可见。管理端角色门按 question-admin 的 moderator 纪律在服务端硬校验;`projectCode` 必须存在于本租户组织快照,`projectName` 由服务端从快照取权威显示名,客户端提交的同名无效;写操作幂等口径为**同 `projectCode` 重复 create 返回 409 冲突**,请改用编辑,编辑版本递增。员工端只下发展示安全字段 `{profileId, projectCode, communityName, label}`(id 序列化为字符串,label 由服务端拼接),**不下发 concerns 或画像正文**,`defaultProfileId` 由服务端按当前员工组织项目匹配,无命中为 null。会话渲染一律服务端完成:显式 `profileId` 校验属本租户且 enabled,非法直接拒绝且不建会话;缺省时按员工组织项目自动匹配默认档案;匹配不到或查询失败 fail-closed 用无画像模板(小区占位回落「本小区」),不报错打断。画像短语(含 concerns)只由服务端按档案字段拼接渲染进 owner 模板的 `{community}`/`{profile}` 占位,**客户端任何文本不得进入 instructions**;实时 Beta 不计分、不持久化、工具白名单与三重校验不变 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`POST /records/{caseId}/publish`、`POST /records/{caseId}/retire`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;`/curate` 只进入经验候选池,`/review` 只完成业务审核,`/publish` 才能在来源授权、脱敏、适用岗位、负责人、版本和生效期齐全时进入正式经验库,`/retire` 要求填写下线原因并立即停止员工召回;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、候选、审核、发布、下线、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情/音频只返回 `knowledge_status=PUBLISHED` 且处于生效期的 `已入库` 案例,并叠加岗位门:`applicable_positions` 为空(通用)或包含服务端按认证手机号从 `aihr_org_snapshot` 解析的在职岗位,岗位解析不到时仅通用经验可见;管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index ecafd6d8..247e195a 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -186,6 +186,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260823_realtime_owner_profile_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` @@ -197,6 +198,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso `aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql` 位于全部知识生命周期迁移之后:为 `aihr_case_record` 增加 `source_ref` 来源引用列和 `(tenant_id, source_type, source_ref)` 唯一键,支撑“最佳答案转经验候选”的幂等追溯;为 `aihr_community_answer` 增加 `(tenant_id, is_best, answer_status)` 索引以服务问模块最佳答案只读检索。脚本只加列和索引,不批准、不发布、不改写任何既有案例或回答。 +`aihr_20260823_realtime_owner_profile_mysql8.sql` 紧随 20260822 之后:新建 `aihr_realtime_owner_profile` 实时陪练业主身份档案表(`(tenant_id, project_code)` 唯一键)。画像属内容运营数据,脚本只建空表,不写入、不启用任何画像;档案由管理端 moderator 逐项目维护,仅 `enabled=1` 对员工可见并可渲染进实时会话。 + 生产物理删除不是部署步骤,也不得与 schema/JAR 发布同窗口批量执行。日常纠错只做 `withdraw`;`purge` 必须有业务/法务确认的保留分类、影响快照、异人批准和已到期 `execute_after`。活动 generation 仍包含该资产时,先构建并启用排除该资产的新 generation,系统不会允许直接清理;清理成功后,受影响的非活动代次会被自动重算并标记失效,不能再作为回滚目标。发布后先运行 reconciliation 并处理不一致,再考虑清理。ENFORCED 启用后旧 generation 保留七天;窗口内指标恶化且旧代次未失效时可从管理端回退,超过窗口或旧代次已失效时必须重新构建候选 generation 和重新通过门禁。 8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。 @@ -235,6 +238,7 @@ WHERE table_schema = DATABASE() 'aihr_work_report', 'aihr_broadcast_message', 'aihr_broadcast_read', 'aihr_broadcast_version', 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient', 'aihr_broadcast_attachment', 'aihr_direct_feedback', + 'aihr_realtime_owner_profile', 'aihr_personal_space', 'aihr_personal_item', 'aihr_personal_fragment', 'aihr_personal_chat_session', 'aihr_personal_chat_message', 'aihr_personal_cleanup_job', 'aihr_personal_ocr_job', 'aihr_personal_ocr_page', 'aihr_personal_export_task',