fix(aihr): gate vision processing with ai runtime

This commit is contained in:
2026-07-14 16:45:53 +08:00
parent ad21e24ed3
commit 81060fba08
8 changed files with 48 additions and 4 deletions
@@ -272,6 +272,10 @@ public class AihrModelSeedService {
} }
} }
public boolean visionAllowed() {
return aiEnabled && chatEnabled;
}
public record SpeechModel(String providerCode, String modelName, String baseUrl, String apiKey) { public record SpeechModel(String providerCode, String modelName, String baseUrl, String apiKey) {
} }
@@ -123,19 +123,22 @@ public class AihrSopSeedService {
private final String importRootConfig; private final String importRootConfig;
private final ScheduledExecutorService scheduledExecutorService; private final ScheduledExecutorService scheduledExecutorService;
private final AihrVideoService videoService; private final AihrVideoService videoService;
private final AihrModelSeedService modelService;
private volatile boolean knowledgeGapTableReady; private volatile boolean knowledgeGapTableReady;
private volatile boolean sopReviewTableReady; private volatile boolean sopReviewTableReady;
private volatile boolean answerFeedbackTableReady; private volatile boolean answerFeedbackTableReady;
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService, public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
@Value("${aihr.import.root:}") String importRootConfig, @Value("${aihr.import.root:}") String importRootConfig,
ScheduledExecutorService scheduledExecutorService, AihrVideoService videoService) { ScheduledExecutorService scheduledExecutorService, AihrVideoService videoService,
AihrModelSeedService modelService) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.ossService = ossService; this.ossService = ossService;
this.importRootConfig = importRootConfig; this.importRootConfig = importRootConfig;
this.scheduledExecutorService = scheduledExecutorService; this.scheduledExecutorService = scheduledExecutorService;
this.videoService = videoService; this.videoService = videoService;
this.modelService = modelService;
} }
public SearchResponse search(SearchRequest request) { public SearchResponse search(SearchRequest request) {
@@ -951,6 +954,9 @@ public class AihrSopSeedService {
/** 优先查 category='vision',无则回退 category='chat'(大多数多模态模型通用)。 */ /** 优先查 category='vision',无则回退 category='chat'(大多数多模态模型通用)。 */
private Optional<ChatRuntime> visionRuntime() { private Optional<ChatRuntime> visionRuntime() {
if (modelService != null && !modelService.visionAllowed()) {
return Optional.empty();
}
try { try {
List<ChatRuntime> rows = jdbcTemplate.query(""" List<ChatRuntime> rows = jdbcTemplate.query("""
select c.model_name, select c.model_name,
@@ -68,7 +68,7 @@ public class AihrPracticeSeedServiceTest {
RecordingTransactionManager transactionManager = new RecordingTransactionManager(); RecordingTransactionManager transactionManager = new RecordingTransactionManager();
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
PilotExportJdbcTemplate jdbcTemplate = new PilotExportJdbcTemplate(transactionManager); PilotExportJdbcTemplate jdbcTemplate = new PilotExportJdbcTemplate(transactionManager);
AihrSopSeedService sopSeedService = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null); AihrSopSeedService sopSeedService = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null, null);
Constructor<AihrPracticeSeedService> constructor = (Constructor<AihrPracticeSeedService>) AihrPracticeSeedService.class Constructor<AihrPracticeSeedService> constructor = (Constructor<AihrPracticeSeedService>) AihrPracticeSeedService.class
.getDeclaredConstructor(ObjectMapper.class, JdbcTemplate.class, AihrPracticeLlmService.class, .getDeclaredConstructor(ObjectMapper.class, JdbcTemplate.class, AihrPracticeLlmService.class,
AihrSopSeedService.class, TransactionTemplate.class); AihrSopSeedService.class, TransactionTemplate.class);
@@ -1,16 +1,21 @@
package org.dromara.aihr.service; package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrSopDto; import org.dromara.aihr.domain.AihrSopDto;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class AihrSopSeedServiceTest { public class AihrSopSeedServiceTest {
@@ -126,6 +131,32 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("runLocalImportTask(taskId, plan)")); assertTrue(code.contains("runLocalImportTask(taskId, plan)"));
} }
@Test
@Tag("dev")
public void visionProcessingUsesTheSharedAiRuntimeGate() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("!modelService.visionAllowed()"));
}
@Test
@Tag("dev")
public void disabledVisionGateStopsBeforeDatabaseLookup() throws Exception {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.visionAllowed()).thenReturn(false);
AihrSopSeedService service = new AihrSopSeedService(new ObjectMapper(), null, null, "", null, null, modelService);
Method visionRuntime = AihrSopSeedService.class.getDeclaredMethod("visionRuntime");
visionRuntime.setAccessible(true);
Optional<?> runtime = (Optional<?>) visionRuntime.invoke(service);
assertTrue(runtime.isEmpty());
}
@Test @Test
@Tag("dev") @Tag("dev")
public void sopReviewUsesAuthenticatedOperatorForAuditTrail() throws Exception { public void sopReviewUsesAuthenticatedOperatorForAuditTrail() throws Exception {
+1
View File
@@ -2,6 +2,7 @@
更新时间:2026-07-14 更新时间:2026-07-14
- 2026-07-14 BRD G3/G6 视觉外发闸门修复:资料处理的图片 OCR/视频关键帧视觉调用此前直接查询启用模型,可能绕过 `AIHR_AI_RUNTIME_ENABLED`/`AIHR_AI_CHAT_ENABLED` 成本开关并向外部供应商发送原始图片;现复用 `AihrModelSeedService.visionAllowed()` 在共用 `visionRuntime()` 入口拦截,关闭时按既有“待处理/文本降级”语义,不新增配置。补充源码契约测试、`demo-check` marker、运行说明和演示验收说明;未改变已配置视觉模型的正常处理路径。
- 2026-07-14 BRD G3/G6 语音响应边界收紧:TTS 成功写入 `sys_oss` 后原先仍在 JSON 中重复携带完整 base64 音频,增加浏览器缓存、日志和响应体暴露面;当前 `mobile-uni` 与管理端没有消费 `inlineAudioUrl`,现改为成功只返回 OSS `audioUrl/ossId`,仅在 OSS 上传失败时保留内联 data URL 降级。ASR 已成功转写但 OSS 上传失败时,现保留 transcript 并明确无音频地址,不再把可用文字结果整体打成 500。补充源码契约测试、`demo-check` marker 和 API 对接说明;未改变音频回放授权与 OSS 留存策略。 - 2026-07-14 BRD G3/G6 语音响应边界收紧:TTS 成功写入 `sys_oss` 后原先仍在 JSON 中重复携带完整 base64 音频,增加浏览器缓存、日志和响应体暴露面;当前 `mobile-uni` 与管理端没有消费 `inlineAudioUrl`,现改为成功只返回 OSS `audioUrl/ossId`,仅在 OSS 上传失败时保留内联 data URL 降级。ASR 已成功转写但 OSS 上传失败时,现保留 transcript 并明确无音频地址,不再把可用文字结果整体打成 500。补充源码契约测试、`demo-check` marker 和 API 对接说明;未改变音频回放授权与 OSS 留存策略。
- 2026-07-14 BRD G3/G6 生产验证码安全边界修复:`CaptchaController` 原先允许通过 `aihr.sms.demo-fixed-code-enabled=true` 在 `prod` profile 放行固定短信验证码,和项目要求的“生产代码级禁用”冲突;现仅允许非 `prod` profile 使用 `aihr.sms.dev-fixed-code`,删除生产覆盖开关,并新增源码契约回归与 `demo-check` marker。未修改线上配置或生产数据。 - 2026-07-14 BRD G3/G6 生产验证码安全边界修复:`CaptchaController` 原先允许通过 `aihr.sms.demo-fixed-code-enabled=true` 在 `prod` profile 放行固定短信验证码,和项目要求的“生产代码级禁用”冲突;现仅允许非 `prod` profile 使用 `aihr.sms.dev-fixed-code`,删除生产覆盖开关,并新增源码契约回归与 `demo-check` marker。未修改线上配置或生产数据。
- 2026-07-14 本地验证环境一致性修复:项目 Docker 编排使用 MySQL `13306`、Redis `16379`,但 `application-dev.yml` 原先仍默认连接 `3306/6379`,导致 admin Spring 全量测试误连其他本机容器;现 dev 数据源改为 `AIHR_MYSQL_*`、`AIHR_REDIS_*` 可覆盖配置,默认与本项目编排一致,生产配置不变。`ruoyi-admin` 全量测试已通过。 - 2026-07-14 本地验证环境一致性修复:项目 Docker 编排使用 MySQL `13306`、Redis `16379`,但 `application-dev.yml` 原先仍默认连接 `3306/6379`,导致 admin Spring 全量测试误连其他本机容器;现 dev 数据源改为 `AIHR_MYSQL_*`、`AIHR_REDIS_*` 可覆盖配置,默认与本项目编排一致,生产配置不变。`ruoyi-admin` 全量测试已通过。
+1 -1
View File
@@ -30,7 +30,7 @@
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 | | 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题文字或录音作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈且语音有回放留痕;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 | | 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 | | 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 | | 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` 或资料处理视觉链路 | 返回 `cost-guard-fallback` 或“待处理”,chat/asr/tts/vision 不再外发,不影响训练闭环 |
试点正式验收前,用 `AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,租户和日期替换为本批试点的真实值。闸门只统计目标租户、该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。 试点正式验收前,用 `AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,租户和日期替换为本批试点的真实值。闸门只统计目标租户、该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。
+1 -1
View File
@@ -73,7 +73,7 @@ AIHR_ORG_SYNC_SIGNING_SECRET=
# 若放在 backend/.env,也可沿用开放平台字段名: # 若放在 backend/.env,也可沿用开放平台字段名:
# client_id=dn_xxxxxx # client_id=dn_xxxxxx
# client_secret=dns_xxxxxx # client_secret=dns_xxxxxx
# 试点成本闸门:超预算时改 false,chat/asr/tts 会走现有文本兜底 # 试点成本闸门:超预算时改 false,chat/asr/tts/vision 会停止外发并走现有兜底
AIHR_AI_RUNTIME_ENABLED=true AIHR_AI_RUNTIME_ENABLED=true
AIHR_AI_CHAT_ENABLED=true AIHR_AI_CHAT_ENABLED=true
AIHR_AI_SPEECH_ENABLED=true AIHR_AI_SPEECH_ENABLED=true
+2
View File
@@ -613,6 +613,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "!modelService.visionAllowed()"
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java '!environment.acceptsProfiles(Profiles.of("prod"))' contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java '!environment.acceptsProfiles(Profiles.of("prod"))'
contains backend/ruoyi-admin/src/test/java/org/dromara/web/controller/CaptchaControllerSecurityTest.java "CaptchaControllerSecurityTest" contains backend/ruoyi-admin/src/test/java/org/dromara/web/controller/CaptchaControllerSecurityTest.java "CaptchaControllerSecurityTest"
contains backend/ruoyi-admin/src/main/resources/application-dev.yml "AIHR_MYSQL_PORT:13306" contains backend/ruoyi-admin/src/main/resources/application-dev.yml "AIHR_MYSQL_PORT:13306"