diff --git a/AGENTS.md b/AGENTS.md index 23192874..6bcc0a82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ - seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。 - 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。 - 已落地的演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀已是真模型优先(未配置/失败回退本地兜底),SOP 知识库、移动端三端首页保留 fallback——改造时降级链必须保留。 -- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,上传队列是 `aihr_knowledge_upload_item`,导入任务是 `aihr_knowledge_import_task`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`,候选人补充资料是 `aihr_candidate_material`,组织人员快照是 `aihr_org_snapshot`(本地 reset 有 Demo seed;生产/试点从 `docs/open-org-sync-api-design-v1.md` 对应开放组织系统同步,入口 `POST /api/aihr/org/sync`)。 +- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,上传队列是 `aihr_knowledge_upload_item`,导入任务是 `aihr_knowledge_import_task`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`,候选人补充资料是 `aihr_candidate_material`,组织人员快照是 `aihr_org_snapshot`(本地 reset 有 Demo seed;生产/试点从 `docs/open-org-sync-api-design-v1.md` 对应开放组织系统同步,入口 `POST /api/aihr/org/sync`)。外部快照只有 `position_id` 时,可用 HR 签认的 `AIHR_ORG_POSITION_MAP_JSON` 在同步写入前映射岗位;外部显式岗位优先,层级只允许“一线/主管/项目经理”,必须先 dry-run 核对命中/主管人数,禁止按 ID 猜角色。 - 2026-07-15 生产开放员工快照只返回 `position_id`,未返回岗位名称,开放 API 也没有岗位字典资源;因此生产 2943 名在职人员当前全部落为 `position_name='员工'`、`position_level='一线'`,主管数为 0。补齐开放平台岗位字段/字典或 HR 正式 `position_id` 映射并重新同步前,不得宣称主管身份与项目权限已验收,也不要在代码里猜 `position_id` 的角色含义。 ## 业务文档 diff --git a/README.md b/README.md index 318f6708..d4987eb4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。三端输入不是统一全模态:员工对练/每日题支持文字和语音、问师傅仅文字,候选面试支持文字和语音且补充资料可传图片,主管端以文字复盘和录音回听为主;详见 [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md)。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 -- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。2026-07-15 已在线上完成外部同步配置和覆盖同步,生产快照为 3417 名员工,其中 3392 人可通过手机号映射,25 人为上游手机号数据不可用。当前开放快照只有 `position_id`、没有岗位名称或岗位字典,生产 2943 名在职人员暂全部归为“一线”,主管身份与项目权限仍待上游/HR 补正式岗位映射后验收。 +- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。2026-07-15 已在线上完成外部同步配置和覆盖同步,生产快照为 3417 名员工,其中 3392 人可通过手机号映射,25 人为上游手机号数据不可用。当前开放快照只有 `position_id`、没有岗位名称或岗位字典;代码已支持用 HR 签认的 `AIHR_ORG_POSITION_MAP_JSON` 在同步时兜底映射岗位,但生产尚未配置,2943 名在职人员暂全部归为“一线”,主管身份与项目权限仍待映射后验收。 - 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。 - 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index bb7ea317..e06b95a6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -46,6 +46,7 @@ public class AihrOrgSyncService { private static final int MAX_PAGE_SIZE = 500; private static final int DEFAULT_MAX_PAGES = 200; private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30); + private static final Set POSITION_LEVELS = Set.of("一线", "主管", "项目经理"); private static final String[] PHONE_FIELDS = {"phone", "phone_number", "phoneNumber", "mobile", "mobile_phone", "mobilePhone", "mobile_tel", "mobileTel", "cellphone", "cellPhone", "contact_phone", "contactPhone", "contact_mobile", "contactMobile", "phonenumber", "telephone", "tel", "手机号", "手机", "联系电话"}; @@ -72,11 +73,15 @@ public class AihrOrgSyncService { @Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}") private boolean storeDisplayFields; + @Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}") + private String configuredPositionMapJson; + public SyncResponse sync(SyncRequest request) { SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request; if (req.dryRun() == null) { throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入"); } + Map positionMappings = positionMappings(); String baseUrl = normalizeBaseUrl(configuredBaseUrl); if (baseUrl.isBlank()) { throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); @@ -103,7 +108,7 @@ public class AihrOrgSyncService { int skipped = 0; int maskedPhone = 0; for (JsonNode employee : employeeItems) { - OrgRow row = orgRow(employee, companies, departments); + OrgRow row = orgRow(employee, companies, departments, positionMappings); if (row == null) { skipped++; } else { @@ -116,6 +121,13 @@ public class AihrOrgSyncService { int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count(); int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count(); int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList()); + if (!positionMappings.isEmpty()) { + int mappedPositions = (int) rows.stream().filter(OrgRow::positionMapped).count(); + int supervisors = (int) rows.stream() + .filter(row -> "主管".equals(row.positionLevel()) || "项目经理".equals(row.positionLevel())) + .count(); + warnings.add("HR 岗位映射命中 " + mappedPositions + " 人,解析主管/项目经理 " + supervisors + " 人,请核对后再写入"); + } if (!rows.isEmpty() && phoneLinked < rows.size()) { warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员"); } @@ -375,7 +387,8 @@ public class AihrOrgSyncService { }); } - private OrgRow orgRow(JsonNode employee, Map companies, Map departments) { + private OrgRow orgRow(JsonNode employee, Map companies, Map departments, + Map positionMappings) { String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id")); if (extPartyId.isBlank()) { return null; @@ -399,9 +412,16 @@ public class AihrOrgSyncService { "组织架构" ); String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"); - String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", - "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); - String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); + String externalPositionName = text(employee, "position_name", "positionName", "job_title", "jobTitle", + "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"); + String positionId = firstNonBlank(text(employee, "position_id", "positionId", "job_id", "jobId", "post_id", "postId")); + PositionMapping mappedPosition = positionMappings.get(positionId); + boolean positionMapped = externalPositionName.isBlank() && mappedPosition != null; + String positionName = firstNonBlank(externalPositionName, mappedPosition == null ? "" : mappedPosition.name(), "员工"); + String externalPositionLevel = text(employee, "position_level", "positionLevel", "job_level", "jobLevel"); + String resolvedPositionLevel = positionMapped + ? mappedPosition.level() + : positionLevel(firstNonBlank(externalPositionLevel, level(positionName)), positionName); LocalDate hireDate = parseDate(firstNonBlank( text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate"))); boolean suspectText = hasQuestionMark(projectName) @@ -416,7 +436,8 @@ public class AihrOrgSyncService { phone(employee), storeDisplayFields ? personName : "", positionName, - positionLevel(positionLevel, positionName), + resolvedPositionLevel, + positionMapped, status(text(employee, "status", "employment_status", "employmentStatus")), hireDate, LocalDate.now(), @@ -602,6 +623,36 @@ public class AihrOrgSyncService { return level; } + private Map positionMappings() { + String json = clean(configuredPositionMapJson); + if (json.isBlank()) { + return Map.of(); + } + try { + JsonNode root = objectMapper.readTree(json); + if (!root.isObject()) { + throw new IllegalArgumentException("岗位映射必须是 JSON 对象"); + } + Map mappings = new TreeMap<>(); + var fields = root.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + String positionId = clean(entry.getKey()); + String name = text(entry.getValue(), "name", "positionName", "position_name"); + String level = text(entry.getValue(), "level", "positionLevel", "position_level"); + if (positionId.isBlank() || name.isBlank() || name.length() > 100 || !POSITION_LEVELS.contains(level)) { + throw new IllegalArgumentException("岗位映射必须包含 position_id、岗位名称及合法层级(一线/主管/项目经理)"); + } + mappings.put(positionId, new PositionMapping(name, level)); + } + return Map.copyOf(mappings); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("岗位映射 JSON 配置无效", e); + } + } + private static String phone(JsonNode employee) { for (String field : PHONE_FIELDS) { String normalized = normalizeMobilePhone(text(employee, field)); @@ -740,6 +791,9 @@ public class AihrOrgSyncService { private record DepartmentInfo(String code, String name, String companyId) { } + private record PositionMapping(String name, String level) { + } + private record OrgRow( String projectCode, String projectName, @@ -749,6 +803,7 @@ public class AihrOrgSyncService { String personName, String positionName, String positionLevel, + boolean positionMapped, String employmentStatus, LocalDate hireDate, LocalDate snapshotDate, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index c7b12fa6..e999330f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -79,13 +79,13 @@ public class AihrOrgSyncServiceTest { """); ReflectionTestUtils.setField(service, "storeDisplayFields", false); - Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of()); assertEquals("", ReflectionTestUtils.getField(masked, "personName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate")); ReflectionTestUtils.setField(service, "storeDisplayFields", true); - Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of()); assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName")); assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate")); @@ -100,13 +100,51 @@ public class AihrOrgSyncServiceTest { """); ReflectionTestUtils.setField(service, "storeDisplayFields", false); - Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of()); assertEquals("", ReflectionTestUtils.getField(masked, "personName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); assertEquals(true, ReflectionTestUtils.getField(masked, "suspectText")); } + @Test + public void signedPositionMapOnlyFillsMissingUpstreamJobFields() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AihrOrgSyncService service = new AihrOrgSyncService(mapper, mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "configuredPositionMapJson", + "{\"1683\":{\"name\":\"项目经理\",\"level\":\"项目经理\"}," + + "\"316\":{\"name\":\"客服主管\",\"level\":\"一线\"}}"); + Map mappings = ReflectionTestUtils.invokeMethod(service, "positionMappings"); + + Object mapped = ReflectionTestUtils.invokeMethod(service, "orgRow", + mapper.readTree("{\"id\":\"EMP-1\",\"position_id\":\"1683\"}"), Map.of(), Map.of(), mappings); + assertEquals("项目经理", ReflectionTestUtils.getField(mapped, "positionName")); + assertEquals("项目经理", ReflectionTestUtils.getField(mapped, "positionLevel")); + assertEquals(true, ReflectionTestUtils.getField(mapped, "positionMapped")); + + Object upstream = ReflectionTestUtils.invokeMethod(service, "orgRow", + mapper.readTree("{\"id\":\"EMP-2\",\"position_id\":\"1683\",\"position_name\":\"客服专员\",\"position_level\":\"一线\"}"), + Map.of(), Map.of(), mappings); + assertEquals("客服专员", ReflectionTestUtils.getField(upstream, "positionName")); + assertEquals("一线", ReflectionTestUtils.getField(upstream, "positionLevel")); + assertEquals(false, ReflectionTestUtils.getField(upstream, "positionMapped")); + + Object noImplicitPromotion = ReflectionTestUtils.invokeMethod(service, "orgRow", + mapper.readTree("{\"id\":\"EMP-3\",\"position_id\":\"316\"}"), Map.of(), Map.of(), mappings); + assertEquals("客服主管", ReflectionTestUtils.getField(noImplicitPromotion, "positionName")); + assertEquals("一线", ReflectionTestUtils.getField(noImplicitPromotion, "positionLevel")); + } + + @Test + public void positionMapRejectsPrivilegeLevelsOutsideTheContract() { + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + ReflectionTestUtils.setField(service, "configuredPositionMapJson", + "{\"1683\":{\"name\":\"项目经理\",\"level\":\"超级管理员\"}}"); + + assertThrows(IllegalArgumentException.class, + () -> ReflectionTestUtils.invokeMethod(service, "positionMappings")); + } + @Test public void hiddenDisplayFieldsStillAllowIdentitySearchByPhone() { AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); @@ -158,7 +196,7 @@ public class AihrOrgSyncServiceTest { server.createContext("/api/open/v1/sync/snapshot", exchange -> { boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee"); String items = employee - ? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]" + ? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\",\"position_id\":\"1683\"}]" : "[]"; byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0) + ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8); @@ -179,6 +217,8 @@ public class AihrOrgSyncServiceTest { ReflectionTestUtils.setField(service, "configuredClientId", ""); ReflectionTestUtils.setField(service, "configuredClientSecret", ""); ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); + ReflectionTestUtils.setField(service, "configuredPositionMapJson", + "{\"1683\":{\"name\":\"项目经理\",\"level\":\"项目经理\"}}"); var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null, null)); @@ -186,6 +226,7 @@ public class AihrOrgSyncServiceTest { assertEquals(1, response.employeeCount()); assertEquals(0, response.syncedCount()); assertEquals(1, response.phoneLinked()); + assertTrue(response.warnings().stream().anyMatch(warning -> warning.contains("命中 1 人,解析主管/项目经理 1 人"))); verifyNoInteractions(jdbcTemplate, transactionTemplate); } finally { server.stop(0); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 62826c75..0521f5de 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -15,7 +15,7 @@ | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页按当前 APP 身份读取正式岗前/入职任务,员工只能将本人处于“待完成/进行中”的任务确认完成,服务端按组织快照/手机号归属更新 `status/completed_time`,不接受前端身份参数;资格证据无正式数据时明确返回 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | -| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入。2026-07-15 线上已完成覆盖同步:公司 17、部门 963、员工 3417,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前开放员工项只有 `position_id`,没有上述岗位名称字段,开放 API 也无岗位字典资源;生产岗位统一回退为“员工/一线”,主管数为 0。人员/手机号同步已就绪,但主管身份与项目权限仍依赖上游岗位字段/字典或 HR 正式映射 | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入。外部员工缺岗位名称时,可由运维配置 HR 签认的 `AIHR_ORG_POSITION_MAP_JSON`,按 `position_id` 填充岗位名称/层级;外部显式岗位字段优先,映射层级只允许“一线/主管/项目经理”。2026-07-15 线上已完成覆盖同步:公司 17、部门 963、员工 3417,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前开放员工项只有 `position_id`,没有上述岗位名称字段,开放 API 也无岗位字典资源;生产尚未配置签认映射,岗位统一回退为“员工/一线”,主管数为 0 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端。生产当前因上游岗位字段缺失只能识别为“一线”,不能用手工选择生活顾问替代主管身份验收 | @@ -101,6 +101,7 @@ Qdrant 本地默认值可不配;需要覆盖时用 JVM property 或环境变 | `AIHR_ORG_SYNC_CLIENT_ID` / `-Daihr.org-sync.client-id` | 空 | 开放平台应用 `client_id`,也用于签名头 `X-Client-Id` | | `AIHR_ORG_SYNC_CLIENT_SECRET` / `-Daihr.org-sync.client-secret` | 空 | `POST /auth/token` 换取访问令牌 | | `AIHR_ORG_SYNC_SIGNING_SECRET` / `-Daihr.org-sync.signing-secret` | 空 | 可选 HMAC-SHA256 签名密钥;为空时使用 `AIHR_ORG_SYNC_CLIENT_SECRET`,业务请求带 `X-Client-Secret/X-Timestamp/X-Nonce/X-Signature`,签名 path 必须包含 `/api/open/v1` | +| `AIHR_ORG_POSITION_MAP_JSON` / `-Daihr.org-sync.position-map-json` | 空 | HR 签认岗位映射,例如 `{"1683":{"name":"项目经理","level":"项目经理"}}`;只在外部员工缺岗位名称时按 `position_id` 兜底,层级仅允许“一线/主管/项目经理”,dry-run 的 `warnings` 会返回映射命中人数及解析出的主管/项目经理人数 | 本地启动脚本会先加载根目录 `.env.local`,再加载 `backend/.env`。如果 `backend/.env` 沿用开放平台字段名 `client_id` / `client_secret`,脚本会映射为 `AIHR_ORG_SYNC_CLIENT_ID` / `AIHR_ORG_SYNC_CLIENT_SECRET`。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 6e5cb57d..7e70260f 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -22,7 +22,9 @@ - 2026-07-15 开放平台岗位契约复核:直接只读检查外部快照键,员工项包含 `position_id`,但不包含 `position_name/job_title/post_name/job_name/role_name/title`;单员工详情同样只有 `position_id`,`position/positions/post` 快照资源均不可用。因此不能在 AIHR 端猜测 `position_id` 角色,需开放平台补岗位名称/岗位字典,或由 HR 签字提供 `position_id → 岗位名称/主管层级` 映射后重新同步。当前生产只读缺口为 SOP 文档 `3/5`、案例 `0/20`、校准 `0/20`、SOP 人工评审 `0`、满意度 `0`、候选资料 `0`、正式岗位适用 `0`、入职任务 `0`、资格门禁 `0`;在职手机号可用 `2927/2943`。 -- 2026-07-15 B11 岗位映射输入准备:按现有开放平台鉴权契约只读遍历员工快照并仅聚合非 PII 字段,得到 `90` 个不同 `position_id`,合计员工 `3417`、在职 `2943`,与生产组织快照总量一致;已生成 `docs/BRD-B11岗位映射签认清单-20260715.csv`,供 HR 填写岗位名称、层级和主管标记。进一步核对确认生产 `aihr_org_snapshot` 当前没有 `position_id` 列,现有同步也未持久化该字段;因此短期 HR 映射必须先由开放平台套用并随员工快照返回岗位名称。若改为本系统持久化/自动套用映射,必须单独批准并先执行 schema 迁移。本轮未修改生产 schema 或业务数据。 +- 2026-07-15 B11 岗位映射输入准备:按现有开放平台鉴权契约只读遍历员工快照并仅聚合非 PII 字段,得到 `90` 个不同 `position_id`,合计员工 `3417`、在职 `2943`,与生产组织快照总量一致;已生成 `docs/BRD-B11岗位映射签认清单-20260715.csv`,供 HR 填写岗位名称、层级和主管标记。进一步核对确认生产 `aihr_org_snapshot` 当前没有 `position_id` 列,不能在同步完成后直接按该键回填;可由开放平台随快照返回岗位名称,或由同步服务在写入前套用签认映射。本轮未修改生产 schema 或业务数据。 + +- 2026-07-15 B11 签认映射最小实现:组织同步新增私密配置 `AIHR_ORG_POSITION_MAP_JSON`,在外部员工缺岗位名称时按 `position_id` 填充 HR 签认的岗位名称/层级;上游显式岗位字段始终优先,配置层级严格限制为“一线/主管/项目经理”,非法配置在同步前直接拒绝。该方案复用现有同步入口和 `position_name/position_level` 字段,不新增表、页面或写接口,也不需要持久化 `position_id`。`AihrOrgSyncServiceTest` 真跑 `13/13` 通过;代码尚未发布,生产映射仍为空,主管数仍为 0。 - 2026-07-15 管理端资料处理分页静态发布:已备份生产根目录至 `/opt/wygj/backups/www-20260715134936`,再同步 `frontend/dist/`;根目录同步未使用 `--delete`,因此未覆盖同目录的 `/h5`。线上 `processing-yUVzof10.js` SHA-256 与本地构建一致。真实管理员会话确认任务分页共 97 页、第二页仍展示 10 条,状态筛选后回到第一页;根站、H5 和租户接口均返回 `200`。本轮未发布后端、迁移数据库或写入业务数据。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 3ed1ee5d..b4af912e 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -123,6 +123,7 @@ AIHR_AI_SPEECH_ENABLED=true - `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` - `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot`。2026-07-15 生产已完成该配置和覆盖同步,线上快照为 3417 名员工。 - 组织同步生产默认关闭 `aihr.org-sync.store-display-fields`,不把外部姓名/部门写入或返回组织人员展示快照;开发环境显式打开该开关仅用于 Demo。项目范围、岗位和外部主体 ID仍用于权限与身份映射。 +- 外部员工只有 `position_id`、没有岗位名称时,可在私密环境配置中设置 HR 签认映射,例如 `AIHR_ORG_POSITION_MAP_JSON='{"1683":{"name":"项目经理","level":"项目经理"}}'`;只允许“一线/主管/项目经理”三种层级,外部显式岗位字段优先。该配置会影响主管权限,必须先 dry-run,再执行覆盖同步并复核主管数与项目范围。 - 本地组织 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 - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) diff --git a/docs/下一步工作计划-BRD对齐-20260715.md b/docs/下一步工作计划-BRD对齐-20260715.md index 237b9e71..051f4c79 100644 --- a/docs/下一步工作计划-BRD对齐-20260715.md +++ b/docs/下一步工作计划-BRD对齐-20260715.md @@ -1,6 +1,6 @@ # 下一步工作计划(BRD 对齐版) -> 版本:v2.3(2026-07-15 执行复核:发布完成;B11 已生成 90 个岗位 ID 的 HR 签认清单,确认本地快照尚未保存 `position_id`) +> 版本:v2.4(2026-07-15 执行复核:发布完成;B11 已生成 90 个岗位 ID 的 HR 签认清单并补齐同步时签认映射能力) > 基准:《物业AI人力资源系统业务需求文档BRD.md》v1.2(唯一事实源) > 阶段边界:《银城员工端APP分阶段实施总纲.md》阶段一(陪练)+ 2026-07-08《数字师傅学练问报整合方案》定案 > 现状证据:《BRD_IMPLEMENTATION_AUDIT.md》2026-07-15 最新条目 + 本日生产只读复核 @@ -47,7 +47,7 @@ | # | 需要的输入 | 提供方 | 负责人 | 回复期限 | 兜底触发日 | 阻塞的 BRD 条款 | 不给的后果 | |---|---|---|---|---|---|---|---| | B3 | **试点项目圈定(1–2 个住宅项目)+ 窗口起止日期 + ≥20 名员工名单 + 每项目至少 1 名主管**(员工可从手机号映射中圈定,主管需与 B11 的正式岗位映射一致) | HR 负责人 | 待指派 | 07-18 | 07-22 | 10.2-D | M5 全部指标无分母;半年会没有"真实使用数据" | -| B11 | **补齐岗位契约**:开放员工快照直接返回 `position_name/job_title`,或开放岗位字典资源;HR 可直接填写 `BRD-B11岗位映射签认清单-20260715.csv`(90 个岗位 ID,合计 3417 人/在职 2943 人)并提供试点主管名单。当前 `aihr_org_snapshot` 未保存 `position_id`,所以短期映射必须由开放平台套用后随快照返回岗位名称;若要求本系统自动套用,须另行批准 schema 变更并先发布迁移。不得按 ID 猜角色 | 开放平台+HR | 待指派 | 07-18 | 07-22 | 5.4、G3、L1-B | 生产 2943 名在职人员全部落为“一线”,主管端和项目级权限无法认证态验收 | +| B11 | **补齐岗位契约**:开放员工快照直接返回 `position_name/job_title`,或开放岗位字典资源;HR 可直接填写 `BRD-B11岗位映射签认清单-20260715.csv`(90 个岗位 ID,合计 3417 人/在职 2943 人)并提供试点主管名单。若开放平台短期不能改接口,运维可将签认结果配置为 `AIHR_ORG_POSITION_MAP_JSON`,系统只在外部缺岗位名称时按 `position_id` 套用,层级仅允许“一线/主管/项目经理”;先 dry-run,再覆盖同步并验收项目权限。不得按 ID 猜角色 | 开放平台+HR | 待指派 | 07-18 | 07-22 | 5.4、G3、L1-B | 生产 2943 名在职人员全部落为“一线”,主管端和项目级权限无法认证态验收 | | B2 | 补 2 份真实 SOP 文档(现 3/5),并由业务确认全部五类核心流程的成功条件、价格/取消规则、回访口径;随后完成 14 条场景/SOP 的人工评审(现 0/14) | 内容负责人/HR | 待指派 | 07-20 | 07-24 | 4.3.4、L1-B(SOP≥5) | SOP 覆盖与 RAG 可用率(≥80%)无法验收 | | B9 | **历史案例批量提供**:试点前 **≥20 条**历史案例语音/材料入库并脱敏审核,不依赖试点新增量补齐验收底线;试点期继续按"每周每项目 1 条"采集新案例 | 各项目负责人 | 待指派 | 07-22 | 07-25 | 4.6、L1-B(案例≥20) | 案例库验收必然失败 | | B4 | 方言 ASR 厂商选型(**阶段一只选 1 种方言**,按试点区域确认;BRD 9.1 只要求 1 条路径)+ 费用授权 + **同时确认 CER 达标阈值**(BRD 只定了 ≤5s 响应,识别准确率的验收线需业务拍板,否则 C3 实测无法判定"达标") | 技术组+HR | 待指派 | 07-20 | 07-24 | 4.3.3、9.1、L1-C | MVP 阻塞项零实测,且测了也无法判定通过 | @@ -118,7 +118,7 @@ ## 7. 风险与止损 1. **B3 试点圈定拖延是当前最大单点风险**(组织数据已就位,决策成本已最低):07-22 仍无答复,升级 HR 负责人;试点每晚一周,半年会"真实使用数据"窗口就少一周。 -2. **B11 主管岗位缺失**:当前开放快照只有 `position_id`,生产主管数为 0;90 个岗位 ID 已整理为 HR 签认清单,但本地快照不保存该键。07-22 前拿不到上游岗位名称/字典,或未批准本系统持久化并套用映射,主管复盘/跨项目权限不能验收,试点只能暂停而不能猜角色替代。 +2. **B11 主管岗位缺失**:当前开放快照只有 `position_id`,生产主管数为 0;90 个岗位 ID 已整理为 HR 签认清单,同步服务已支持签认 JSON 映射且不需改库。07-22 前拿不到上游岗位名称/字典或 HR 正式映射,主管复盘/跨项目权限不能验收,试点只能暂停而不能猜角色替代。 3. **案例 20 条**:依赖 B9 存量导入;若试点前不足 20 条,立即提高采集频率(每项目每周 2–3 条)并如实下调收口预期,不用未脱敏或演示数据凑数。 4. **发布失败**:A3 原子流程任一步失败即按备份回滚,当天不重试第二次,先归因。 5. **范围失控**(BRD 附录 C 头号风险):任何新页面/新功能请求一律进 `docs/BACKLOG.md`;每周对照本计划复盘一次偏差。 @@ -127,4 +127,4 @@ --- -*本计划由工程侧起草,v2.3 已按 2026-07-15 生产执行结果及 B11 外部接口复核修订;B 表负责人/期限需业务方确认后生效。执行进展回写《BRD_IMPLEMENTATION_AUDIT.md》,本文件只改计划不记证据。* +*本计划由工程侧起草,v2.4 已按 2026-07-15 生产执行结果及 B11 外部接口复核修订;B 表负责人/期限需业务方确认后生效。执行进展回写《BRD_IMPLEMENTATION_AUDIT.md》,本文件只改计划不记证据。*