feat(aihr): support signed position mappings

This commit is contained in:
2026-07-15 16:32:06 +08:00
parent 07a08cecde
commit c866ef2701
8 changed files with 118 additions and 18 deletions
+1 -1
View File
@@ -77,7 +77,7 @@
- seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。 - seed service 阶段已完成;后续按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移 `ruoyi-ai` 的知识库、文档解析、RAG、chat 能力。
- 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。 - 迁移时不要整包搬 `ruoyi-chat`;优先在 `ruoyi-aihr` 内吸收必要表、service、loader 和检索接口,并保留 seed fallback。
- 已落地的演示流不要退回静态壳:AI 面试、三角色对练、案例沉淀已是真模型优先(未配置/失败回退本地兜底),SOP 知识库、移动端三端首页保留 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` 的角色含义。 - 2026-07-15 生产开放员工快照只返回 `position_id`,未返回岗位名称,开放 API 也没有岗位字典资源;因此生产 2943 名在职人员当前全部落为 `position_name='员工'`、`position_level='一线'`,主管数为 0。补齐开放平台岗位字段/字典或 HR 正式 `position_id` 映射并重新同步前,不得宣称主管身份与项目权限已验收,也不要在代码里猜 `position_id` 的角色含义。
## 业务文档 ## 业务文档
+1 -1
View File
@@ -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)。 - 用户侧 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 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 英雄路径: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_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)。 - 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。组织身份同步和至少 20 名手机号映射门槛已具备;下一步仍需选定真实试点窗口,产生员工训练、人工校准和 SOP 评审样本后重跑严格门禁,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。 - 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。 - 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
@@ -46,6 +46,7 @@ public class AihrOrgSyncService {
private static final int MAX_PAGE_SIZE = 500; private static final int MAX_PAGE_SIZE = 500;
private static final int DEFAULT_MAX_PAGES = 200; private static final int DEFAULT_MAX_PAGES = 200;
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30); private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30);
private static final Set<String> POSITION_LEVELS = Set.of("一线", "主管", "项目经理");
private static final String[] PHONE_FIELDS = {"phone", "phone_number", "phoneNumber", "mobile", "mobile_phone", private static final String[] PHONE_FIELDS = {"phone", "phone_number", "phoneNumber", "mobile", "mobile_phone",
"mobilePhone", "mobile_tel", "mobileTel", "cellphone", "cellPhone", "contact_phone", "mobilePhone", "mobile_tel", "mobileTel", "cellphone", "cellPhone", "contact_phone",
"contactPhone", "contact_mobile", "contactMobile", "phonenumber", "telephone", "tel", "手机号", "手机", "联系电话"}; "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}}") @Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
private boolean storeDisplayFields; private boolean storeDisplayFields;
@Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}")
private String configuredPositionMapJson;
public SyncResponse sync(SyncRequest request) { public SyncResponse sync(SyncRequest request) {
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request; SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request;
if (req.dryRun() == null) { if (req.dryRun() == null) {
throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入"); throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入");
} }
Map<String, PositionMapping> positionMappings = positionMappings();
String baseUrl = normalizeBaseUrl(configuredBaseUrl); String baseUrl = normalizeBaseUrl(configuredBaseUrl);
if (baseUrl.isBlank()) { if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
@@ -103,7 +108,7 @@ public class AihrOrgSyncService {
int skipped = 0; int skipped = 0;
int maskedPhone = 0; int maskedPhone = 0;
for (JsonNode employee : employeeItems) { for (JsonNode employee : employeeItems) {
OrgRow row = orgRow(employee, companies, departments); OrgRow row = orgRow(employee, companies, departments, positionMappings);
if (row == null) { if (row == null) {
skipped++; skipped++;
} else { } else {
@@ -116,6 +121,13 @@ public class AihrOrgSyncService {
int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count(); int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count();
int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count(); int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count();
int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList()); 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()) { if (!rows.isEmpty() && phoneLinked < rows.size()) {
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员"); warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
} }
@@ -375,7 +387,8 @@ public class AihrOrgSyncService {
}); });
} }
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments) { private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments,
Map<String, PositionMapping> positionMappings) {
String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id")); String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id"));
if (extPartyId.isBlank()) { if (extPartyId.isBlank()) {
return null; return null;
@@ -399,9 +412,16 @@ public class AihrOrgSyncService {
"组织架构" "组织架构"
); );
String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"); String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-");
String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", String externalPositionName = text(employee, "position_name", "positionName", "job_title", "jobTitle",
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); "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 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( LocalDate hireDate = parseDate(firstNonBlank(
text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate"))); text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate")));
boolean suspectText = hasQuestionMark(projectName) boolean suspectText = hasQuestionMark(projectName)
@@ -416,7 +436,8 @@ public class AihrOrgSyncService {
phone(employee), phone(employee),
storeDisplayFields ? personName : "", storeDisplayFields ? personName : "",
positionName, positionName,
positionLevel(positionLevel, positionName), resolvedPositionLevel,
positionMapped,
status(text(employee, "status", "employment_status", "employmentStatus")), status(text(employee, "status", "employment_status", "employmentStatus")),
hireDate, hireDate,
LocalDate.now(), LocalDate.now(),
@@ -602,6 +623,36 @@ public class AihrOrgSyncService {
return level; return level;
} }
private Map<String, PositionMapping> 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<String, PositionMapping> 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) { private static String phone(JsonNode employee) {
for (String field : PHONE_FIELDS) { for (String field : PHONE_FIELDS) {
String normalized = normalizeMobilePhone(text(employee, field)); String normalized = normalizeMobilePhone(text(employee, field));
@@ -740,6 +791,9 @@ public class AihrOrgSyncService {
private record DepartmentInfo(String code, String name, String companyId) { private record DepartmentInfo(String code, String name, String companyId) {
} }
private record PositionMapping(String name, String level) {
}
private record OrgRow( private record OrgRow(
String projectCode, String projectCode,
String projectName, String projectName,
@@ -749,6 +803,7 @@ public class AihrOrgSyncService {
String personName, String personName,
String positionName, String positionName,
String positionLevel, String positionLevel,
boolean positionMapped,
String employmentStatus, String employmentStatus,
LocalDate hireDate, LocalDate hireDate,
LocalDate snapshotDate, LocalDate snapshotDate,
@@ -79,13 +79,13 @@ public class AihrOrgSyncServiceTest {
"""); """);
ReflectionTestUtils.setField(service, "storeDisplayFields", false); 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, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate"));
ReflectionTestUtils.setField(service, "storeDisplayFields", true); 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, "personName"));
assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName")); assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate")); assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate"));
@@ -100,13 +100,51 @@ public class AihrOrgSyncServiceTest {
"""); """);
ReflectionTestUtils.setField(service, "storeDisplayFields", false); 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, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(true, ReflectionTestUtils.getField(masked, "suspectText")); 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 @Test
public void hiddenDisplayFieldsStillAllowIdentitySearchByPhone() { public void hiddenDisplayFieldsStillAllowIdentitySearchByPhone() {
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); 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 -> { server.createContext("/api/open/v1/sync/snapshot", exchange -> {
boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee"); boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee");
String items = 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) byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0)
+ ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8); + ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8);
@@ -179,6 +217,8 @@ public class AihrOrgSyncServiceTest {
ReflectionTestUtils.setField(service, "configuredClientId", ""); ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", ""); ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); 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)); 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(1, response.employeeCount());
assertEquals(0, response.syncedCount()); assertEquals(0, response.syncedCount());
assertEquals(1, response.phoneLinked()); assertEquals(1, response.phoneLinked());
assertTrue(response.warnings().stream().anyMatch(warning -> warning.contains("命中 1 人,解析主管/项目经理 1 人")));
verifyNoInteractions(jdbcTemplate, transactionTemplate); verifyNoInteractions(jdbcTemplate, transactionTemplate);
} finally { } finally {
server.stop(0); server.stop(0);
+2 -1
View File
@@ -15,7 +15,7 @@
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 案例媒体安全 | `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` 仍保留一期生活顾问学习导航语义 | | 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 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | | 资料处理 `/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` 才启用固定码。 | | 移动端手机号登录 | `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 保演示 | | 用户侧三端首页 `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` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端。生产当前因上游岗位字段缺失只能识别为“一线”,不能用手工选择生活顾问替代主管身份验收 | | 移动端登录后角色识别 | `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_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_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_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`。 本地启动脚本会先加载根目录 `.env.local`,再加载 `backend/.env`。如果 `backend/.env` 沿用开放平台字段名 `client_id` / `client_secret`,脚本会映射为 `AIHR_ORG_SYNC_CLIENT_ID` / `AIHR_ORG_SYNC_CLIENT_SECRET`。
+3 -1
View File
@@ -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 开放平台岗位契约复核:直接只读检查外部快照键,员工项包含 `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`。本轮未发布后端、迁移数据库或写入业务数据。 - 2026-07-15 管理端资料处理分页静态发布:已备份生产根目录至 `/opt/wygj/backups/www-20260715134936`,再同步 `frontend/dist/`;根目录同步未使用 `--delete`,因此未覆盖同目录的 `/h5`。线上 `processing-yUVzof10.js` SHA-256 与本地构建一致。真实管理员会话确认任务分页共 97 页、第二页仍展示 10 条,状态筛选后回到第一页;根站、H5 和租户接口均返回 `200`。本轮未发布后端、迁移数据库或写入业务数据。
+1
View File
@@ -123,6 +123,7 @@ AIHR_AI_SPEECH_ENABLED=true
- `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` - `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_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仍用于权限与身份映射。 - 组织同步生产默认关闭 `aihr.org-sync.store-display-fields`,不把外部姓名/部门写入或返回组织人员展示快照;开发环境显式打开该开关仅用于 Demo。项目范围、岗位和外部主体 ID仍用于权限与身份映射。
- 外部员工只有 `position_id`、没有岗位名称时,可在私密环境配置中设置 HR 签认映射,例如 `AIHR_ORG_POSITION_MAP_JSON='{"1683":{"name":"项目经理","level":"项目经理"}}'`;只允许“一线/主管/项目经理”三种层级,外部显式岗位字段优先。该配置会影响主管权限,必须先 dry-run,再执行覆盖同步并复核主管数与项目范围。
- 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围 - 本地组织 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
- 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`)
@@ -1,6 +1,6 @@
# 下一步工作计划(BRD 对齐版) # 下一步工作计划(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(唯一事实源) > 基准:《物业AI人力资源系统业务需求文档BRD.md》v1.2(唯一事实源)
> 阶段边界:《银城员工端APP分阶段实施总纲.md》阶段一(陪练)+ 2026-07-08《数字师傅学练问报整合方案》定案 > 阶段边界:《银城员工端APP分阶段实施总纲.md》阶段一(陪练)+ 2026-07-08《数字师傅学练问报整合方案》定案
> 现状证据:《BRD_IMPLEMENTATION_AUDIT.md》2026-07-15 最新条目 + 本日生产只读复核 > 现状证据:《BRD_IMPLEMENTATION_AUDIT.md》2026-07-15 最新条目 + 本日生产只读复核
@@ -47,7 +47,7 @@
| # | 需要的输入 | 提供方 | 负责人 | 回复期限 | 兜底触发日 | 阻塞的 BRD 条款 | 不给的后果 | | # | 需要的输入 | 提供方 | 负责人 | 回复期限 | 兜底触发日 | 阻塞的 BRD 条款 | 不给的后果 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| B3 | **试点项目圈定(1–2 个住宅项目)+ 窗口起止日期 + ≥20 名员工名单 + 每项目至少 1 名主管**(员工可从手机号映射中圈定,主管需与 B11 的正式岗位映射一致) | HR 负责人 | 待指派 | 07-18 | 07-22 | 10.2-D | M5 全部指标无分母;半年会没有"真实使用数据" | | 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%)无法验收 | | 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) | 案例库验收必然失败 | | 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 阻塞项零实测,且测了也无法判定通过 | | 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. 风险与止损 ## 7. 风险与止损
1. **B3 试点圈定拖延是当前最大单点风险**(组织数据已就位,决策成本已最低):07-22 仍无答复,升级 HR 负责人;试点每晚一周,半年会"真实使用数据"窗口就少一周。 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 条)并如实下调收口预期,不用未脱敏或演示数据凑数。 3. **案例 20 条**:依赖 B9 存量导入;若试点前不足 20 条,立即提高采集频率(每项目每周 2–3 条)并如实下调收口预期,不用未脱敏或演示数据凑数。
4. **发布失败**:A3 原子流程任一步失败即按备份回滚,当天不重试第二次,先归因。 4. **发布失败**:A3 原子流程任一步失败即按备份回滚,当天不重试第二次,先归因。
5. **范围失控**(BRD 附录 C 头号风险):任何新页面/新功能请求一律进 `docs/BACKLOG.md`;每周对照本计划复盘一次偏差。 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》,本文件只改计划不记证据。*