diff --git a/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql b/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql new file mode 100644 index 00000000..ec9113bf --- /dev/null +++ b/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql @@ -0,0 +1,41 @@ +-- AIHR 案例原始音频字段迁移(MySQL 8.x) +-- 仅补齐旧库缺失的媒体留痕字段,不写入业务数据;可重复执行。 + +SET @aihr_case_table_exists := ( + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' +); + +SET @aihr_case_media_oss_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' + AND COLUMN_NAME = 'media_oss_id' +); +SET @aihr_case_media_oss_ddl := IF( + @aihr_case_table_exists = 1 AND @aihr_case_media_oss_exists = 0, + 'ALTER TABLE aihr_case_record ADD COLUMN `media_oss_id` bigint DEFAULT NULL COMMENT ''原始音频OSS文件ID'' AFTER `summary`', + 'SELECT 1' +); +PREPARE aihr_case_media_oss_stmt FROM @aihr_case_media_oss_ddl; +EXECUTE aihr_case_media_oss_stmt; +DEALLOCATE PREPARE aihr_case_media_oss_stmt; + +SET @aihr_case_media_url_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'aihr_case_record' + AND COLUMN_NAME = 'media_url' +); +SET @aihr_case_media_url_ddl := IF( + @aihr_case_table_exists = 1 AND @aihr_case_media_url_exists = 0, + 'ALTER TABLE aihr_case_record ADD COLUMN `media_url` varchar(500) DEFAULT NULL COMMENT ''原始音频访问地址'' AFTER `media_oss_id`', + 'SELECT 1' +); +PREPARE aihr_case_media_url_stmt FROM @aihr_case_media_url_ddl; +EXECUTE aihr_case_media_url_stmt; +DEALLOCATE PREPARE aihr_case_media_url_stmt; diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 2a55473e..5659f6b6 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -8,6 +8,8 @@ - 2026-07-14 BRD G3 SOP 附件绑定补偿:同步/待处理图片文档此前在 `sys_oss` 上传成功后再写 `aihr_knowledge_attach`,附件写入失败时可能留下无业务引用对象;现仅对“附件尚未成功绑定”的异常路径做 OSS 补偿删除,解析失败后已存在的失败/待处理附件仍保留用于重试,不改变现有文档处理状态机。 - 2026-07-14 BRD G3 OSS 生命周期补偿:候选资料和案例音频此前先写 `sys_oss`、再写业务记录,业务数据库写入失败时可能留下没有业务引用的对象;现对“本次上传新建且尚未绑定”的 `oss_id` 增加失败补偿删除,案例同时移除内存态,补充两条回归测试和 `demo-check` marker。该修复不触碰已有业务资源;SOP 分片/ASR 等复杂异步链路仍需按各自状态机单独核查,不能用本次补偿覆盖全部媒体生命周期。 - 2026-07-14 BRD G3 文件名边界复核:候选资料、案例音频、同步 SOP 文档和 ASR 音频此前虽然分别校验或清理文件名,但仍有路径把原始 `MultipartFile` 直接交给 `sys_oss`,清理后的名字没有贯通到 OSS 元数据;现新增共用 `AihrMultipartFiles` 包装器,四条链路均以清理后的文件名上传/外发,并补单测与 `demo-check` marker。该修复不改变文件类型、大小、权限和业务状态,仍需正式环境抽查 OSS 原始文件名与下载响应头。 +- 2026-07-14 BRD 4.6 案例媒体 schema 迁移:案例服务此前依靠 reset DDL 和运行时懒迁移提供 `media_oss_id/media_url`,旧库和生产库缺少显式版本化迁移;现新增可重复执行的 `aihr_20260714_case_media_mysql8.sql`,并在 schema 回归测试中覆盖“无表先跳过、旧表补列、重复执行不重复加列”。这只解决结构发布一致性,不增加案例业务数据,也不替代正式预渲染视频样片、学习点和主管点评输入。 +- 2026-07-14 BRD 4.6 案例媒体严格门禁:正式预检现在同时检查 `aihr_case_record.media_oss_id` 与 `media_url` 两列,避免案例音频回放依赖首次请求触发运行时懒迁移;该检查只判断 schema,不把已有音频或案例数量误判为正式案例学习资产。 更新时间:2026-07-14 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index e13fece9..b0629d3d 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -81,7 +81,7 @@ check_pilot_samples() { [[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end" [[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date" - local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count + local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count audio_upload_table_count case_media_schema_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average local org_identity_cte=" WITH active_org AS ( @@ -141,6 +141,7 @@ check_pilot_samples() { ) ready_rubrics")" audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")" audio_upload_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio_upload'")" + case_media_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' AND column_name IN ('media_oss_id','media_url')")" prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")" requester_identity_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'requester_ext_party_id'")" hire_date_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' AND column_name = 'hire_date'")" @@ -297,7 +298,7 @@ check_pilot_samples() { AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ) SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")" - echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}" + echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} audio_upload_schema=${audio_upload_table_count} case_media_schema=${case_media_schema_count}/2 prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}" [[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || { (( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12" @@ -309,6 +310,7 @@ check_pilot_samples() { (( case_unmasked_count == 0 )) || fail "pilot stored cases contain unmasked known PII (${case_unmasked_count})" (( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)" (( audio_upload_table_count == 1 )) || fail "pilot audio staging schema unavailable (aihr_practice_audio_upload missing)" + (( case_media_schema_count == 2 )) || fail "pilot case media schema unavailable (aihr_case_record.media_oss_id/media_url missing)" (( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)" (( requester_identity_schema_count == 1 )) || fail "pilot SOP requester identity schema unavailable (aihr_sop_answer_review.requester_ext_party_id missing)" (( hire_date_schema_count == 1 )) || fail "pilot org hire_date schema unavailable" @@ -439,6 +441,8 @@ contains frontend/src/views/knowledge/cases.vue 'v-loading="recordsLoading"' contains frontend/src/views/knowledge/cases.vue "const records = ref([])" contains frontend/src/views/knowledge/cases.vue "案例所属项目" contains frontend/src/views/knowledge/cases.vue "请先选择案例所属项目" +contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_oss_id" +contains backend/script/sql/update/aihr_20260714_case_media_mysql8.sql "media_url" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "系统用户上传案例必须明确所属项目" contains frontend/src/views/index.vue "核心流程覆盖待内容确认" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程覆盖待内容确认" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 51befd53..7b676a36 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -21,6 +21,22 @@ trap cleanup EXIT mysql -e "CREATE DATABASE \`$DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;" +mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql" +mysql "$DB" <<'SQL' +CREATE TABLE aihr_case_record ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) DEFAULT '000000', + case_id varchar(80) NOT NULL, + summary varchar(1000) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_case_record_case (tenant_id, case_id) +) ENGINE=InnoDB; +SQL +mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql" +mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_case_media_mysql8.sql" +case_media_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_case_record' AND column_name IN ('media_oss_id','media_url');")" +test "$case_media_columns" = 'media_oss_id,media_url' + mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")" @@ -52,4 +68,4 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_org_hire_date_m org_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_org_snapshot' AND column_name='hire_date';")" test "$org_columns" = 'hire_date' -echo "PASS: AIHR legacy schema migrations are idempotent" +echo "PASS: AIHR legacy schema migrations are idempotent, including case media fields" diff --git a/scripts/tests/demo-check-pilot-gates.test.sh b/scripts/tests/demo-check-pilot-gates.test.sh index 69055fd0..c424ed04 100755 --- a/scripts/tests/demo-check-pilot-gates.test.sh +++ b/scripts/tests/demo-check-pilot-gates.test.sh @@ -68,7 +68,8 @@ USE \`$TEST_DB\`; CREATE TABLE aihr_practice_scenario (tenant_id varchar(20), position varchar(80), scenario_type varchar(80), scenario_name varchar(120), enabled tinyint); CREATE TABLE aihr_knowledge_attach (tenant_id varchar(20), type varchar(50), status tinyint); CREATE TABLE aihr_case_record ( - tenant_id varchar(20), status varchar(30), title varchar(120), transcript text, summary varchar(1000) + tenant_id varchar(20), status varchar(30), title varchar(120), transcript text, summary varchar(1000), + media_oss_id bigint, media_url varchar(500) ); CREATE TABLE aihr_practice_session (tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime, satisfaction_score tinyint); CREATE TABLE aihr_practice_assignment (tenant_id varchar(20), ext_party_id varchar(100), create_time datetime); @@ -103,14 +104,14 @@ INSERT INTO aihr_knowledge_attach VALUES ('000000','training',2), ('other','sop',2), ('000000','sop',1); -INSERT INTO aihr_case_record +INSERT INTO aihr_case_record (tenant_id, status, title, transcript, summary) SELECT '000000','已入库', CONCAT('case-', n), '已按流程回访,手机号1390****1111', '已完成' FROM ( SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20 ) n; -INSERT INTO aihr_case_record VALUES +INSERT INTO aihr_case_record (tenant_id, status, title, transcript, summary) VALUES ('000000','已转写','pending','',''), ('other','已入库','other','',''); INSERT INTO aihr_org_snapshot VALUES @@ -168,6 +169,7 @@ pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 [[ "$pilot_output" == *'calibration=1/20 consistency=1/1'* ]] || { echo "FAIL: calibration window: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'sop_docs=5/5 stored_cases=20/20'* ]] || { echo "FAIL: BRD content quantity gates: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'case_unmasked=0'* ]] || { echo "FAIL: redacted case content gate: $pilot_output" >&2; exit 1; } +[[ "$pilot_output" == *'case_media_schema=2/2'* ]] || { echo "FAIL: case media schema gate: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'satisfaction=5.0/5 responses=19'* ]] || { echo "FAIL: safe satisfaction identity scope: $pilot_output" >&2; exit 1; }