Compare commits
10
Commits
4e7c1a2e80
...
f007b7ab88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f007b7ab88 | ||
|
|
af64c9a997 | ||
|
|
46950b39fe | ||
|
|
c82440a86d | ||
|
|
b5389dc712 | ||
|
|
8e288e67e9 | ||
|
|
6b8c89bcdb | ||
|
|
d9ff43eae3 | ||
|
|
83fb657b1b | ||
|
|
66ce7c58bf |
@@ -4,6 +4,9 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **2026-08-05,`0.1.20 (120)` 内测包通过门禁并完成真机验收;手册打标产品化已上线**:`bangdao-0.1.20-dcloud-test.apk`(33,935,485 字节,SHA-256 `B2E238596651A265E26B46662B1B612A14CA6170BF5F3D20EF83799B2F2F784F`,DCloud 云端证书 `AB06ECC0…EF5D`)经拆包门禁核验:API 35、v2 单签名、`debuggable=0`、禁止明文流量、`prompt=none`、包内 `app-service.js` 与当前源码重建产物逐字节一致、无手机号/固定码/密钥泄漏。vivo V2443A 真机完成:固定码登录、问模块有据回答(引用《关于规范财务相关报备流程的通知.pdf》+「单月未超500元无明确依据」的边界标注)、零依据 ROUTE 出口(转人工/查全网)、实时陪练单人设与业主身份选择器默认本小区、实时会话进入。管理端「资料处理 → 质量审核」authority 下拉新增「操作手册/培训手册」预置并按文件名建议预填(commit `af64c9a9`,已发布,整站备份 `/opt/wygj/backups/www-pre-manual-authority-20260805-092505`)。注意:真机 09:29 曾出现一次「发送无反应」,当时该会话 `/mobile/me` 身份解析连续报错且后端未收到发送请求,重进后自愈,根因未完全定位,列为观察项。存量手册/制度改标仍需业务确认清单;该包仍为 DCloud 测试签名,受控分发与企业正式签名见 Go/No-Go。
|
||||
- **2026-08-05,实时陪练业主身份档案与「业主·急躁」下线已发布**:按客户要求删除 `owner-impatient` 人设(`owner-calm` 改名「业主」,id 不变);新增 `aihr_realtime_owner_profile` 业主身份档案(迁移 `aihr_20260823_realtime_owner_profile_mysql8.sql` 已在生产执行,`(tenant_id, project_code)` 唯一键,画像属内容运营数据、enabled=1 才对员工可见)。管理端训练运营新增「业主身份档案」配置页(moderator 角色门、projectCode 必须命中本租户组织快照、projectName 以快照为准、concerns 限 500 字带内容纪律提示、重复创建 409);员工端实时陪练新增「业主身份」选择器(默认本小区 `defaultProfileId`、可改选或显式不指定、storage 记忆、接口失败静默隐藏),员工接口只返回 `{profileId, projectCode, communityName, label}` 展示安全字段,画像正文不下发。会话渲染 `{community}/{profile}` 全部由服务端完成,客户端文本无法进入 instructions;显式 profileId 非法/跨租户/未启用 400 拒绝,默认解析 fail-closed 回落无画像模板。实时 Beta 不计分不持久化、工具白名单与三重校验不变。后端 JAR SHA-256 `234888996a82ddc5e7d36ee62f0b5086f32c9a1ac95e51bf7ce83fbb01100e0c` 经定向发布脚本完成备份、原子切换与发布后预检(模块哈希一致),回滚备份 `/opt/wygj/backups/backend-20260805032157-f0d017829a9c`;移动 H5 与管理端静态已同步发布(整站备份 `/opt/wygj/backups/www-pre-owner-profiles-20260805-032337`),根站、`/h5/` 200,`/api/train/practice/realtime/personas` 与 `/api/aihr/practice/realtime/owner-profiles` 均在线且正确鉴权。后端 `1057/1057`、移动端 typecheck 与 H5 构建、管理端构建通过。同日在 vivo V2443A 真机(0.1.20 内测包)完成认证态复验:管理端配置银河湾星苑档案并启用后,实时陪练「业主」人设下出现「业主身份」选择器并默认选中本小区,实时会话可正常进入;人设列表动态下架「业主·急躁」。
|
||||
- **2026-08-05,问模块四来源(知识空间/公网/经验库/有奖征答)已发布**:员工问答在既有知识空间与公网同意闸之外新增两个 REFERENCE 级增强来源——正式经验库(`aihr_case_record`,`已入库+PUBLISHED+生效期+岗位门`,chip「正式经验」)与有奖征答最佳答案(`PUBLISHED+is_best=1` 且问题 `PUBLISHED/CLOSED`,只读 SQL 不含提交人身份,chip「同事经验·有奖征答」)。岗位由服务端按组织身份解析(`AihrEmployeePositionResolver`),经验/案例类硬过滤非适用岗位,形式制度不受影响,解析失败 fail-closed 仅通用可见;两增强源只追加引用、不稀释 FORMAL 答案、失败不打断主链,零文档证据时仲裁从 ROUTE 升为 GUIDANCE。管理端问题榜新增「转为经验候选」(moderator 门、幂等、固定 CANDIDATE 且 source_authorized/deidentified=0,绝不自动发布);移动端引用 chip 直达案例详情/问题详情,已点「有用」的答案禁用转人工按钮。迁移 `aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql` 已在生产执行并核验(备份 `/opt/wygj/backups/pre-20260822-four-source-20260805-024645`)。后端 JAR SHA-256 `f0d017829a9c2415a56e185bba2b138c7ccda2017d5d9a95a6d1b2ef070e548d` 经定向发布脚本完成备份、原子切换与发布后预检(schema `83/83`、collation `0/0`、模块哈希一致),回滚备份 `/opt/wygj/backups/backend-20260805024804-f1fe73d8c499`;移动 H5 与管理端静态已同步发布(整站备份 `/opt/wygj/backups/www-pre-four-source-20260805-025018`),根站与 `/h5/` 均 200。后端 `1040/1040`、移动端 typecheck 与 H5 构建、管理端构建通过;**四源链路尚未做生产认证态真机复验**(经验库/最佳答案需存在已发布内容才会命中),`0.1.18` 云打包 APK、签名包验收与受控分发仍未完成。
|
||||
- **2026-08-05,问模块证据分级、澄清闭环与 ROUTE 出口已发布并完成真机验证**:员工问答从“正式源检索封锁 + 官腔拒答”改为四级证据仲裁(`FULL/PARTIAL/GUIDANCE/ROUTE`):`AihrEvidenceGradingService` 为唯一仲裁点,`GUIDANCE` 允许参考经验出流程答案但强制标注“非正式制度依据”,政策断言只允许 FORMAL 引用(policyClaims 后置校验 + 金额断言启发式兜底);程序性问题不再追问金额语义,挂起澄清随 30 分钟会话闭环(已回答/放弃/新话题,同一澄清最多问一轮);`ROUTE` 给出转人工/直通财务/查全网可执行出口,`aihr_knowledge_answer_feedback.fragment_id` 可空使零引用答案也能走“自动补 down 反馈 + 确认”的幂等转人工链。迁移 `aihr_20260804_pending_clarification_mysql8.sql`、`aihr_20260804_feedback_fragment_nullable_mysql8.sql` 及 `aihr_20260804_unify_all_collations_mysql8.sql` 已在生产执行并核验(执行前备份相关表至 `/opt/wygj/backups/pre-20260804-ask-refactor-20260805-005414`)。后端 JAR SHA-256 `f1fe73d8c499f75bcd44f52893033d21910fec6ba2745598874dd7d73f54735d` 经定向发布脚本备份、原子切换与发布后预检(schema `83/83`、collation `0/0`、模块哈希一致),回滚备份 `/opt/wygj/backups/backend-20260805005921-74d0cd2acc60`;移动 H5 已同步发布(远端备份 `/opt/wygj/backups/h5-pre-ask-refactor-20260805-010119`)。后端 `1014/1014` 测试、移动端 typecheck 与 H5/App 构建通过;vivo V2443A(Android 15)经 HBuilderX 5.23 标准基座完成固定码登录和“单笔500元采购报销”发问、追问放弃澄清、出口按钮组的真实链路验证。`@dcloudio/*` 编译器同步升级为 `3.0.0-alpha-5020320260803001`(对齐 HBuilderX-Alpha 5.23),版本号升至 `0.1.18 (118)`。标准基座验证不算签名包验收:`0.1.18` 云打包 APK、APK 门禁、真机正式验收和受控分发均未完成;非金额类政策断言的分级依赖模型诚实标注(观察项),空片段反馈并发双击可能产生冗余行(有幂等兜底),已点“有用”的答案上转人工会被 409 拒绝(前端待禁用)。
|
||||
- **2026-08-04,受约束 Grounded Agent 与财务 RAG 证据门禁完成实现并受控发布**:新增 `ResolvedContext/SemanticQueryPlan/ExecutionContext/ToolCall/ToolResult/DocumentEvidence/VerifiedFact/DecisionResult/GroundedAnswer` 契约,接入 `MY_CURRENT_TASKS + KNOWLEDGE_SEARCH` 双工具、服务端授权参数注入、有界工具执行、候选调用去重、受控超时、证据槽位/来源权威/正文蕴含门禁、逐结论引用校验及结构化会话状态。财务金额覆盖 `¥500`、`五百块`、`不到500`、单笔/月累计与口径冲突;知识检索为不生成答案的授权证据工具,并将候选召回、rerank 和最终 evidence/display 截断分层。生产后端以 JAR SHA-256 `74d0cd2acc60ebf52f20eb8bee643f59395ba7cff3583076d671e57406f824ac` 发布,`wygj-aihr.service` 为 `active`,schema `83/83`,公开根站、租户列表和移动首页 HTTP 200;生产 `ACTIVE` 仅覆盖文本 `KNOWLEDGE_QA`/`LIVE_MY_WORK`,其余请求和不合格计划回退旧链路。30 条纯内存黄金场景、RAG/资料治理/Agent 新链路 166 项、旧 Planner/Orchestrator/会话兼容 45 项及 additive MySQL 8 迁移回归通过。认证态用户逐例问答、客户端状态 UI 全量验收、真实模型 + MySQL/Qdrant 的完整召回评测和 100–300 条人工黄金集仍待完成,不能据此宣称生产召回率达标。
|
||||
- **2026-08-02,功能复核清单首批增量已实现并完成本地验证,未部署**:当前工作树新增大喇叭 `ALL/TARGET_ONLY` 可见性、受控在线查看与动态水印、下载门禁和带原文依据的主题标签;问师傅未解决反馈确认转待审核问题;正式学习任务、审核题库随机组卷及考试关联;案例经验“候选→审核→发布→下线”;原生异常恢复回归和资料暂存盘容量健康门禁。管理端生产构建、移动端类型检查与 `216/216` 单测、后端 `838/838` 测试及迁移静态清单通过;`390×844` 已验证员工主要入口和登录门禁。Docker/MySQL 未运行,四个 `aihr_20260802_*` 迁移尚未执行,管理端认证业务、峰值压测、签名 APK、真机、生产发布和严格试点均未验收。
|
||||
|
||||
+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");
|
||||
|
||||
@@ -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 才对员工可见';
|
||||
@@ -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`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -82,7 +82,9 @@ APK 门禁还会比较包内与当前 `dist/build/app/app-service.js` 的 SHA-25
|
||||
|
||||
2026-07-28 最终自定义基座大小为 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`。2026-07-29 最后一个已生成并可重复校验的独立内测包为 `bangdao-0.1.13-dcloud-test.apk`:包名 `com.yincheng.wygj`、版本 `0.1.13 (113)`、`targetSdkVersion=35`,大小 `33,921,543` 字节,SHA-256 为 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`。APK 反编译门禁确认包内业务资源等于冻结时的 App-Plus 编译产物、启动原生隐私提示已关闭、登录页法务链接与未勾选拦截及身份加载失败关闭已进入包体,并通过敏感值、`usesCleartextTraffic=false`、`networkSecurityConfig` 和系统证书信任检查。干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的统一 RC 已完成,`releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节、SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,`ops9c53668c`、`0.1.12` 及更早 RC/交付 ZIP 只保留为历史基线。当前 AVD 为 `x86_64`,APK 原生库仅含 `arm64-v8a/armeabi-v7a`;安装和原生外壳可由系统转译运行,但 DCloud Weex JS 进程实际出现 `spinWaitPeer`/IPC 超时,页面主体空白不能用于判断产品页面。浏览器验证只作补充,不替代 App 运行;不会为了模拟器改变正式 ABI、回退为 H5 壳或增加业务兜底。该包仍使用 DCloud 云端测试证书,只能作为 8 月 1 日受控内测包;受控分发、法务例外和测试签名接受当前均待业务签认,企业正式 release 还必须使用签认后的企业证书摘要。
|
||||
|
||||
当前源码和 DCloud 测试签名候选均为 `0.1.17 (117)`。APK 大小 `33,922,976` 字节,SHA-256 为 `0A8581D70A5988E4BDCF4BB3F0C88B4608FA8C6785FDCF1569DD56CE88E27F60`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`;拆包门禁已确认 API 35、禁止明文流量、登录法务和包内业务资源与当前 App-Plus 构建一致。同一 APK 已在 vivo Android 15 真机完成登录、麦克风允许/拒绝/恢复、系统选择器取消、音频选择/生产 ASR 和成果投稿确认。它仍是 DCloud 测试签名,不等于企业正式 release;受控分发、法务/签名接受和剩余设备门禁见 Go/No-Go。
|
||||
当前源码和 DCloud 测试签名候选均为 `0.1.20 (120)`(`bangdao-0.1.20-dcloud-test.apk`,2026-08-05 经 HBuilderX 5.23 CLI 云打包)。APK 大小 `33,935,485` 字节,SHA-256 为 `B2E238596651A265E26B46662B1B612A14CA6170BF5F3D20EF83799B2F2F784F`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`;拆包门禁确认包名 `com.yincheng.wygj`、API 35、v2 单签名、`debuggable=0`、`allowBackup=0`、禁止明文流量、`networkSecurityConfig` 存在、`androidPrivacy.json` 为 `prompt=none`、登录法务链接在包内、无手机号/固定码/密钥类敏感值,且包内 `app-service.js` SHA-256 为 `9A90A64A3106C2F1438FCA9095C175A1AC33AEE9B9B2E6FB27ED3248CF466A04`(与当前源码 App-Plus 重建产物逐字节一致)。同一 APK 已在 vivo V2443A(Android 15)覆盖安装并完成:固定码登录、问模块有据回答(引用+边界标注)、零依据 ROUTE 出口按钮、实时陪练人设列表(业主/数字师傅,无急躁)、业主身份选择器默认本小区(管理端配置档案后)、实时会话进入。它仍是 DCloud 测试签名,不等于企业正式 release;受控分发、法务/签名接受见 Go/No-Go。注意设备上曾存在 8 月 3 日旧代码的 `0.1.19 (119)`,版本号高于其代码新旧,`0.1.18 (118)` 内测包未装机即被 `0.1.20` 取代。
|
||||
|
||||
历史候选 `0.1.17 (117)`:APK 大小 `33,922,976` 字节,SHA-256 为 `0A8581D70A5988E4BDCF4BB3F0C88B4608FA8C6785FDCF1569DD56CE88E27F60`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`;拆包门禁已确认 API 35、禁止明文流量、登录法务和包内业务资源与当前 App-Plus 构建一致。同一 APK 已在 vivo Android 15 真机完成登录、麦克风允许/拒绝/恢复、系统选择器取消、音频选择/生产 ASR 和成果投稿确认。它仍是 DCloud 测试签名,不等于企业正式 release;受控分发、法务/签名接受和剩余设备门禁见 Go/No-Go。
|
||||
|
||||
企业正式签名 APK 还必须用发布负责人保管的证书摘要执行 release 模式核验;该模式会额外拒绝 `debuggable=true`、明文 HTTP 和签名摘要不一致:
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = { code: number; msg: string; data: T };
|
||||
|
||||
/**
|
||||
* 业主身份档案(实时对练 persona 的运营数据),moderator 角色门,与 question-admin 同口径。
|
||||
* 后端契约:/api/aihr/realtime/owner-profiles
|
||||
* - GET / 列表(本租户,含 enabled/version/审计字段)
|
||||
* - POST / 创建(同 projectCode 重复返回冲突)
|
||||
* - PUT /{id} 全量覆盖更新(联调时若后端改乐观锁可在此集中调整)
|
||||
* - POST /{id}/enable、POST /{id}/disable 启停(若后端改为 PUT /{id}/status 或全量带 enabled,
|
||||
* 只需调整下方 setOwnerProfileEnabled 一处封装)
|
||||
*/
|
||||
|
||||
export type OwnerProfile = {
|
||||
id: number;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
ageRange: string;
|
||||
incomeLevel: string;
|
||||
familyStructure: string;
|
||||
concerns: string;
|
||||
enabled: boolean;
|
||||
version?: number;
|
||||
createBy?: string;
|
||||
createTime?: string;
|
||||
updateBy?: string;
|
||||
updateTime?: string;
|
||||
};
|
||||
|
||||
export type OwnerProfilePayload = {
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
ageRange: string;
|
||||
incomeLevel: string;
|
||||
familyStructure: string;
|
||||
concerns: string;
|
||||
};
|
||||
|
||||
export const listOwnerProfiles = (): Promise<ApiResult<OwnerProfile[]>> => request({
|
||||
url: '/api/aihr/realtime/owner-profiles', method: 'get'
|
||||
});
|
||||
|
||||
export const createOwnerProfile = (data: OwnerProfilePayload): Promise<ApiResult<OwnerProfile>> => request({
|
||||
url: '/api/aihr/realtime/owner-profiles', method: 'post', data, headers: { repeatSubmit: false }
|
||||
});
|
||||
|
||||
export const updateOwnerProfile = (id: number, data: OwnerProfilePayload): Promise<ApiResult<OwnerProfile>> => request({
|
||||
url: `/api/aihr/realtime/owner-profiles/${id}`, method: 'put', data, headers: { repeatSubmit: false }
|
||||
});
|
||||
|
||||
/**
|
||||
* 启停统一入口:先按独立 enable/disable 端点实现;
|
||||
* 若后端实际提供 PUT /{id}/status(带 enabled)或仅支持 PUT 全量带 enabled 字段,
|
||||
* 联调时只改这一个函数即可。
|
||||
*/
|
||||
export const setOwnerProfileEnabled = (id: number, enabled: boolean): Promise<ApiResult<OwnerProfile>> => request({
|
||||
url: `/api/aihr/realtime/owner-profiles/${id}/${enabled ? 'enable' : 'disable'}`, method: 'post', headers: { repeatSubmit: false }
|
||||
});
|
||||
@@ -132,6 +132,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
name: 'TrainTasks',
|
||||
meta: { title: '任务运营', icon: 'date' }
|
||||
},
|
||||
{
|
||||
path: 'owner-profiles',
|
||||
component: () => import('@/views/train/owner-profiles.vue'),
|
||||
name: 'TrainOwnerProfiles',
|
||||
meta: { title: '业主身份档案', icon: 'user' }
|
||||
},
|
||||
{
|
||||
path: 'rubrics',
|
||||
component: () => import('@/views/train/rubrics.vue'),
|
||||
|
||||
@@ -1083,9 +1083,11 @@
|
||||
<el-select v-model="qualityReviewForm.sourceAuthority" filterable allow-create>
|
||||
<el-option label="公司正式制度" value="COMPANY_POLICY" />
|
||||
<el-option label="已批准 SOP" value="APPROVED_SOP" />
|
||||
<el-option label="操作手册/培训手册" value="OPERATING_MANUAL" />
|
||||
<el-option label="法规" value="REGULATION" />
|
||||
<el-option label="员工经验投稿" value="EMPLOYEE_SUBMISSION" />
|
||||
</el-select>
|
||||
<p v-if="authoritySuggested" class="authority-suggestion-tip">已按文件名建议:操作手册/培训手册</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源版本">
|
||||
<el-input v-model="qualityReviewForm.sourceVersion" />
|
||||
@@ -1549,6 +1551,10 @@ const qualityReviewForm = ref<QualityReviewRequest>({
|
||||
acceptedReasonCodes: [],
|
||||
supersedesAssetId: undefined
|
||||
});
|
||||
const authoritySuggestedByName = ref(false);
|
||||
const authoritySuggested = computed(
|
||||
() => authoritySuggestedByName.value && qualityReviewForm.value.sourceAuthority === 'OPERATING_MANUAL'
|
||||
);
|
||||
const overview = ref<ProcessingOverview | null>(null);
|
||||
const overviewFailed = ref(false);
|
||||
const categoryKeyword = ref('');
|
||||
@@ -2803,6 +2809,13 @@ async function openQualityReview(asset: QualityAsset) {
|
||||
acceptedReasonCodes: [],
|
||||
supersedesAssetId: undefined
|
||||
};
|
||||
// 文件名含「手册/指南/教材」且来源权威级别未填写或仍为默认「员工经验投稿」时,按文件名建议预填,不强制、可改。
|
||||
const currentAuthority = (asset.sourceAuthority || '').trim();
|
||||
const manualNameMatched = /手册|指南|教材/.test(asset.sourceName || '');
|
||||
authoritySuggestedByName.value = manualNameMatched && (!currentAuthority || currentAuthority === 'EMPLOYEE_SUBMISSION');
|
||||
if (authoritySuggestedByName.value) {
|
||||
qualityReviewForm.value.sourceAuthority = 'OPERATING_MANUAL';
|
||||
}
|
||||
qualityScopeModes.value = {
|
||||
applicableRole: asset.applicableRole?.trim() ? 'SPECIFIED' : 'ALL',
|
||||
applicableRegion: asset.applicableRegion?.trim() ? 'SPECIFIED' : 'ALL',
|
||||
@@ -3051,6 +3064,7 @@ function sourceAuthorityLabel(value: string) {
|
||||
FORMAL_POLICY: '正式制度',
|
||||
COMPANY_POLICY: '公司正式制度',
|
||||
APPROVED_SOP: '已批准 SOP',
|
||||
OPERATING_MANUAL: '操作手册/培训手册',
|
||||
REGULATION: '法规',
|
||||
EMPLOYEE_SUBMISSION: '员工经验投稿',
|
||||
UNKNOWN: '来源待确认'
|
||||
@@ -4279,6 +4293,13 @@ async function requestAssetPurge(asset: QualityAsset) {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.authority-suggestion-tip {
|
||||
margin: 4px 0 0;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quality-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="owner-profiles-page">
|
||||
<section class="page-head">
|
||||
<div><h1>业主身份档案</h1><p>按项目维护实时对练的业主画像(年龄段、收入水平、家庭结构与关注点),启停后影响对练人设选择。</p></div>
|
||||
<div class="head-actions"><el-button type="danger" :disabled="!canModerate" @click="openEdit()">新建档案</el-button><el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<el-table v-loading="loading" :data="profiles" empty-text="暂无业主档案">
|
||||
<el-table-column label="小区/项目" min-width="180"><template #default="{ row }">{{ row.projectName || row.projectCode }}<span class="muted">({{ row.projectCode }})</span></template></el-table-column>
|
||||
<el-table-column prop="ageRange" label="年龄段" width="110" />
|
||||
<el-table-column prop="incomeLevel" label="收入水平" width="110" />
|
||||
<el-table-column prop="familyStructure" label="家庭结构" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="concerns" label="关注点" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="启用状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="updateTime" label="更新时间" width="170" />
|
||||
<el-table-column v-if="canModerate" label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link :type="row.enabled ? 'warning' : 'success'" :loading="togglingId === row.id" @click="toggleEnabled(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑业主档案' : '新建业主档案'" width="640px">
|
||||
<el-form label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="项目(小区)" required>
|
||||
<el-select v-model="form.projectCode" filterable allow-create default-first-option class="full" placeholder="选择组织项目,或手输项目编码" :disabled="Boolean(form.id)" @change="onProjectChange">
|
||||
<el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称" required><el-input v-model="form.projectName" maxlength="60" :disabled="Boolean(form.id)" placeholder="选择项目后自动带出,可修正" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="年龄段" required><el-input v-model="form.ageRange" maxlength="30" placeholder="如 30-45 岁" /></el-form-item>
|
||||
<el-form-item label="收入水平" required><el-input v-model="form.incomeLevel" maxlength="30" placeholder="如 中等收入" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="家庭结构" required><el-input v-model="form.familyStructure" maxlength="100" placeholder="如 三口之家、与老人同住" /></el-form-item>
|
||||
<el-form-item label="关注点" required>
|
||||
<el-input v-model="form.concerns" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="业主对物业服务的典型关注点,用于对练人设" />
|
||||
<p class="concern-tip">画像属内容运营数据,避免刻板与歧视性描述</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="submit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainOwnerProfiles" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { getOrgSnapshot, type OrgProjectOption } from '@/api/aihr/mobile-admin';
|
||||
import { createOwnerProfile, listOwnerProfiles, setOwnerProfileEnabled, updateOwnerProfile, type OwnerProfile, type OwnerProfilePayload } from '@/api/aihr/realtime-owner-profiles';
|
||||
import auth from '@/plugins/auth';
|
||||
import { operationsConsoleRoles } from '@/router/access';
|
||||
|
||||
// 权限显示口径同 operationsConsoleRoles:本页路由 meta.roles 已限定(superadmin/hr_operator),
|
||||
// 后端接口仍按 moderator 角色门二次强校验;此处仅控制操作按钮展示。
|
||||
const canModerate = computed(() => auth.hasRoleOr([...operationsConsoleRoles]));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const togglingId = ref<number | null>(null);
|
||||
const profiles = ref<OwnerProfile[]>([]);
|
||||
const projectOptions = ref<OrgProjectOption[]>([]);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
type OwnerProfileForm = OwnerProfilePayload & { id?: number };
|
||||
const emptyForm = (): OwnerProfileForm => ({ projectCode: '', projectName: '', ageRange: '', incomeLevel: '', familyStructure: '', concerns: '' });
|
||||
const form = reactive<OwnerProfileForm>(emptyForm());
|
||||
|
||||
const loadProfiles = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
profiles.value = (await listOwnerProfiles()).data || [];
|
||||
} catch {
|
||||
profiles.value = [];
|
||||
ElMessage.error('业主档案列表读取失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 项目候选复用组织快照 projects(与任务派发下拉同一数据来源);
|
||||
// 快照不可用时项目下拉允许手输 projectCode + projectName。
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const result = await getOrgSnapshot({ status: 'active', pageNum: 1, pageSize: 1 });
|
||||
projectOptions.value = result.data?.projects || [];
|
||||
} catch {
|
||||
projectOptions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadAll = () => Promise.all([loadProfiles(), loadProjects()]);
|
||||
|
||||
const onProjectChange = (code: string) => {
|
||||
const matched = projectOptions.value.find((item) => item.projectCode === code);
|
||||
if (matched) form.projectName = matched.projectName || matched.projectCode;
|
||||
};
|
||||
|
||||
const openEdit = (row?: OwnerProfile) => {
|
||||
Object.assign(form, emptyForm(), row || {});
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload: OwnerProfilePayload = {
|
||||
projectCode: form.projectCode.trim(),
|
||||
projectName: form.projectName.trim(),
|
||||
ageRange: form.ageRange.trim(),
|
||||
incomeLevel: form.incomeLevel.trim(),
|
||||
familyStructure: form.familyStructure.trim(),
|
||||
concerns: form.concerns.trim()
|
||||
};
|
||||
if (!payload.projectCode || !payload.projectName || !payload.ageRange || !payload.incomeLevel || !payload.familyStructure || !payload.concerns) {
|
||||
ElMessage.warning('请完整填写档案内容');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateOwnerProfile(form.id, payload);
|
||||
ElMessage.success('业主档案已更新');
|
||||
} else {
|
||||
await createOwnerProfile(payload);
|
||||
ElMessage.success('业主档案已创建');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
await loadProfiles();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (row: OwnerProfile) => {
|
||||
const target = !row.enabled;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target ? `启用后「${row.projectName || row.projectCode}」的业主画像将用于实时对练人设,是否继续?` : `停用后「${row.projectName || row.projectCode}」的业主画像将不再用于实时对练人设,是否继续?`,
|
||||
target ? '启用档案' : '停用档案',
|
||||
{ confirmButtonText: target ? '确认启用' : '确认停用', cancelButtonText: '取消', type: 'warning' }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
togglingId.value = row.id;
|
||||
try {
|
||||
await setOwnerProfileEnabled(row.id, target);
|
||||
ElMessage.success(target ? '档案已启用' : '档案已停用');
|
||||
await loadProfiles();
|
||||
} finally {
|
||||
togglingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.owner-profiles-page{padding:24px;color:#172033}.page-head,.head-actions{display:flex;gap:12px;align-items:center}.page-head{justify-content:space-between;margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.table-panel{padding:8px}.muted{color:#98a2b3;font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.full{width:100%}.concern-tip{margin:6px 0 0;color:#98a2b3;font-size:12px}@media(max-width:1100px){.owner-profiles-page{padding:16px}.page-head{align-items:flex-start}.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -3,8 +3,8 @@
|
||||
"appid" : "__UNI__B36D8BE",
|
||||
"description" : "帮道员工端",
|
||||
"vueVersion" : "3",
|
||||
"versionName" : "0.1.18",
|
||||
"versionCode" : "118",
|
||||
"versionName" : "0.1.20",
|
||||
"versionCode" : "120",
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-if="showOwnerProfilePicker" class="owner-profile-row">
|
||||
<text class="owner-profile-label">业主身份</text>
|
||||
<picker
|
||||
class="owner-profile-picker"
|
||||
mode="selector"
|
||||
:range="ownerProfilePickerOptions"
|
||||
:value="ownerProfilePickerIndex"
|
||||
:disabled="busy"
|
||||
@change="handleOwnerProfileChange"
|
||||
>
|
||||
<view class="owner-profile-value">
|
||||
<text class="owner-profile-value-text">{{ ownerProfilePickerOptions[ownerProfilePickerIndex] }}</text>
|
||||
<text class="owner-profile-arrow" aria-hidden="true">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<button
|
||||
v-if="status === 'idle' || status === 'finished'"
|
||||
class="primary-button realtime-start-button"
|
||||
@@ -530,7 +546,7 @@ import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile
|
||||
import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
|
||||
import { createRealtimePracticeController, createRealtimeSession, fetchOwnerProfiles, fetchRealtimePersonas, invokeRealtimeTool, type OwnerProfileOption, type RealtimePersonaOption } from '@/services/realtime-practice';
|
||||
import { stripInternalCodes } from '@/services/text';
|
||||
import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
|
||||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||||
@@ -552,6 +568,10 @@ const defaultScenarioName = '投诉接待';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const realtimePersonaStorageKey = 'aihr_realtime_practice_persona';
|
||||
const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey);
|
||||
// 业主身份选择持久化:存 profileId;用户显式选「不指定」时存 NONE 标记,
|
||||
// 以便与「从未选择」(用服务端 defaultProfileId 作首次默认)区分。
|
||||
const ownerProfileStorageKey = 'aihr_realtime_owner_profile';
|
||||
const ownerProfileNoneMarker = 'NONE';
|
||||
const status = ref<PracticeStatus>('idle');
|
||||
const sessionId = ref('');
|
||||
const roundIndex = ref(0);
|
||||
@@ -589,6 +609,8 @@ const realtimeAudioBlocked = ref(false);
|
||||
const realtimeSessionId = ref('');
|
||||
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
|
||||
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
|
||||
const ownerProfiles = ref<OwnerProfileOption[]>([]);
|
||||
const selectedOwnerProfileId = ref('');
|
||||
const realtimeAppBridge = ref<{
|
||||
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
|
||||
stop: () => void;
|
||||
@@ -662,6 +684,16 @@ const canStartPractice = computed(() =>
|
||||
const canStartRealtime = computed(() =>
|
||||
status.value === 'idle' || status.value === 'finished'
|
||||
);
|
||||
// 业主身份选择器仅在选中 owner 类人设且服务端返回了档案时展示;
|
||||
// 档案为空或接口失败时整体隐藏,静默降级为无画像对练。
|
||||
const showOwnerProfilePicker = computed(() =>
|
||||
selectedRealtimePersonaId.value.startsWith('owner') && ownerProfiles.value.length > 0
|
||||
);
|
||||
const ownerProfilePickerOptions = computed(() => ['不指定', ...ownerProfiles.value.map((item) => item.label)]);
|
||||
const ownerProfilePickerIndex = computed(() => {
|
||||
const index = ownerProfiles.value.findIndex((item) => item.profileId === selectedOwnerProfileId.value);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
||||
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
|
||||
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
|
||||
@@ -996,6 +1028,8 @@ const clearPracticeData = () => {
|
||||
status.value = 'idle';
|
||||
selectedScenarioId.value = defaultScenarioId;
|
||||
activeScenarioName.value = defaultScenarioName;
|
||||
ownerProfiles.value = [];
|
||||
selectedOwnerProfileId.value = '';
|
||||
helpLoading.value = false;
|
||||
helpAnswer.value = '';
|
||||
helpRefs.value = [];
|
||||
@@ -1083,7 +1117,8 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
|
||||
if (!requests.isCurrent(request)) return;
|
||||
realtimePersonaOptions.value = options;
|
||||
if (!options.some((item) => item.id === selectedRealtimePersonaId.value)) {
|
||||
selectedRealtimePersonaId.value = options[0]?.id || '';
|
||||
// 「业主·急躁」下线后默认选中「业主」(owner-calm),其余情况回落首项
|
||||
selectedRealtimePersonaId.value = options.find((item) => item.id === 'owner-calm')?.id || options[0]?.id || '';
|
||||
}
|
||||
} catch {
|
||||
// 人设列表拉取失败不阻塞页面:会话创建接口对空白 personaId 回落默认人设
|
||||
@@ -1091,6 +1126,35 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
|
||||
}
|
||||
};
|
||||
|
||||
const loadOwnerProfiles = async (request: RequestScopeTicket = requests.begin('owner-profiles')) => {
|
||||
try {
|
||||
const data = await fetchOwnerProfiles();
|
||||
if (!requests.isCurrent(request)) return;
|
||||
const profiles = Array.isArray(data?.profiles) ? data.profiles : [];
|
||||
ownerProfiles.value = profiles;
|
||||
const stored = uni.getStorageSync(ownerProfileStorageKey);
|
||||
if (stored === ownerProfileNoneMarker) {
|
||||
selectedOwnerProfileId.value = '';
|
||||
} else if (typeof stored === 'string' && stored && profiles.some((item) => item.profileId === stored)) {
|
||||
selectedOwnerProfileId.value = stored;
|
||||
} else if (data?.defaultProfileId && profiles.some((item) => item.profileId === data.defaultProfileId)) {
|
||||
selectedOwnerProfileId.value = data.defaultProfileId;
|
||||
} else {
|
||||
selectedOwnerProfileId.value = '';
|
||||
}
|
||||
} catch {
|
||||
// 业主档案拉取失败不阻塞页面:选择器整体隐藏,降级为无画像对练
|
||||
if (requests.isCurrent(request)) ownerProfiles.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleOwnerProfileChange = (event: { detail: { value: number | string } }) => {
|
||||
const index = Number(event.detail.value);
|
||||
if (!Number.isInteger(index) || index < 0 || index > ownerProfiles.value.length) return;
|
||||
selectedOwnerProfileId.value = index === 0 ? '' : ownerProfiles.value[index - 1].profileId;
|
||||
uni.setStorageSync(ownerProfileStorageKey, selectedOwnerProfileId.value || ownerProfileNoneMarker);
|
||||
};
|
||||
|
||||
const stopRealtime = () => {
|
||||
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
|
||||
if (isNativeAppRuntime()) realtimeAppBridge.value?.stop();
|
||||
@@ -1129,7 +1193,11 @@ const startRealtime = async () => {
|
||||
throw new Error(microphoneDeniedMessage(permission));
|
||||
}
|
||||
if (!realtimeActive.value) return;
|
||||
const session = await createRealtimeSession(selectedRealtimePersonaId.value || undefined, selectedScenarioId.value || undefined);
|
||||
const session = await createRealtimeSession(
|
||||
selectedRealtimePersonaId.value || undefined,
|
||||
selectedScenarioId.value || undefined,
|
||||
showOwnerProfilePicker.value && selectedOwnerProfileId.value ? selectedOwnerProfileId.value : undefined
|
||||
);
|
||||
if (!realtimeActive.value) return;
|
||||
realtimeSessionId.value = session.sessionId;
|
||||
activeScenarioName.value = session.personaName || '实时陪练';
|
||||
@@ -1823,6 +1891,7 @@ const handleShow = async () => {
|
||||
void loadAssignments(requests.begin('assignments'));
|
||||
void loadCurriculum(requests.begin('curriculum'));
|
||||
void loadRealtimePersonas(requests.begin('realtime-personas'));
|
||||
void loadOwnerProfiles(requests.begin('owner-profiles'));
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
@@ -3781,6 +3850,49 @@ onUnload(() => {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.owner-profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #f1e3e4;
|
||||
border-radius: 12px;
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
.owner-profile-label {
|
||||
flex-shrink: 0;
|
||||
color: #5e6672;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.owner-profile-picker {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.owner-profile-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.owner-profile-value-text {
|
||||
overflow: hidden;
|
||||
color: #25282d;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.owner-profile-arrow {
|
||||
flex-shrink: 0;
|
||||
color: #b6bcc6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface RealtimePersonaOption {
|
||||
hasTools: boolean;
|
||||
}
|
||||
|
||||
export interface OwnerProfileOption {
|
||||
profileId: string;
|
||||
projectCode: string;
|
||||
communityName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OwnerProfilesResponse {
|
||||
profiles: OwnerProfileOption[];
|
||||
defaultProfileId: string | null;
|
||||
}
|
||||
|
||||
export interface RealtimeSessionResponse {
|
||||
sessionId: string;
|
||||
personaId: string;
|
||||
@@ -45,11 +57,18 @@ export const fetchRealtimePersonas = () =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const createRealtimeSession = (personaId?: string, scenarioId?: string) =>
|
||||
export const fetchOwnerProfiles = () =>
|
||||
apiRequest<OwnerProfilesResponse>({
|
||||
url: '/api/aihr/practice/realtime/owner-profiles',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const createRealtimeSession = (personaId?: string, scenarioId?: string, profileId?: string) =>
|
||||
apiRequest<RealtimeSessionResponse>({
|
||||
url: '/api/train/practice/realtime/session',
|
||||
method: 'POST',
|
||||
data: { personaId: personaId || null, scenarioId: scenarioId || null },
|
||||
data: { personaId: personaId || null, scenarioId: scenarioId || null, profileId: profileId || null },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user