From bd19cdd255679a2eb480804ca319e97a9f5f6296 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 13:06:47 +0800 Subject: [PATCH 1/4] fix(aihr): harden zip upload queue --- .../aihr/service/AihrUploadQueueService.java | 131 ++++++++++++++---- .../service/AihrUploadQueueServiceTest.java | 80 +++++++++++ 2 files changed, 185 insertions(+), 26 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java index c43939a3..390f8d11 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java @@ -14,6 +14,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -21,7 +23,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; @@ -47,6 +52,8 @@ public class AihrUploadQueueService { private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; private static final int MAX_ARCHIVE_ENTRIES = 1000; private static final int MAX_ERROR_CHARS = 480; + // ponytail: UTF-8 + GBK covers the current Chinese sources; add an explicit charset option only if another legacy encoding appears. + private static final Charset ZIP_FALLBACK_CHARSET = Charset.forName("GBK"); /** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */ private static final int WORKER_PERMITS = 2; /** 处理中超过该时长视为孤儿(服务重启/线程异常丢失),由清扫任务重置回待处理。需大于最慢单文件加工时长。 */ @@ -284,22 +291,53 @@ public class AihrUploadQueueService { } else { log.info("upload item {} finished but row was re-claimed, skip status write", id); } + } catch (ArchiveException e) { + log.warn("upload item {} archive rejected: {}", id, e.getMessage(), e); + markFailed(tenantId, id, e.getMessage()); } catch (Exception e) { - log.warn("upload item {} process failed(处理错误已隐藏)", id); + log.warn("upload item {} process failed", id, e); markFailed(tenantId, id, "资料加工失败,请重试或联系管理员"); } } private int expandArchive(ItemRow archive) throws IOException { + List files; + try { + files = extractArchiveWithEncodingFallback(archive.stagingPath()); + } catch (ArchiveException e) { + throw e; + } catch (IOException | IllegalArgumentException e) { + throw new ArchiveException("ZIP解压失败,请使用未加密的 UTF-8 或 GBK ZIP 文件后重试", e); + } + try { + enqueueExtractedFiles(archive, files); + } catch (RuntimeException e) { + deleteExtractedFiles(files); + throw new ArchiveException("ZIP文件入队失败,请重试或联系管理员", e); + } + return files.size(); + } + + private List extractArchiveWithEncodingFallback(String stagingPath) throws IOException { + try { + return extractArchive(stagingPath, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + log.info("ZIP filenames are not UTF-8, retrying with GBK"); + return extractArchive(stagingPath, ZIP_FALLBACK_CHARSET); + } + } + + private List extractArchive(String stagingPath, Charset charset) throws IOException { List files = new ArrayList<>(); long unpackedBytes = 0; int entries = 0; - try (var input = Files.newInputStream(Path.of(archive.stagingPath())); - var zip = new ZipInputStream(input)) { + Set usedFileNames = new HashSet<>(); + try (var input = Files.newInputStream(Path.of(stagingPath)); + var zip = new ZipInputStream(input, charset)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { if (++entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("ZIP文件数量不能超过 1000 个"); + throw new ArchiveException("ZIP文件数量不能超过 1000 个"); } if (entry.isDirectory()) { continue; @@ -307,11 +345,11 @@ public class AihrUploadQueueService { String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/'); Path entryPath = Path.of(entryName).normalize(); if (entryName.isBlank() || entryPath.isAbsolute() || entryPath.startsWith("..")) { - throw new IOException("ZIP包含不安全的文件路径"); + throw new ArchiveException("ZIP包含不安全的文件路径"); } - String fileName = sanitizeFileName(entryPath.getFileName().toString()); + String fileName = uniqueFileName(sanitizeFileName(entryPath.getFileName().toString()), usedFileNames); if (isZip(fileName)) { - throw new IOException("不支持嵌套 ZIP 文件"); + throw new ArchiveException("不支持嵌套 ZIP 文件"); } if (!AihrSopSeedService.supportedFile(fileName)) { continue; @@ -326,37 +364,68 @@ public class AihrUploadQueueService { entryBytes += read; unpackedBytes += read; if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) { - throw new IOException("ZIP解压后文件超过大小限制"); + throw new ArchiveException("ZIP解压后文件超过大小限制"); } output.write(buffer, 0, read); } + } catch (IOException | RuntimeException e) { + deleteQuietly(extracted); + throw e; } files.add(new ExtractedFile(fileName, extracted)); } - } catch (IOException e) { - files.forEach(file -> deleteQuietly(file.stagedPath())); + } catch (IOException | RuntimeException e) { + deleteExtractedFiles(files); throw e; } if (files.isEmpty()) { - throw new IOException("ZIP内没有支持的资料文件"); + throw new ArchiveException("ZIP内没有支持的资料文件"); } - try { - for (ExtractedFile file : files) { - jdbcTemplate.update(""" - insert into aihr_knowledge_upload_item - (tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time) - values (?, ?, ?, ?, ?, 0, now(), now()) - """, archive.tenantId(), archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString()); + return files; + } + + private void enqueueExtractedFiles(ItemRow archive, List files) { + StringBuilder sql = new StringBuilder(""" + insert into aihr_knowledge_upload_item + (tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time) + values + """); + List parameters = new ArrayList<>(files.size() * 5); + for (int index = 0; index < files.size(); index++) { + if (index > 0) { + sql.append(','); } - } catch (RuntimeException e) { - for (ExtractedFile file : files) { - jdbcTemplate.update("delete from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?", - archive.tenantId(), file.stagedPath().toString()); - deleteQuietly(file.stagedPath()); - } - throw new IOException("ZIP文件入队失败", e); + sql.append(" (?, ?, ?, ?, ?, 0, now(), now())"); + ExtractedFile file = files.get(index); + parameters.add(archive.tenantId()); + parameters.add(archive.batchId()); + parameters.add(file.fileName()); + parameters.add(archive.category()); + parameters.add(file.stagedPath().toString()); } - return files.size(); + jdbcTemplate.update(sql.toString(), parameters.toArray()); + } + + private static void deleteExtractedFiles(List files) { + files.forEach(file -> deleteQuietly(file.stagedPath())); + } + + private static String uniqueFileName(String fileName, Set usedFileNames) { + String candidate = fileName; + int suffix = 2; + while (!usedFileNames.add(candidate.toLowerCase(Locale.ROOT))) { + candidate = fileNameWithSuffix(fileName, suffix++); + } + return candidate; + } + + private static String fileNameWithSuffix(String fileName, int suffix) { + int extensionStart = fileName.lastIndexOf('.'); + String extension = extensionStart > 0 ? fileName.substring(extensionStart) : ""; + String stem = extensionStart > 0 ? fileName.substring(0, extensionStart) : fileName; + String marker = "(" + suffix + ")"; + int maxStemLength = Math.max(1, 200 - extension.length() - marker.length()); + return stem.substring(0, Math.min(stem.length(), maxStemLength)) + marker + extension; } private static boolean isZip(String fileName) { @@ -366,6 +435,16 @@ public class AihrUploadQueueService { private record ExtractedFile(String fileName, Path stagedPath) { } + private static final class ArchiveException extends IOException { + private ArchiveException(String message) { + super(message); + } + + private ArchiveException(String message, Throwable cause) { + super(message, cause); + } + } + /** 清扫卡在「处理中」的孤儿条目(重启/异常导致),重置回待处理后再触发一轮加工;顺带回收过期暂存文件。 */ private void sweepAndTrigger() { try { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java index 9bc67760..5c863004 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java @@ -2,16 +2,66 @@ package org.dromara.aihr.service; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.jdbc.core.JdbcTemplate; +import org.mockito.ArgumentCaptor; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("dev") class AihrUploadQueueServiceTest { + @TempDir + Path tempDir; + + @Test + void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception { + Path staging = Files.createDirectories(tempDir.resolve("staging")); + Path archive = tempDir.resolve("legacy.zip"); + writeZip(archive, Charset.forName("GBK"), "目录一/附件.docx", "目录二/附件.docx"); + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + try { + AihrUploadQueueService service = new AihrUploadQueueService(null, jdbcTemplate, executor, staging.toString()); + + int extracted = assertDoesNotThrow(() -> expandArchive(service, itemRow(archive))); + + assertEquals(2, extracted); + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate, times(1)).update(anyString(), parameters.capture()); + Object[] values = parameters.getValue(); + assertEquals(10, values.length); + assertEquals("附件.docx", values[2]); + assertEquals("附件(2).docx", values[7]); + assertTrue(Files.isRegularFile(Path.of(values[4].toString()))); + assertTrue(Files.isRegularFile(Path.of(values[9].toString()))); + } finally { + executor.shutdownNow(); + } + } + @Test void queueKeepsTenantWhenWorkerLeavesLoginThread() throws Exception { Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java"); @@ -31,4 +81,34 @@ class AihrUploadQueueServiceTest { assertFalse(code.contains("\"暂存文件写入失败:\" + e.getMessage()")); assertFalse(code.contains("where tenant_id = ? and status = 1\n and (")); } + + private static void writeZip(Path archive, Charset charset, String... entries) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive), charset)) { + for (String entry : entries) { + output.putNextEntry(new ZipEntry(entry)); + output.write("fixture".getBytes()); + output.closeEntry(); + } + } + } + + private static Object itemRow(Path archive) throws ReflectiveOperationException { + Class rowType = Class.forName("org.dromara.aihr.service.AihrUploadQueueService$ItemRow"); + Constructor constructor = rowType.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + return constructor.newInstance("000000", 1L, "batch", "legacy.zip", "__auto__", archive.toString(), 1); + } + + private static int expandArchive(AihrUploadQueueService service, Object itemRow) throws Exception { + Method method = service.getClass().getDeclaredMethod("expandArchive", itemRow.getClass()); + method.setAccessible(true); + try { + return (int) method.invoke(service, itemRow); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof Exception cause) { + throw cause; + } + throw e; + } + } } From ab406e616a0ab3e5ccc46c5cf28c951507428c25 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 13:33:56 +0800 Subject: [PATCH 2/4] fix(release): enforce AIHR tenant collation contract --- .../aihr/service/AihrSopSeedService.java | 12 ++-- .../aihr/service/AihrSopSeedServiceTest.java | 20 +++++- .../sql/aihr_candidate_material_mysql8.sql | 2 +- .../sql/aihr_interview_result_mysql8.sql | 4 +- backend/script/sql/aihr_knowledge_mysql8.sql | 10 +-- backend/script/sql/aihr_model_mysql8.sql | 4 +- .../script/sql/aihr_org_snapshot_mysql8.sql | 2 +- backend/script/sql/aihr_practice_mysql8.sql | 36 +++++----- ...0260714_practice_catalog_schema_mysql8.sql | 6 +- ...aihr_20260714_practice_evidence_mysql8.sql | 6 +- ...260714_practice_five_dimensions_mysql8.sql | 2 +- ...0715_candidate_interview_review_mysql8.sql | 6 +- ...ihr_20260715_knowledge_feedback_mysql8.sql | 4 +- ...0715_position_sop_qualification_mysql8.sql | 8 +-- .../aihr_20260715_tenant_collation_mysql8.sql | 70 +++++++++++++++++++ docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 19 ++++- docs/DEV_SETUP.md | 6 +- scripts/release-preflight.sh | 54 ++++++++++++-- scripts/tests/aihr-schema-migrations.test.sh | 39 ++++++++++- 19 files changed, 243 insertions(+), 67 deletions(-) create mode 100644 backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 1c24bb0b..fc4e3da2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -1138,7 +1138,7 @@ public class AihrSopSeedService { return Optional.ofNullable(jdbcTemplate.queryForObject(""" select o.ext1, coalesce(k.name, '') as category from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id where a.tenant_id = ? and a.id = ? limit 1 @@ -1169,7 +1169,7 @@ public class AihrSopSeedService { else 'name-size' end as match_type from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id where a.tenant_id = ? and ( json_unquote(json_extract(o.ext1, '$.aihrFileSha256')) = ? @@ -1291,8 +1291,7 @@ public class AihrSopSeedService { } private List processingRows() { - try { - return jdbcTemplate.query(""" + return jdbcTemplate.query(""" select a.id, a.oss_id, i.name as category, @@ -1307,7 +1306,7 @@ public class AihrSopSeedService { from aihr_knowledge_attach a join aihr_knowledge_info i on i.id = a.knowledge_id and i.tenant_id = a.tenant_id left join aihr_knowledge_fragment f on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id - left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id where a.tenant_id = ? group by a.id, a.oss_id, i.name, a.name, a.type, a.status, a.update_time, a.remark, o.ext1 order by a.update_time desc, a.id desc @@ -1332,9 +1331,6 @@ public class AihrSopSeedService { insight.reason() ); }, tenantId()); - } catch (DataAccessException e) { - return List.of(); - } } private ProcessingTaskResponse processingTask(ProcessingTaskRow row) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index f041dddc..d3ac99bc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -5,6 +5,7 @@ import org.dromara.aihr.domain.AihrSopDto; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -18,6 +19,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +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; @@ -349,13 +351,27 @@ public class AihrSopSeedServiceTest { } String code = Files.readString(source); - assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); - assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); + assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id")); + assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and binary o.tenant_id = binary a.tenant_id")); assertTrue(code.contains("left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id")); assertTrue(code.contains("where tenant_id = ? and oss_id = ? limit 1")); assertTrue(code.contains("where tenant_id = ? and oss_id = ?\", objectMapper.writeValueAsString(node), tenantId(), ossId")); } + @Test + @Tag("dev") + @SuppressWarnings({"rawtypes", "unchecked"}) + public void processingOverviewPropagatesDatabaseFailures() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenThrow(new DataAccessResourceFailureException("collation mismatch")); + AihrModelSeedService modelService = mock(AihrModelSeedService.class); + when(modelService.visionAllowed()).thenReturn(false); + AihrSopSeedService service = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null, modelService); + + assertThrows(DataAccessResourceFailureException.class, service::processingOverview); + } + @Test @Tag("dev") public void qdrantRebuildKeepsOtherTenantsVectors() throws Exception { diff --git a/backend/script/sql/aihr_candidate_material_mysql8.sql b/backend/script/sql/aihr_candidate_material_mysql8.sql index e88e43d6..e08fc8fa 100644 --- a/backend/script/sql/aihr_candidate_material_mysql8.sql +++ b/backend/script/sql/aihr_candidate_material_mysql8.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID', `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', `material_type` varchar(50) DEFAULT NULL COMMENT '资料类型', diff --git a/backend/script/sql/aihr_interview_result_mysql8.sql b/backend/script/sql/aihr_interview_result_mysql8.sql index 68bd77f8..5ebe4a72 100644 --- a/backend/script/sql/aihr_interview_result_mysql8.sql +++ b/backend/script/sql/aihr_interview_result_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '面试会话ID', `candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID', `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', @@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID', `ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID', `status` varchar(20) DEFAULT '已关联' COMMENT '关联状态', diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 4dfa9748..f3360c5f 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_info` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `name` varchar(100) NOT NULL COMMENT '知识库名称', `description` varchar(1000) DEFAULT NULL COMMENT '知识库描述', `separator` varchar(255) DEFAULT NULL COMMENT '知识分隔符', @@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_info` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_attach` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `knowledge_id` bigint NOT NULL COMMENT '知识库ID', `oss_id` bigint DEFAULT NULL COMMENT '对象存储ID', `doc_id` varchar(32) DEFAULT NULL COMMENT '文档ID', @@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_attach` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_fragment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `knowledge_id` bigint NOT NULL COMMENT '知识库ID', `idx` int NOT NULL COMMENT '片段序号', `doc_id` varchar(32) DEFAULT NULL COMMENT '文档ID', @@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_fragment` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_import_task` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `directory` varchar(500) NOT NULL COMMENT '导入相对目录', `category` varchar(100) NOT NULL COMMENT '知识库分类', `status` tinyint DEFAULT 0 COMMENT '任务状态: 0等待, 1运行中, 2完成, 3失败, 4取消', @@ -138,7 +138,7 @@ ON DUPLICATE KEY UPDATE -- 知识库批量上传队列:上传秒回后由后台 worker 加工,支持单文件重试 CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `batch_id` varchar(64) NOT NULL COMMENT '上传批次', `file_name` varchar(255) NOT NULL COMMENT '文件名', `category` varchar(100) DEFAULT '' COMMENT '目标分类', diff --git a/backend/script/sql/aihr_model_mysql8.sql b/backend/script/sql/aihr_model_mysql8.sql index 43bec1c2..02efecbe 100644 --- a/backend/script/sql/aihr_model_mysql8.sql +++ b/backend/script/sql/aihr_model_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_model_provider` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `provider_name` varchar(100) NOT NULL COMMENT '供应商名称', `provider_code` varchar(50) NOT NULL COMMENT '供应商编码', `provider_desc` varchar(500) DEFAULT NULL COMMENT '供应商描述', @@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS `aihr_model_provider` ( CREATE TABLE IF NOT EXISTS `aihr_model_config` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `category` varchar(30) NOT NULL COMMENT '模型类型: chat/vector/rerank', `model_name` varchar(100) NOT NULL COMMENT '模型名称', `provider_code` varchar(50) NOT NULL COMMENT '供应商编码', diff --git a/backend/script/sql/aihr_org_snapshot_mysql8.sql b/backend/script/sql/aihr_org_snapshot_mysql8.sql index 4548716a..55d54f7d 100644 --- a/backend/script/sql/aihr_org_snapshot_mysql8.sql +++ b/backend/script/sql/aihr_org_snapshot_mysql8.sql @@ -6,7 +6,7 @@ DROP TABLE IF EXISTS `aihr_org_snapshot`; CREATE TABLE `aihr_org_snapshot` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `project_code` varchar(50) NOT NULL COMMENT '项目编码(数据权限范围主体,D1)', `project_name` varchar(100) NOT NULL COMMENT '项目名称', `dept_name` varchar(100) DEFAULT NULL COMMENT '部门/条线', diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 11c240a4..07a9485a 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_case_record` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `case_id` varchar(80) NOT NULL COMMENT '案例ID', `file_name` varchar(255) DEFAULT NULL COMMENT '来源文件', `project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID', @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `aihr_case_record` ( CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部主体或手机号', `trainee_name` varchar(100) DEFAULT NULL COMMENT '学员名称', @@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` ( CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `turn_index` int NOT NULL COMMENT '话术序号', `source_text` varchar(1000) DEFAULT NULL COMMENT '原话', @@ -80,7 +80,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` ( CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `turn_index` int NOT NULL COMMENT '话术序号', `role` varchar(30) NOT NULL COMMENT '音频角色', @@ -95,7 +95,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( CREATE TABLE IF NOT EXISTS `aihr_practice_audio_upload` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `oss_id` bigint NOT NULL COMMENT 'OSS文件ID', `owner_user_id` bigint NOT NULL COMMENT '上传用户ID', `owner_identity` varchar(100) NOT NULL COMMENT '上传主体', @@ -111,7 +111,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio_upload` ( CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体或手机号', `trainee_name` varchar(100) DEFAULT NULL COMMENT '员工姓名', `scenario_id` varchar(80) NOT NULL COMMENT '场景ID', @@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `reviewer` varchar(100) DEFAULT NULL COMMENT '校准人', `original_score` int DEFAULT NULL COMMENT 'AI原始总分', @@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', `scenario_name` varchar(100) NOT NULL COMMENT '场景名称', `position` varchar(80) NOT NULL COMMENT '岗位', @@ -184,7 +184,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', `rubric_name` varchar(100) NOT NULL COMMENT 'Rubric名称', @@ -200,7 +200,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', `dimension_code` varchar(50) NOT NULL COMMENT '维度编码', `dimension_name` varchar(50) NOT NULL COMMENT '维度名称', @@ -216,7 +216,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` ( CREATE TABLE IF NOT EXISTS `aihr_prompt_template` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `code` varchar(80) NOT NULL COMMENT '模板编码', `name` varchar(100) NOT NULL COMMENT '模板名称', `category` varchar(50) NOT NULL COMMENT '业务分类', @@ -236,7 +236,7 @@ CREATE TABLE IF NOT EXISTS `aihr_prompt_template` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `query_text` varchar(1000) NOT NULL COMMENT '未命中问题', `category` varchar(100) DEFAULT NULL COMMENT '知识分类', `position` varchar(80) DEFAULT NULL COMMENT '岗位', @@ -249,7 +249,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `query_text` varchar(1000) NOT NULL COMMENT '问题', `category` varchar(100) DEFAULT NULL COMMENT '知识分类', `answer_text` varchar(2000) DEFAULT NULL COMMENT '回答', @@ -271,7 +271,7 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `query_text` varchar(1000) NOT NULL COMMENT '问题原文', `query_norm` varchar(500) NOT NULL COMMENT '规范化问题', `fragment_id` bigint NOT NULL COMMENT '知识片段ID', @@ -468,7 +468,7 @@ ON DUPLICATE KEY UPDATE -- 不写入业务数据;与 update/aihr_20260715_position_sop_qualification_mysql8.sql 保持一致。 CREATE TABLE IF NOT EXISTS `aihr_position_responsibility` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `position_code` varchar(80) NOT NULL COMMENT '岗位编码', `responsibility_text` varchar(1000) NOT NULL COMMENT '岗位职责', `sop_id` bigint DEFAULT NULL COMMENT '关联SOP主键(跨模块引用)', @@ -485,7 +485,7 @@ CREATE TABLE IF NOT EXISTS `aihr_position_responsibility` ( CREATE TABLE IF NOT EXISTS `aihr_sop_applicability` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `sop_id` bigint DEFAULT NULL COMMENT '关联SOP主键(跨模块引用)', `sop_code` varchar(100) DEFAULT NULL COMMENT '关联SOP编码', `project_type` varchar(80) DEFAULT NULL COMMENT '项目类型:住宅/公建/景区等', @@ -503,7 +503,7 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_applicability` ( CREATE TABLE IF NOT EXISTS `aihr_onboard_task` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体ID,不复制姓名', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编码', `position_code` varchar(80) DEFAULT NULL COMMENT '岗位编码', @@ -525,7 +525,7 @@ CREATE TABLE IF NOT EXISTS `aihr_onboard_task` ( CREATE TABLE IF NOT EXISTS `aihr_qualification_gate` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体ID,不复制姓名', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编码', `position_code` varchar(80) NOT NULL COMMENT '岗位编码', diff --git a/backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql b/backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql index 3e0d7ec3..71232239 100644 --- a/backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_practice_catalog_schema_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', `scenario_name` varchar(100) NOT NULL COMMENT '场景名称', `position` varchar(80) NOT NULL COMMENT '岗位', @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` ( CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', `scenario_code` varchar(80) NOT NULL COMMENT '场景编码', `rubric_name` varchar(100) NOT NULL COMMENT 'Rubric名称', @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码', `dimension_code` varchar(50) NOT NULL COMMENT '维度编码', `dimension_name` varchar(50) NOT NULL COMMENT '维度名称', diff --git a/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql b/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql index 4d03cfba..24af9e65 100644 --- a/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `turn_index` int NOT NULL COMMENT '话术序号', `role` varchar(30) NOT NULL COMMENT '音频角色', @@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( CREATE TABLE IF NOT EXISTS `aihr_practice_audio_upload` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `oss_id` bigint NOT NULL COMMENT 'OSS文件ID', `owner_user_id` bigint NOT NULL COMMENT '上传用户ID', `owner_identity` varchar(100) NOT NULL COMMENT '上传主体', @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio_upload` ( CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '训练会话ID', `reviewer` varchar(100) DEFAULT NULL COMMENT '校准人', `original_score` int DEFAULT NULL COMMENT 'AI原始总分', diff --git a/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql b/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql index 1e9e944c..fbc73112 100644 --- a/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql @@ -57,7 +57,7 @@ EXECUTE aihr_response_latency_stmt; DEALLOCATE PREPARE aihr_response_latency_stmt; CREATE TEMPORARY TABLE IF NOT EXISTS aihr_brd_rubric_codes ( - tenant_id varchar(20) NOT NULL, + tenant_id varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, rubric_code varchar(100) NOT NULL, PRIMARY KEY (tenant_id, rubric_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql b/backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql index 06ec6ab0..807f5dd0 100644 --- a/backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql +++ b/backend/script/sql/update/aihr_20260715_candidate_interview_review_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID', `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', `material_type` varchar(50) DEFAULT NULL COMMENT '资料类型', @@ -56,7 +56,7 @@ DEALLOCATE PREPARE aihr_candidate_material_reviewed_time_stmt; CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `session_id` varchar(100) NOT NULL COMMENT '面试会话ID', `candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID', `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', @@ -182,7 +182,7 @@ DEALLOCATE PREPARE aihr_interview_reviewed_time_stmt; CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID', `ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID', `status` varchar(20) DEFAULT '已关联' COMMENT '关联状态', diff --git a/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql b/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql index 715b6fa3..d958e8bd 100644 --- a/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql +++ b/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `query_text` varchar(1000) NOT NULL COMMENT '未命中问题', `category` varchar(100) DEFAULT NULL COMMENT '知识分类', `position` varchar(80) DEFAULT NULL COMMENT '岗位', @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` ( CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号', `query_text` varchar(1000) NOT NULL COMMENT '问题原文', `query_norm` varchar(500) NOT NULL COMMENT '规范化问题', `fragment_id` bigint NOT NULL COMMENT '知识片段ID', diff --git a/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql b/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql index a344e8f1..d17efab6 100644 --- a/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql +++ b/backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS `aihr_position_responsibility` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `position_code` varchar(80) NOT NULL COMMENT '岗位编码', `responsibility_text` varchar(1000) NOT NULL COMMENT '岗位职责', `sop_id` bigint DEFAULT NULL COMMENT '关联SOP主键(跨模块引用)', @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS `aihr_position_responsibility` ( CREATE TABLE IF NOT EXISTS `aihr_sop_applicability` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `sop_id` bigint DEFAULT NULL COMMENT '关联SOP主键(跨模块引用)', `sop_code` varchar(100) DEFAULT NULL COMMENT '关联SOP编码', `project_type` varchar(80) DEFAULT NULL COMMENT '项目类型:住宅/公建/景区等', @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_applicability` ( CREATE TABLE IF NOT EXISTS `aihr_onboard_task` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体ID,不复制姓名', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编码', `position_code` varchar(80) DEFAULT NULL COMMENT '岗位编码', @@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS `aihr_onboard_task` ( CREATE TABLE IF NOT EXISTS `aihr_qualification_gate` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号', `ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体ID,不复制姓名', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编码', `position_code` varchar(80) NOT NULL COMMENT '岗位编码', diff --git a/backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql b/backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql new file mode 100644 index 00000000..c6c4e2ce --- /dev/null +++ b/backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql @@ -0,0 +1,70 @@ +-- Keep AIHR tenant identifiers compatible with the framework tenant contract. +-- This migration derives the target charset/collation from sys_oss.tenant_id, +-- preserves each AIHR tenant_id column's nullability/default/comment, and is idempotent. + +DROP PROCEDURE IF EXISTS aihr_align_tenant_collation; + +DELIMITER $$ + +CREATE PROCEDURE aihr_align_tenant_collation() +BEGIN + DECLARE done int DEFAULT 0; + DECLARE target_charset varchar(64); + DECLARE target_collation varchar(64); + DECLARE current_table varchar(64); + DECLARE current_type text; + DECLARE current_nullable varchar(3); + DECLARE current_default text; + DECLARE current_comment text; + + DECLARE tenant_columns CURSOR FOR + SELECT table_name, column_type, is_nullable, column_default, column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND LEFT(table_name, 5) = 'aihr_' + AND column_name = 'tenant_id' + AND collation_name <> target_collation + ORDER BY table_name; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1; + + SELECT character_set_name, collation_name + INTO target_charset, target_collation + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_oss' + AND column_name = 'tenant_id' + LIMIT 1; + + IF target_charset IS NULL OR target_collation IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'sys_oss.tenant_id charset/collation contract is missing'; + END IF; + + OPEN tenant_columns; + align_loop: LOOP + FETCH tenant_columns + INTO current_table, current_type, current_nullable, current_default, current_comment; + IF done = 1 THEN + LEAVE align_loop; + END IF; + + SET @aihr_tenant_ddl = CONCAT( + 'ALTER TABLE `', REPLACE(current_table, '`', '``'), + '` MODIFY COLUMN `tenant_id` ', current_type, + ' CHARACTER SET `', REPLACE(target_charset, '`', '``'), + '` COLLATE `', REPLACE(target_collation, '`', '``'), '`', + IF(current_nullable = 'YES', ' NULL', ' NOT NULL'), + IF(current_default IS NULL, '', CONCAT(' DEFAULT ', QUOTE(current_default))), + ' COMMENT ', QUOTE(COALESCE(current_comment, '')) + ); + PREPARE aihr_tenant_statement FROM @aihr_tenant_ddl; + EXECUTE aihr_tenant_statement; + DEALLOCATE PREPARE aihr_tenant_statement; + END LOOP; + CLOSE tenant_columns; +END$$ + +DELIMITER ; + +CALL aihr_align_tenant_collation(); +DROP PROCEDURE aihr_align_tenant_collation; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index a6c1d515..cf00597b 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -11,12 +11,13 @@ - 对练五维、每日三题评分模式、SOP 提问主体/版本、组织入职日期字段尚未全部迁移; - `aihr_prompt_template` 表存在,但内置模板数量为 `0`; - `aihr_candidate_material` 缺少 `reviewer/reviewed_time`,`aihr_interview_result` 缺少人工复核字段和 `questions_json`,`aihr_candidate_employee_link` 尚不存在;这些候选人/HR 复核链路不能依赖首次请求触发懒迁移; +- AIHR 表的 `tenant_id` 曾使用 `utf8mb4_0900_ai_ci`,框架 `sys_oss.tenant_id` 使用 `utf8mb4_general_ci`;跨模块等值连接会报 `Illegal mix of collations`,不能靠查询吞错或单次 `binary` 兼容长期掩盖; - 这些事实不能由应用启动时的懒迁移或页面 fallback 代替。 ## 发布前置条件 1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。 -2. 先执行 `scripts/release-preflight.sh` 的只读检查;生产静态资源、后端包和当前提交必须一致。若要同时核对线上结构,追加 `RELEASE_VERIFY_REMOTE_SCHEMA=true`,该开关只读检查必需表,不执行迁移。 +2. 先执行 `scripts/release-preflight.sh` 的本地产物检查。生产迁移后、后端发布完成后,必须追加 `RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_REMOTE_BACKEND=true` 再做线上核验;该门禁会检查必需表、所有 AIHR `tenant_id` 与 `sys_oss.tenant_id` 的排序规则,以及资料处理实际连接 SQL。 3. 确认迁移目标库、租户和字符集为 MySQL 8 / `utf8mb4`;脚本只允许在目标业务库执行。 4. 生产组织同步凭据、AI 供应商留存/费用口径和正式试点窗口仍未闭合时,不要把迁移完成宣称为 BRD 验收完成。 @@ -40,9 +41,10 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_position_sop_qualification_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。 +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;租户排序规则迁移必须最后执行,以覆盖本批次新建的所有 AIHR 表。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。 ## 迁移后只读核验 @@ -84,9 +86,20 @@ SELECT COUNT(*) AS built_in_prompt_templates FROM aihr_prompt_template WHERE tenant_id = '000000' AND code IN ('summary_card', 'sop_answer', 'mentor_rewrite', 'review_annotation', 'prep_card', 'supervision_image_analysis_maintenance'); + +SELECT aihr.table_name, aihr.collation_name, core.collation_name AS expected_collation +FROM information_schema.columns aihr +JOIN information_schema.columns core + ON core.table_schema = aihr.table_schema + AND core.table_name = 'sys_oss' + AND core.column_name = 'tenant_id' +WHERE aihr.table_schema = DATABASE() + AND LEFT(aihr.table_name, 5) = 'aihr_' + AND aihr.column_name = 'tenant_id' + AND aihr.collation_name <> core.collation_name; ``` -预期是三张场景/Rubric 表、三张证据表、两张知识治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在,会话证据字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。 +预期是三张场景/Rubric 表、三张证据表、两张知识治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在,会话证据字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`,排序规则差异查询返回 `0` 行。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。 ## 迁移后应用回归 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 84d92a31..ff11791d 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -169,11 +169,11 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p' RELEASE_REMOTE_URL=https://peilian.njzhmj.top ./scripts/release-preflight.sh # 发布后核验线上主资源是否与当前本地产物一致 RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true ./scripts/release-preflight.sh -# 同时通过 SSH 校验线上后端 jar;默认使用本机 SSH alias YCWY;比较 ruoyi-aihr 模块内容,避免 ZIP 打包时间戳造成误报 -RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_BACKEND=true ./scripts/release-preflight.sh +# 同时校验线上 schema、资料处理连接 SQL 与后端 jar;后端发布验收不得省略 schema 门禁 +RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_REMOTE_BACKEND=true ./scripts/release-preflight.sh ``` -`RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。 +`RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true`、`RELEASE_VERIFY_REMOTE_SCHEMA=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。schema 门禁会拒绝 AIHR 与框架租户字段排序规则不一致,并执行资料处理页依赖的 `aihr_knowledge_attach`/`sys_oss` 连接检查。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。 带 `RELEASE_REMOTE_URL` 时,预检同时校验租户接口 JSON 的业务 `code=200`;HTTP 200 但业务返回 401/405 会判定失败。 diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 8de90123..d04fac0a 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -68,6 +68,9 @@ grep -q '/h5/' "$mobile_index" || fail "mobile H5 index does not contain the /h5 if [[ "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then fail "RELEASE_VERIFY_REMOTE_BACKEND=true requires RELEASE_VERIFY_REMOTE_MATCH=true" fi +if [[ "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" != "true" ]]; then + fail "RELEASE_VERIFY_REMOTE_BACKEND=true requires RELEASE_VERIFY_REMOTE_SCHEMA=true" +fi if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then fail "RELEASE_VERIFY_REMOTE_SCHEMA=true requires RELEASE_VERIFY_REMOTE_MATCH=true" fi @@ -111,12 +114,12 @@ require_remote_schema() { local remote_db="${RELEASE_REMOTE_DB_NAME:-ry-vue}" [[ "$remote_db" =~ ^[A-Za-z0-9_-]+$ ]] || fail "remote database name is invalid: $remote_db" - local missing - missing="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' + local issues + issues="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' -SELECT required.table_name +SELECT CONCAT('missing_table:', required.table_name) AS issue FROM ( SELECT 'aihr_prompt_template' AS table_name UNION ALL SELECT 'aihr_practice_scenario' @@ -138,13 +141,54 @@ FROM ( LEFT JOIN information_schema.tables actual ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name WHERE actual.table_name IS NULL -ORDER BY required.table_name; +UNION ALL +SELECT 'missing_column:sys_oss.tenant_id' +WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_oss' + AND column_name = 'tenant_id' +) +UNION ALL +SELECT CONCAT( + 'tenant_collation:', aihr.table_name, '.', aihr.column_name, + '=', COALESCE(aihr.collation_name, 'NULL'), + ',expected=', core.collation_name +) +FROM information_schema.columns aihr +JOIN information_schema.columns core + ON core.table_schema = aihr.table_schema + AND core.table_name = 'sys_oss' + AND core.column_name = 'tenant_id' +WHERE aihr.table_schema = DATABASE() + AND LEFT(aihr.table_name, 5) = 'aihr_' + AND aihr.column_name = 'tenant_id' + AND aihr.collation_name <> core.collation_name +ORDER BY issue; SQL REMOTE )" || fail "remote schema check failed: $remote_ssh:$remote_db" - [[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')" + [[ -z "$issues" ]] || fail "remote schema incompatible: $(printf '%s' "$issues" | tr '\n' ', ' | sed 's/, $//')" + + local processing_join_count + processing_join_count="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' +set -euo pipefail +db="$1" +mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' +SELECT COUNT(*) +FROM aihr_knowledge_attach a +LEFT JOIN sys_oss o + ON o.oss_id = a.oss_id + AND o.tenant_id = a.tenant_id; +SQL +REMOTE + )" || fail "remote processing join check failed: $remote_ssh:$remote_db" + [[ "$processing_join_count" =~ ^[0-9]+$ ]] || fail "remote processing join count is invalid: $processing_join_count" echo "remote_schema=16/16 $remote_ssh:$remote_db" + echo "remote_tenant_collation=aligned" + echo "remote_processing_join_count=$processing_join_count" } changed_files="$(git status --porcelain)" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 481b21d7..66b58cf8 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -11,6 +11,17 @@ MYSQL_CONTAINER="wygj-mysql" DB="wygj_aihr_schema_test_$$" LEGACY_DB="wygj_aihr_candidate_legacy_test_$$" +tenant_contract_violations="$({ + find "$ROOT_DIR/backend/script/sql" -maxdepth 2 -type f -name 'aihr_*_mysql8.sql' -print0 \ + | xargs -0 rg -n '^[[:space:]]*`?tenant_id`?[[:space:]]+varchar\(20\)' \ + | grep -v 'COLLATE utf8mb4_general_ci' +} || true)" +if [[ -n "$tenant_contract_violations" ]]; then + echo "FAIL: AIHR tenant_id DDL must use the framework utf8mb4_general_ci contract:" >&2 + printf '%s\n' "$tenant_contract_violations" >&2 + exit 1 +fi + while IFS= read -r migration; do migration_name="$(basename "$migration")" grep -Fq "backend/script/sql/update/$migration_name" "$ROOT_DIR/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md" || { @@ -185,4 +196,30 @@ test "$candidate_interview_tables" = 'aihr_candidate_employee_link,aihr_candidat candidate_interview_columns="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$LEGACY_DB' AND ((table_name = 'aihr_candidate_material' AND column_name IN ('reviewer','reviewed_time')) OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json','reviewed_score','review_status','reviewer','review_note','reviewed_time')));")" test "$candidate_interview_columns" = '8' -echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, and position/SOP qualification contracts" +mysql "$LEGACY_DB" <<'SQL' +CREATE TABLE sys_oss ( + oss_id bigint NOT NULL, + tenant_id varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000', + PRIMARY KEY (oss_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +CREATE TABLE aihr_collation_probe ( + id bigint NOT NULL, + oss_id bigint NOT NULL, + tenant_id varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '000000', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +INSERT INTO sys_oss (oss_id, tenant_id) VALUES (1, '000000'); +INSERT INTO aihr_collation_probe (id, oss_id, tenant_id) VALUES (1, 1, '000000'); +SQL +if mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM aihr_collation_probe a JOIN sys_oss o ON o.oss_id=a.oss_id AND o.tenant_id=a.tenant_id;" >/dev/null 2>&1; then + echo "FAIL: mixed-collation tenant join unexpectedly succeeded before migration" >&2 + exit 1 +fi +mysql "$LEGACY_DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql" +mysql "$LEGACY_DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_tenant_collation_mysql8.sql" +mismatched_tenant_columns="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$LEGACY_DB' AND table_name LIKE 'aihr\\_%' AND column_name='tenant_id' AND collation_name <> 'utf8mb4_general_ci';")" +test "$mismatched_tenant_columns" = '0' +collation_join_count="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM aihr_collation_probe a JOIN sys_oss o ON o.oss_id=a.oss_id AND o.tenant_id=a.tenant_id;")" +test "$collation_join_count" = '1' + +echo "PASS: AIHR legacy schema migrations are idempotent, including tenant collation compatibility" From c7208802ac40ce1ed0e373e6cf19500a35657527 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 13:40:06 +0800 Subject: [PATCH 3/4] fix(release): support backend-only verification --- docs/DEV_SETUP.md | 4 +++- scripts/release-preflight.sh | 34 +++++++++++++++++++--------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index ff11791d..742678ba 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -171,9 +171,11 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top ./scripts/release-preflight.sh RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true ./scripts/release-preflight.sh # 同时校验线上 schema、资料处理连接 SQL 与后端 jar;后端发布验收不得省略 schema 门禁 RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_REMOTE_BACKEND=true ./scripts/release-preflight.sh +# 仅发布后端时,显式跳过未发布的管理端/H5 hash;后端模块和 schema 仍会严格校验 +RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true RELEASE_VERIFY_REMOTE_STATIC=false RELEASE_VERIFY_REMOTE_SCHEMA=true RELEASE_VERIFY_REMOTE_BACKEND=true ./scripts/release-preflight.sh ``` -`RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true`、`RELEASE_VERIFY_REMOTE_SCHEMA=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。schema 门禁会拒绝 AIHR 与框架租户字段排序规则不一致,并执行资料处理页依赖的 `aihr_knowledge_attach`/`sys_oss` 连接检查。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。 +`RELEASE_VERIFY_REMOTE_BACKEND=true` 必须与 `RELEASE_VERIFY_REMOTE_MATCH=true`、`RELEASE_VERIFY_REMOTE_SCHEMA=true` 同时使用;预检会同时打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准。schema 门禁会拒绝 AIHR 与框架租户字段排序规则不一致,并执行资料处理页依赖的 `aihr_knowledge_attach`/`sys_oss` 连接检查。`RELEASE_VERIFY_REMOTE_STATIC=false` 只允许用于后端单组件发布,避免用未发布的本地静态包覆盖或阻断线上前端;默认仍严格核对管理端和 H5。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。 带 `RELEASE_REMOTE_URL` 时,预检同时校验租户接口 JSON 的业务 `code=200`;HTTP 200 但业务返回 401/405 会判定失败。 diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index d04fac0a..feb0fe6c 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -217,22 +217,26 @@ if [[ -n "${RELEASE_REMOTE_URL:-}" ]]; then fi if [[ "${RELEASE_VERIFY_REMOTE_MATCH:-false}" == "true" ]]; then - remote_frontend_asset="$(curl -fsS --max-time 15 "$remote/" | sed -nE 's/.*src="(\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" - remote_mobile_asset="$(curl -fsS --max-time 15 "$remote/h5/" | sed -nE 's/.*src="(\/h5\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" - [[ "$remote_frontend_asset" == /assets/*.js ]] || fail "remote frontend entry asset not found" - [[ "$remote_mobile_asset" == /h5/assets/*.js ]] || fail "remote mobile H5 entry asset not found" + if [[ "${RELEASE_VERIFY_REMOTE_STATIC:-true}" == "true" ]]; then + remote_frontend_asset="$(curl -fsS --max-time 15 "$remote/" | sed -nE 's/.*src="(\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" + remote_mobile_asset="$(curl -fsS --max-time 15 "$remote/h5/" | sed -nE 's/.*src="(\/h5\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" + [[ "$remote_frontend_asset" == /assets/*.js ]] || fail "remote frontend entry asset not found" + [[ "$remote_mobile_asset" == /h5/assets/*.js ]] || fail "remote mobile H5 entry asset not found" - remote_frontend_sha256="$(curl -fsS --max-time 15 "$remote$remote_frontend_asset" | sha256_stream)" - remote_mobile_sha256="$(curl -fsS --max-time 15 "$remote$remote_mobile_asset" | sha256_stream)" - local_frontend_sha256="$(sha256 "$frontend_asset_path")" - local_mobile_sha256="$(sha256 "$mobile_asset_path")" - echo "remote_frontend_asset=$remote_frontend_asset" - echo "remote_frontend_asset_sha256=$remote_frontend_sha256" - echo "remote_mobile_asset=$remote_mobile_asset" - echo "remote_mobile_asset_sha256=$remote_mobile_sha256" - [[ "$remote_frontend_sha256" == "$local_frontend_sha256" ]] || fail "remote frontend asset does not match local build" - [[ "$remote_mobile_sha256" == "$local_mobile_sha256" ]] || fail "remote mobile H5 asset does not match local build" - echo "remote_asset_match=true" + remote_frontend_sha256="$(curl -fsS --max-time 15 "$remote$remote_frontend_asset" | sha256_stream)" + remote_mobile_sha256="$(curl -fsS --max-time 15 "$remote$remote_mobile_asset" | sha256_stream)" + local_frontend_sha256="$(sha256 "$frontend_asset_path")" + local_mobile_sha256="$(sha256 "$mobile_asset_path")" + echo "remote_frontend_asset=$remote_frontend_asset" + echo "remote_frontend_asset_sha256=$remote_frontend_sha256" + echo "remote_mobile_asset=$remote_mobile_asset" + echo "remote_mobile_asset_sha256=$remote_mobile_sha256" + [[ "$remote_frontend_sha256" == "$local_frontend_sha256" ]] || fail "remote frontend asset does not match local build" + [[ "$remote_mobile_sha256" == "$local_mobile_sha256" ]] || fail "remote mobile H5 asset does not match local build" + echo "remote_asset_match=true" + else + echo "remote_static_match=skipped" + fi if [[ "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" ]]; then command -v ssh >/dev/null 2>&1 || fail "ssh is required for remote backend verification" From 3435663d69c76ba2e4b44655e02ee9633a1ad2a5 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 13:54:22 +0800 Subject: [PATCH 4/4] docs: reconcile production release contracts --- AGENTS.md | 21 ++++++--------------- README.md | 2 +- docs/API_INTEGRATION.md | 2 +- docs/DEV_SETUP.md | 2 +- docs/H5_REDESIGN.md | 2 +- docs/README.md | 4 ++-- docs/RUOYI_AI_INCREMENTAL_MIGRATION.md | 6 +++--- docs/物业AI人力资源系统开发规格TechSpec.md | 1 + 8 files changed, 16 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 403cbbe7..c987f0e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - `mobile-uni/` 是当前用户侧工程,承载员工端、候选人端、主管端,使用 uni-app Vue3 H5,后续便于转换 H5/APP/小程序;旧 `mobile/` 仅保留为 MVP 演示兜底,不再承载新增功能。 - 本项目自己的本地编排放在根目录:`docker-compose.dev.yml`、`scripts/`;运行说明在 `docs/DEV_SETUP.md`。 - 除非确实要接业务模块,不要改上游框架默认配置;本地端口和开关优先放根目录脚本里覆盖。 +- 新增或变更 AIHR DDL 时,所有 `aihr_*`.`tenant_id` 必须显式使用与框架 `sys_oss.tenant_id` 相同的 `utf8mb4_general_ci`;跨模块连接不能靠吞错或永久 `binary` 兼容掩盖排序规则漂移。 ## 本地运行 @@ -41,6 +42,7 @@ - 线上移动端 H5:`https://peilian.njzhmj.top/h5/`,Caddy 服务 `/opt/wygj/www/h5`。 - 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。 +- 后端或含后端发布完成后,必须按 `docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md` 运行远端 schema + 后端内容核验;仅后端发布可显式设置 `RELEASE_VERIFY_REMOTE_STATIC=false`,不得省略 schema 门禁。 ## 品牌入口 @@ -67,11 +69,11 @@ - 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。 - 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。 - AI 面试页、三角色对练页、案例沉淀页、SOP 知识库页和移动端三端首页已是 API 优先 + 本地 fallback;SOP 知识库优先查 `aihr_knowledge_fragment` 的 MySQL Fulltext,vector 模型和 Qdrant 可用时混合召回,`category=rerank` 模型启用时融合后语义重排(失败保持 RRF 顺序);支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、图片 `.jpg/.jpeg/.png/.gif/.webp/.bmp` 及视频 `.mp4/.mov/.avi/.mkv/.webm/.m4v` 上传解析入库。 -- 浏览器批量上传走异步队列 `POST /api/knowledge/doc/upload-async`(暂存目录 `aihr.upload.staging` 默认 `./.data/staging`,队列表 `aihr_knowledge_upload_item`,单文件重试);同步接口 `POST /api/knowledge/doc/upload` 只留给 SOP 页单文件即时预览,**不要把重加工逻辑加回同步请求线程**。视频(≤500MB/≤60分钟)只走异步队列,依赖服务器安装 ffmpeg/ffprobe。 +- 浏览器批量上传走异步队列 `POST /api/knowledge/doc/upload-async`(暂存目录 `aihr.upload.staging` 默认 `./.data/staging`,队列表 `aihr_knowledge_upload_item`,单文件重试);ZIP 文件名按 UTF-8 解码并兼容 GBK,必须保留 Zip Slip、条目数/解压量、嵌套 ZIP、同名文件和失败清理保护。同步接口 `POST /api/knowledge/doc/upload` 只留给 SOP 页单文件即时预览,**不要把重加工逻辑加回同步请求线程**。视频(≤500MB/≤60分钟)只走异步队列,依赖服务器安装 ffmpeg/ffprobe。 - SOP 上传接口是 `POST /api/knowledge/doc/upload`,表单字段为 `file` 和 `category`;当前限制 100MB 内,支持 txt/md/PDF/Word/Excel/PPT 及图片,先写 MinIO/`sys_oss` 再绑定 `aihr_knowledge_attach.oss_id`,同一知识库同名文件会替换旧 fragment。若数据库启用 `category=vector` 的模型配置,会同步调用 OpenAI-compatible `/embeddings`,写入 `embedding_json` 并尽力 upsert 到 Qdrant;配置缺失或外部 embedding 失败才用 `local-hash-v1` 兜底,Qdrant 不可用时保留 MySQL Fulltext/seed fallback。管理端 `uploadKnowledgeDoc` 单接口 timeout 是 180s;遇到约 50s `Broken pipe` 先查浏览器客户端超时,不要改全局 axios。 - 图片视觉 OCR:上传图片时经 `visionRuntime()` 找启用模型(优先 `aihr_model_config.category='vision'`,无则回退 `category='chat'`),把图片编码成 base64 data URL 调 OpenAI-compatible `/chat/completions` 提取文字再切分入库;无可用视觉模型或 OCR 无结果时按「待处理」落库(0 片段),不算失败。视频关键帧用带画面描述的 FRAME_PROMPT(文字+一句场景描述),改 prompt 时别退回纯 OCR。不依赖 Tesseract。 - 向量库状态接口是 `GET /api/knowledge/doc/vector-index-status`,重建接口是 `POST /api/knowledge/doc/rebuild-vector-index`;外部 embedding 成功但 Qdrant collection 维度不一致时不要静默降级,走状态提示和重建。 -- 资料处理接口是 `GET /api/knowledge/processing/overview`,直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;`POST /api/knowledge/doc/import-local` 保留同步导入,`POST /api/knowledge/doc/import-local-task` + `GET /api/knowledge/doc/import-tasks` 用于页面启动后台目录导入和轮询进度,`POST /api/knowledge/doc/import-tasks/{id}/cancel` 取消运行中任务。目录只能是 `aihr.import.root` 下的相对路径,逐文件复用上传解析链路;大批量资料优先走服务端导入任务,不走浏览器单次上传。 +- 资料处理接口 `GET /api/knowledge/processing/overview` 直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;查询异常必须向上暴露并记录,不能转换成“0 份资料”的假成功。目录导入只能读取 `aihr.import.root` 下的相对路径并复用上传链路,接口契约见 `docs/API_INTEGRATION.md`。 - seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。 - 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。 - 已落地的演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀已是真模型优先(未配置/失败回退本地兜底),SOP 知识库、移动端三端首页保留 fallback——改造时降级链必须保留。 @@ -79,16 +81,5 @@ ## 业务文档 -- 总入口:`README.md` -- 文档索引:`docs/README.md` -- Figma 设计资产清单:`docs/FIGMA_DESIGN_INVENTORY.md`;页面实现应复用既有变量与组件,以清单中的正式画板为视觉基准,不按单页自由发挥。 -- MVP 演示与验收:`docs/DEMO_ACCEPTANCE.md` -- 阶段交付归档:`docs/archive/2026-07-mvp-delivery/` -- 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md` -- AI 陪练二期推进(阶段一执行):`docs/AI陪练二期开发推进计划.md` -- 数字师傅产品定案(2026-07-08 学练问报/问题榜/学分):`docs/20260708/数字师傅学练问报整合方案.md` -- AI 陪练缺口修订:`docs/AI陪练功能优化与缺口分析(修订).md` -- 后端 API 对接:`docs/API_INTEGRATION.md` -- 对外会议版口径:`docs/AI人力资源系统项目规划方案与AI接口说明.md` -- 业务需求:`docs/物业AI人力资源系统业务需求文档BRD.md` -- 开发规格:`docs/物业AI人力资源系统开发规格TechSpec.md` +- 总入口和完整索引是 `README.md`、`docs/README.md`;归档和原始纪要不作为施工入口。 +- 页面以 `docs/FIGMA_DESIGN_INVENTORY.md` 为基准;发布、API、验收分别以对应 Runbook、`docs/API_INTEGRATION.md`、`docs/DEMO_ACCEPTANCE.md` 为准。 diff --git a/README.md b/README.md index 562e36ff..56d8d74a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。 - 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 -- 生产发布:先执行 `scripts/release-preflight.sh`,再按 [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 处理 schema、内置内容、备份、回滚和发布后回归;本地构建或 strict gate 通过,都不能单独证明线上已发布。 +- 生产发布:先执行 `scripts/release-preflight.sh`,再按 [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 处理 schema、内置内容、备份、回滚和发布后回归;后端发布必须同时核验 AIHR 租户字段排序规则、资料处理连接 SQL 和远端后端内容,仅后端发布时可显式跳过静态资源匹配。本地构建或 strict gate 通过,都不能单独证明线上已发布。 ## 快速启动 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index c87726cb..470206ff 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -70,7 +70,7 @@ SOP 文档上传第三片已经落最小后端边界: | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 | | 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 | -| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;支持的子文件写回同一 batch 继续复用解析链路;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | +| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK,同名子文件追加编号并用单条批量写入入队,失败时清理已解压文件并返回可重试的安全提示;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无 asr 且无 vision 结果时按「待处理」落库不报错;视频同时只加工 1 个 | 模型能力第二片已经落最小后端边界: diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 742678ba..e264b700 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -124,7 +124,7 @@ AIHR_AI_SPEECH_ENABLED=true - `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot` - 组织同步生产默认关闭 `aihr.org-sync.store-display-fields`,不把外部姓名/部门写入或返回组织人员展示快照;开发环境显式打开该开关仅用于 Demo。项目范围、岗位和外部主体 ID仍用于权限与身份映射。 - 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围 -- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。资料处理中心异步接口另支持 `.zip`(≤500MB),后台安全解压后逐文件入队;最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP +- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。资料处理中心异步接口另支持 `.zip`(≤500MB),后台安全解压后逐文件入队;最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP,文件名优先 UTF-8 并兼容 GBK,同名子文件自动编号,路径或大小异常会清理已解压文件并保留原队列项供重试 - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 diff --git a/docs/H5_REDESIGN.md b/docs/H5_REDESIGN.md index cd120fee..a98f00e5 100644 --- a/docs/H5_REDESIGN.md +++ b/docs/H5_REDESIGN.md @@ -89,7 +89,7 @@ demo 数据、今日安排样例、问师傅 placeholder、对练入口场景, 师傅形象层(§2.0)统摄两个面板:今日安排以师傅口吻叙述,问答从师傅气泡发起。 **职责地图按岗位配置渲染**(不写死布局):岗位 → 模块权重/排序/入口集,查配置表。 -生活顾问 [问][练] 大区;保安/保洁将来 [学](视频)为大区。 +生活顾问「问/练」为大区;保安/保洁将来「学(视频)」为大区。 这张配置表即分发引擎规则版 v1 的第一块积木(见 §4)。 **「问师傅」= 产品灵魂交互**(入口即 §2.0 形象层的语音/文字/chips)。回答统一用**答案卡系统**: diff --git a/docs/README.md b/docs/README.md index 28eee6da..d506dce4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ | [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) | 70 张正式画板与代码/线上实现状态、二期边界和剩余动作 | | [FIGMA_ONLINE_DELTA.md](FIGMA_ONLINE_DELTA.md) | Figma 目标与线上 H5 实测差异矩阵、当前实施边界和验收口径 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 | -| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | +| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练/复盘、候选资料、SOP 与 ZIP 异步上传解析契约 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | | [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 | | [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜、员工个人 AI 助理) → 北森对接;身份三轴模型与防遗漏分发策略 | @@ -20,7 +20,7 @@ | [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 个人 AI 助理阶段二按 P0/P1 拆分的逐任务实施、测试、提交和试点闸门 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | BRD 功能、权限、迁移、线上资源和未完成项的证据记录;不替代正式发布验收 | -| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、只读核验和发布后回归口径 | +| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产迁移、租户排序规则、后端单组件发布门禁和发布后回归口径 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)、半年会发布会方案分层及客户需求逐项处置对照 | | [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 | diff --git a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md index 77eb72b0..9c3dedb9 100644 --- a/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md +++ b/docs/RUOYI_AI_INCREMENTAL_MIGRATION.md @@ -17,7 +17,7 @@ | 2. 模型能力 | `chat_provider / chat_model` 改造为 `aihr_model_*`,最小 OpenAI-compatible 调用边界 | 多模型市场、图像、SSE、成本看板 | 模型列表可查;数据库配置后可调用 `/chat/completions` | | 3. 文档解析 | 文本/Markdown/PDF/Word/Excel/PPT loader、OSS-first 上传与分片 | 异步重试队列、图片智能分析 | txt/md/PDF/Word/Excel/PPT 文件可落 OSS 并解析为 fragment | | 4. 检索与 embedding | MySQL Fulltext + OpenAI-compatible embedding + Qdrant 最小向量召回 | Milvus/Weaviate 全量适配、独立向量库后台 | 同一问题返回带分数片段,配置 vector 模型后片段有 embedding,Qdrant 可用时参与 RRF 融合 | -| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/目录选择上传、服务端后台目录导入任务 | 单失败文件重试、分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态并导入 MVP 样例文件 | +| 5. 资料处理状态 | 解析任务状态页、资料分类、处理链路、多文件/目录选择上传、ZIP 异步展开、单文件重试、服务端后台目录导入任务 | 分布式队列 | 能查看等待解析/解析中/已完成/失败、片段数、向量化状态并导入 MVP 样例文件 | | 6. RAG 回答 | 带引用回答、训练题生成 | 多模型市场、成本看板 | SOP 页面展示真实引用 | | 7. Chat | 最小会话、消息、引用来源 | 多智能体、工作流、MCP、Skills | 可围绕 SOP 连续追问 | @@ -73,7 +73,7 @@ - 新增后端 API:`GET /api/knowledge/processing/overview`。 - 新增前端页面:`/knowledge/processing`,菜单名“资料处理”;`SOP知识库` 菜单保持独立不改名。 - 页面聚合展示资料总量、已完成、处理中、失败、资料分类、解析任务表、处理链路、规则风险和事件列表。 -- 批量导入先复用 `POST /api/knowledge/doc/upload`,支持多文件和浏览器目录选择。 +- 页面批量导入走 `POST /api/knowledge/doc/upload-async`,支持多文件、浏览器目录选择和 ZIP;按批次轮询并可单文件重试,重加工不进入同步请求线程。 - 新增 `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks` 和 `POST /api/knowledge/doc/import-tasks/{id}/cancel`,只读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入 `aihr_knowledge_import_task` 后后台逐文件复用上传解析链路,页面轮询进度并支持取消运行中任务;`POST /api/knowledge/doc/import-local` 保留同步调试。 ## 当前第六片 @@ -92,5 +92,5 @@ - 工作流、多智能体、MCP、Skills - 全量模型管理后台 - 完整流式 chat 与消息持久化 -- 单失败文件重试、导入任务暂停、分布式队列 +- 导入任务暂停、分布式队列 - 大规模资料的异步向量重建进度条 diff --git a/docs/物业AI人力资源系统开发规格TechSpec.md b/docs/物业AI人力资源系统开发规格TechSpec.md index 93b8e07a..8c0ea817 100644 --- a/docs/物业AI人力资源系统开发规格TechSpec.md +++ b/docs/物业AI人力资源系统开发规格TechSpec.md @@ -268,6 +268,7 @@ POST /api/ai/score req:{ rubricId, dialogue } resp:{ dimensions, co - 同步:`POST /api/sync/pull`(拉取组织人员,MVP 可读快照文件) - 知识检索(RAG):`POST /api/knowledge/search` → `{ queryText, category, answer, reference, docs[], snippets[], training[], records[] }` - 知识上传解析:`POST /api/knowledge/doc/upload` → `multipart/form-data { file, category }`,支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、100MB 内;先落 `sys_oss` 并绑定 `aihr_knowledge_attach.oss_id`,返回 `{ docId, ossId, fileName, category, fragments, snippets[] }`;管理端上传请求 timeout 为 180s。 +- 批量异步上传:`POST /api/knowledge/doc/upload-async` → `multipart/form-data { file, category, batchId }`,只暂存并入队;ZIP ≤500MB,最多 1000 个子文件/2GB 解压量,拒绝 Zip Slip 与嵌套 ZIP,文件名兼容 UTF-8/GBK,同名子文件自动编号;`GET /api/knowledge/doc/upload-items` 轮询批次,`POST /api/knowledge/doc/upload-items/{id}/retry` 单文件重试。 - 服务端目录导入:`POST /api/knowledge/doc/import-local-task` → `{ directory, category, limit }`,只允许读取 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,写入后台导入任务并逐文件复用上传解析链路;`GET /api/knowledge/doc/import-tasks` 返回任务进度列表;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 取消运行中任务;`POST /api/knowledge/doc/import-local` 保留同步调试。 - 资料处理状态:`GET /api/knowledge/processing/overview` → 聚合附件状态、片段数、向量化状态、分类、处理链路和事件列表。 - 片段向量化:同一上传接口在 `category=vector` 模型配置可用时调用 OpenAI-compatible `/embeddings`,写入 `embedding_json/embedding_model/embedding_time`,并尽力 upsert 到 Qdrant;未配置或 Qdrant 不可用不阻塞上传。