From 971694d64803e01bbec8036a5e5397f9df3dd0a0 Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 20 Mar 2026 11:34:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E6=8A=80=E8=83=BD=E6=A0=87=E7=AD=BE=E6=8F=90=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/代码开发风格文档.md | 28 +- .kiro/steering/项目结构说明.md | 38 +- .../jiayunet/service/UserProfileService.java | 9 + .../java/org/jiayunet/config/AsyncConfig.java | 30 ++ .../UserProfileSkillTagRelationMapper.java | 13 + .../pojo/po/UserProfileSkillTagRelation.java | 30 ++ .../service/UserSkillTagMatchService.java | 324 ++++++++++++++++++ 7 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 common/src/main/java/org/jiayunet/config/AsyncConfig.java create mode 100644 manager/src/main/java/org/jiayunet/mapper/UserProfileSkillTagRelationMapper.java create mode 100644 manager/src/main/java/org/jiayunet/pojo/po/UserProfileSkillTagRelation.java create mode 100644 manager/src/main/java/org/jiayunet/service/UserSkillTagMatchService.java diff --git a/.kiro/steering/代码开发风格文档.md b/.kiro/steering/代码开发风格文档.md index ee16467..6b9120e 100644 --- a/.kiro/steering/代码开发风格文档.md +++ b/.kiro/steering/代码开发风格文档.md @@ -125,4 +125,30 @@ inclusion: manual - 通过 MyBatis-Plus 的 `@TableField(typeHandler = JacksonTypeHandler.class)` 注解实现自动序列化/反序列化 - 含有 TypeHandler 字段的 PO,`@TableName` 必须加 `autoResultMap = true`,如 `@TableName(value = "bg_xxx", autoResultMap = true)` - JSON 数组存简单值用 `List` 或 `List`,存复杂结构则抽象为独立的 VO 类(如 `DescriptionParagraph`),放在 `manager/pojo/vo/` 下 -- Param 和 Dto 中对应字段直接使用相同的 Java 类型,Controller 层通过 `BeanUtils.copyProperties` 直接拷贝,不做手动 JSON 转换 \ No newline at end of file +- Param 和 Dto 中对应字段直接使用相同的 Java 类型,Controller 层通过 `BeanUtils.copyProperties` 直接拷贝,不做手动 JSON 转换 + +## 代码格式规范 + +### 紧凑风格 +- 避免过度换行,保持代码紧凑易读 +- Lambda 表达式和 Stream 操作尽量写在一行,除非超过 120 字符 +- 方法参数列表较多时,可适当换行但保持紧凑,每行放多个参数 +- 字符串拼接优先写在一行,除非过长影响可读性 + +### 示例 + +**推荐(紧凑风格):** +```java +// 查询语句一行 +List categories = jobCategoryMapper.selectList(new LambdaQueryWrapper().eq(JobCategory::getLevel, 2)); + +// Stream 操作一行 +List ids = categories.stream().map(JobCategory::getId).collect(Collectors.toList()); + +// 方法参数紧凑排列 +private List identifyCategories(UserProfile profile, List educationList, + List workList, List internshipList, + List projectList, List competitionList) { + +// 字符串拼接一行 +String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【二级岗位分类列表】\n" + categoryText; diff --git a/.kiro/steering/项目结构说明.md b/.kiro/steering/项目结构说明.md index 0058bfd..9a792d0 100644 --- a/.kiro/steering/项目结构说明.md +++ b/.kiro/steering/项目结构说明.md @@ -44,7 +44,7 @@ offerpie/back-end │ ├─ pom.xml │ └─ src/main/java │ └─ org.jiayunet -│ ├─ config/ # OSS、Redis、Security、WxPay、Sms 等统一配置 +│ ├─ config/ # OSS、Redis、Security、WxPay、Sms、Async 等统一配置 │ ├─ tool/ # Http、IP、Redis、认证、验证码等工具类 │ ├─ interceptor/ # 全局拦截器(日志、TraceId、黑名单、SQL 统计等) │ ├─ aop/ # AOP 日志切面 @@ -88,6 +88,7 @@ offerpie/back-end │ ├─ UserProfileInternshipMapper.java # 用户实习经历Mapper │ ├─ UserProfileProjectMapper.java # 用户项目经历Mapper │ ├─ UserProfileCompetitionMapper.java # 用户竞赛经历Mapper + │ ├─ UserProfileSkillTagRelationMapper.java # 用户技能标签关联Mapper │ └─ AppJobDataMapper.java # 爬虫岗位原始数据Mapper ├─ pojo/ │ ├─ po/ # 持久化实体 @@ -117,9 +118,10 @@ offerpie/back-end │ │ ├─ UserProfileInternship.java # 用户实习经历表(bg_user_profile_internship) │ │ ├─ UserProfileProject.java # 用户项目经历表(bg_user_profile_project) │ │ ├─ UserProfileCompetition.java # 用户竞赛经历表(bg_user_profile_competition) + │ │ ├─ UserProfileSkillTagRelation.java # 用户技能标签关联表(bg_user_profile_skill_tag_relation) │ │ └─ AppJobData.java # 爬虫岗位原始数据表(app_job_data) │ └─ vo/ # ViewObject(OssUrlVo、DescriptionParagraph 等) - └─ service/ # 业务 Service(OssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService 等) + └─ service/ # 业务 Service(OssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService、UserSkillTagMatchService 等) ``` > **设计理念** – 业务实体和 Mapper 位于 `manager`,B 端和 C 端共享;C 端特有的注解、切面、权限服务、路由菜单服务位于 `client-api`,避免 B 端误用;`common` 提供统一的技术支撑。 @@ -127,7 +129,7 @@ offerpie/back-end | 层级 | 主要职责 | 关键类/包 | |------|----------|-----------| | **client-api** | - 面向终端用户的 REST API
- 启动 Spring Boot 应用
- 短信验证码登录(含自动注册、邀请码绑定)
- **功能权限校验**:注解 + 切面 + 权限服务(校验、扣减、回退)
- **路由菜单**:获取用户有效菜单树 | `ClientApplication`、`LoginController`、`RouteMenuController`、`FuncPermission`、`FuncPermissionAspect`、`FuncPermissionService`、`RouteMenuService`、`UserRegisterService`、`RouteMenuVo` | -| **common** | - **统一配置**:OSS、Redis、Security、WxPay、Sms 等
- **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等
- **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印
- **统一异常/响应**:`GlobalExceptionAdvice`、`UnifiedResponse`
- **业务抽象**:邮件发送、微信支付(Native/JS/Transfer)
- **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` | +| **common** | - **统一配置**:OSS、Redis、Security、WxPay、Sms、Async 等
- **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等
- **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印
- **统一异常/响应**:`GlobalExceptionAdvice`、`UnifiedResponse`
- **业务抽象**:邮件发送、微信支付(Native/JS/Transfer)、异步任务
- **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` | | **manager** | - **业务实体**(`User`、`OssFile`、`UserInvite`、`RouteMenu`、`FuncPermission`、`UserRouteMenuStock`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`ChinaRegionsCode`、`JobCategory`、`Company`、`Job`、`JobRegionRelation`、`Industry`、`SkillTag`、`UserJobFavorite`、`UserJobApplication`、`UserJobDislike`、`AppJobData`)
- **MyBatis Mapper**(`UserMapper`、`OssFileMapper`、`UserInviteMapper`、`RouteMenuMapper`、`FuncPermissionMapper`、`UserRouteMenuStockMapper`、`UserFuncPermissionStockMapper`、`UserFuncUsageLogMapper`、`ChinaRegionsCodeMapper`、`JobCategoryMapper`、`CompanyMapper`、`JobMapper`、`JobRegionRelationMapper`、`IndustryMapper`、`SkillTagMapper`、`UserJobFavoriteMapper`、`UserJobApplicationMapper`、`UserJobDislikeMapper`、`AppJobDataMapper`)
- **业务 API**:文件上传/下载、健康检查等
- **业务逻辑**:服务层、工具类等
- **既供 B 端 UI(待实现)使用,也供 C 端业务直接调用** | `controller/`, `mapper/`, `pojo/po/`, `pojo/vo/`, `service/`, `constant/` | ## 3️⃣ 关键业务实体 @@ -162,6 +164,7 @@ offerpie/back-end | `UserProfileProject` | manager | 用户项目经历表(bg_user_profile_project,profile子表),公司、项目名、角色、起止时间、描述段落(JSON对象数组)。 | | `UserProfileCompetition` | manager | 用户竞赛经历表(bg_user_profile_competition,profile子表),竞赛名、奖项、获奖时间、描述段落(JSON对象数组)。 | | `JobSkillTagRelation` | manager | 岗位-技能标签关联表(bg_job_skill_tag_relation),预定义技能标签与岗位的关联,用于匹配度计算。 | +| `UserProfileSkillTagRelation` | manager | 用户技能标签关联表(bg_user_profile_skill_tag_relation),记录用户匹配的技能标签,由AI自动识别生成。 | | `AppJobData` | manager | 爬虫岗位原始数据表(app_job_data),存储爬虫抓取的原始岗位数据,供清洗服务读取并写入业务表。 | ## 4️⃣ 权限体系设计 @@ -199,7 +202,7 @@ offerpie/back-end ## 5️⃣ 共享技术栈(位于 `common`) | 类别 | 关键实现 | 位置 | |------|----------|------| -| **配置** | `OssConfig`, `RedissonConf`, `SecurityConfig`, `WxPayConfig`, `SmsConfig` | `common/config` | +| **配置** | `OssConfig`, `RedissonConf`, `SecurityConfig`, `WxPayConfig`, `SmsConfig`, `AsyncConfig` | `common/config` | | **安全** | JWT 过滤器、登录令牌 (`RedisLoginTokenInfo`)、防重放 (`RedisPreventReplayInfo`) | `common/interceptor`、`common/pojo/interceptor` | | **邮件** | `EmailAbility`(封装邮件发送) | `common/email` | | **AI** | `AiChatAbility`(OpenAI 兼容多供应商对话)、`AiChatConfig`(供应商配置) | `common/ai` | @@ -223,7 +226,32 @@ offerpie/back-end - 权限体系分两层:前端路由控制菜单可见性,后端切面控制功能点权限与库存扣减。 - 权限和菜单作为商品维度,框架只负责校验和库存管理,不关心权限来源。 -## 4.5️⃣ 邀请模块设计 +## 4.5️⃣ 用户技能标签匹配设计 +### 整体架构 +- **触发时机**:用户保存个人资料(主表或任意子表)后异步触发 +- **匹配流程**:两次 AI 调用 → 第一次识别用户所属的二级岗位分类(1-10个),第二次从候选技能标签中匹配用户技能 +- **数据存储**:全量替换 `bg_user_profile_skill_tag_relation` 表 + +### 数据库表(1张) +| 表名 | 说明 | +|------|------| +| `bg_user_profile_skill_tag_relation` | 用户技能标签关联(user_id + skill_tag_id 唯一约束) | + +### 核心流程 +1. 查询用户完整个人资料(主表 + 5张子表) +2. 数据有效性检查(主表或所有子表为空 → 清空关联表) +3. 第一次 AI 调用:识别用户所属的二级岗位分类(1-10个),信息不足返回空数组 +4. 查询三级分类:根据二级分类 ID 查询所有三级分类(技能标签挂在三级分类下) +5. 查询候选技能标签(WHERE category_id IN 三级分类列表) +6. 第二次 AI 调用:从候选标签中匹配用户技能标签 +7. 全量替换关联表(先 DELETE,结果非空则 BATCH INSERT) + +### 异步执行 +- 使用 Spring `@Async` 注解,配置独立线程池(核心5线程,最大10线程,队列200) +- 异常统一记录日志,不影响主流程 +- AI 调用失败不修改关联表(保持原状) + +## 4.6️⃣ 邀请模块设计 ### 数据库表(1张) | 表名 | 说明 | |------|------| diff --git a/client-api/src/main/java/org/jiayunet/service/UserProfileService.java b/client-api/src/main/java/org/jiayunet/service/UserProfileService.java index ecbb55e..f06144d 100644 --- a/client-api/src/main/java/org/jiayunet/service/UserProfileService.java +++ b/client-api/src/main/java/org/jiayunet/service/UserProfileService.java @@ -44,6 +44,9 @@ public class UserProfileService { @Autowired private UserProfileCompetitionMapper competitionMapper; + @Autowired + private org.jiayunet.service.UserSkillTagMatchService userSkillTagMatchService; + // ==================== 主表 ==================== /** 查询当前用户个人资料 */ @@ -75,6 +78,7 @@ public class UserProfileService { profile.setUpdateTime(now); userProfileMapper.insert(profile); } + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 教育经历 ==================== @@ -110,6 +114,7 @@ public class UserProfileService { item.setUpdateTime(now); }); educationMapper.batchInsert(list); + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 工作经历 ==================== @@ -146,6 +151,7 @@ public class UserProfileService { item.setUpdateTime(now); }); workMapper.batchInsert(list); + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 实习经历 ==================== @@ -182,6 +188,7 @@ public class UserProfileService { item.setUpdateTime(now); }); internshipMapper.batchInsert(list); + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 项目经历 ==================== @@ -218,6 +225,7 @@ public class UserProfileService { item.setUpdateTime(now); }); projectMapper.batchInsert(list); + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 竞赛经历 ==================== @@ -254,6 +262,7 @@ public class UserProfileService { item.setUpdateTime(now); }); competitionMapper.batchInsert(list); + userSkillTagMatchService.matchUserSkillTags(userId); } // ==================== 内部方法 ==================== diff --git a/common/src/main/java/org/jiayunet/config/AsyncConfig.java b/common/src/main/java/org/jiayunet/config/AsyncConfig.java new file mode 100644 index 0000000..b2382da --- /dev/null +++ b/common/src/main/java/org/jiayunet/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package org.jiayunet.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步任务配置 + * 用于用户技能标签匹配等异步任务 + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "userProfileAsyncExecutor") + public Executor userProfileAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(200); + executor.setThreadNamePrefix("user-profile-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/manager/src/main/java/org/jiayunet/mapper/UserProfileSkillTagRelationMapper.java b/manager/src/main/java/org/jiayunet/mapper/UserProfileSkillTagRelationMapper.java new file mode 100644 index 0000000..e5a2725 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/mapper/UserProfileSkillTagRelationMapper.java @@ -0,0 +1,13 @@ +package org.jiayunet.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.jiayunet.pojo.po.UserProfileSkillTagRelation; + +/** + * 用户技能标签关联Mapper + * + * @author zk + */ +@Mapper +public interface UserProfileSkillTagRelationMapper extends CommonMapper { +} diff --git a/manager/src/main/java/org/jiayunet/pojo/po/UserProfileSkillTagRelation.java b/manager/src/main/java/org/jiayunet/pojo/po/UserProfileSkillTagRelation.java new file mode 100644 index 0000000..0d098df --- /dev/null +++ b/manager/src/main/java/org/jiayunet/pojo/po/UserProfileSkillTagRelation.java @@ -0,0 +1,30 @@ +package org.jiayunet.pojo.po; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.Instant; + +/** + * 用户技能标签关联表(bg_user_profile_skill_tag_relation) + * + * @author zk + */ +@Data +@TableName(value = "bg_user_profile_skill_tag_relation") +public class UserProfileSkillTagRelation { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 用户ID */ + private Long userId; + + /** 技能标签ID */ + private Long skillTagId; + + /** 创建时间 */ + private Instant createTime; +} diff --git a/manager/src/main/java/org/jiayunet/service/UserSkillTagMatchService.java b/manager/src/main/java/org/jiayunet/service/UserSkillTagMatchService.java new file mode 100644 index 0000000..e251874 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/service/UserSkillTagMatchService.java @@ -0,0 +1,324 @@ +package org.jiayunet.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.jiayunet.ai.AiChatAbility; +import org.jiayunet.mapper.*; +import org.jiayunet.pojo.po.*; +import org.jiayunet.tool.HttpTool; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户技能标签匹配服务 + *

主要功能:根据用户个人资料,AI识别匹配的技能标签

+ *

依赖:AiChatAbility(AI调用)

+ *

使用表:bg_user_profile(主表)、bg_user_profile_*(5张子表)、 + * bg_job_category(岗位分类)、bg_skill_tag(技能标签)、 + * bg_user_profile_skill_tag_relation(关联表)

+ * + * @author zk + */ +@Slf4j +@Service +public class UserSkillTagMatchService { + + @Autowired + private AiChatAbility aiChatAbility; + + @Autowired + private UserProfileMapper userProfileMapper; + + @Autowired + private UserProfileEducationMapper educationMapper; + + @Autowired + private UserProfileWorkMapper workMapper; + + @Autowired + private UserProfileInternshipMapper internshipMapper; + + @Autowired + private UserProfileProjectMapper projectMapper; + + @Autowired + private UserProfileCompetitionMapper competitionMapper; + + @Autowired + private JobCategoryMapper jobCategoryMapper; + + @Autowired + private SkillTagMapper skillTagMapper; + + @Autowired + private UserProfileSkillTagRelationMapper relationMapper; + + /** + * 异步匹配用户技能标签 + *

1. 查询用户完整个人资料 2. 第一次AI调用识别二级分类 3. 查询三级分类 + * 4. 查询候选技能标签 5. 第二次AI调用匹配技能标签 6. 全量替换关联表

+ */ + @Async("userProfileAsyncExecutor") + public void matchUserSkillTags(Long userId) { + try { + log.info("开始匹配用户技能标签, userId={}", userId); + + // 1. 查询用户完整个人资料 + UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper().eq(UserProfile::getUserId, userId)); + if (profile == null) { + log.info("用户主表为空,清空技能标签, userId={}", userId); + clearRelations(userId); + return; + } + + List educationList = educationMapper.selectList(new LambdaQueryWrapper().eq(UserProfileEducation::getUserId, userId)); + List workList = workMapper.selectList(new LambdaQueryWrapper().eq(UserProfileWork::getUserId, userId)); + List internshipList = internshipMapper.selectList(new LambdaQueryWrapper().eq(UserProfileInternship::getUserId, userId)); + List projectList = projectMapper.selectList(new LambdaQueryWrapper().eq(UserProfileProject::getUserId, userId)); + List competitionList = competitionMapper.selectList(new LambdaQueryWrapper().eq(UserProfileCompetition::getUserId, userId)); + + // 2. 数据有效性检查 + if (educationList.isEmpty() && workList.isEmpty() && internshipList.isEmpty() && projectList.isEmpty() && competitionList.isEmpty()) { + log.info("用户所有子表为空,清空技能标签, userId={}", userId); + clearRelations(userId); + return; + } + + // 3. 第一次 AI 调用(识别二级分类) + List categoryIds = identifyCategories(profile, educationList, workList, internshipList, projectList, competitionList); + if (categoryIds.isEmpty()) { + log.info("AI未识别出岗位分类,清空技能标签, userId={}", userId); + clearRelations(userId); + return; + } + + // 4. 查询三级分类(技能标签挂在三级分类下) + List level3Categories = jobCategoryMapper.selectList(new LambdaQueryWrapper().eq(JobCategory::getLevel, 3).in(JobCategory::getParentId, categoryIds)); + if (level3Categories.isEmpty()) { + log.info("二级分类下无三级分类,清空技能标签, userId={}", userId); + clearRelations(userId); + return; + } + + // 5. 查询候选技能标签(使用三级分类ID) + List level3CategoryIds = level3Categories.stream().map(JobCategory::getId).collect(Collectors.toList()); + List candidateTags = skillTagMapper.selectList(new LambdaQueryWrapper().in(SkillTag::getCategoryId, level3CategoryIds)); + if (candidateTags.isEmpty()) { + log.info("候选技能标签为空,清空技能标签, userId={}", userId); + clearRelations(userId); + return; + } + + // 6. 第二次 AI 调用(匹配技能标签) + List matchedTagIds = matchSkillTags(profile, educationList, workList, internshipList, projectList, competitionList, candidateTags); + + // 7. 全量替换关联表 + replaceRelations(userId, matchedTagIds); + + log.info("用户技能标签匹配完成, userId={}, 匹配{}个标签", userId, matchedTagIds.size()); + + } catch (Exception e) { + log.error("用户技能标签匹配异常, userId={}", userId, e); + } + } + + /** + * 第一次 AI 调用:识别用户所属的二级岗位分类 + */ + private List identifyCategories(UserProfile profile, List educationList, + List workList, List internshipList, + List projectList, List competitionList) { + // 构建用户资料 JSON + Map profileData = new HashMap<>(); + profileData.put("name", profile.getName()); + profileData.put("workYears", profile.getWorkYears()); + profileData.put("education", educationList); + profileData.put("work", workList); + profileData.put("internship", internshipList); + profileData.put("project", projectList); + profileData.put("competition", competitionList); + + String profileJson; + try { + profileJson = HttpTool.objectMapper.writeValueAsString(profileData); + } catch (Exception e) { + log.error("用户资料序列化失败", e); + return List.of(); + } + + // 查询所有二级分类 + List allCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper().eq(JobCategory::getLevel, 2)); + + // 构建分类列表文本(带一级分类名称) + Map parentNameMap = new HashMap<>(); + List parentCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper().eq(JobCategory::getLevel, 1)); + for (JobCategory parent : parentCategories) { + parentNameMap.put(parent.getId(), parent.getName()); + } + + StringBuilder categoryText = new StringBuilder(); + for (JobCategory category : allCategories) { + String parentName = parentNameMap.getOrDefault(category.getParentId(), "未知"); + categoryText.append(String.format("ID: %d, 名称: %s > %s\n", category.getId(), parentName, category.getName())); + } + + // 构建 Prompt + String systemPrompt = """ + 任务:根据用户个人资料,选择最匹配的二级岗位分类。 + + 规则: + 1. 选择 1-10 个最匹配的二级分类(优先核心岗位,按匹配度排序) + 2. 信息不足无法判断时返回空数组 [] + 3. 只返回纯数字数组 JSON,不要任何其他文字 + + 返回格式示例: + - 有分类:[23, 45, 67] + - 无法判断:[] + """; + + String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【二级岗位分类列表(格式:一级分类 > 二级分类)】\n" + categoryText; + + try { + String aiResponse = aiChatAbility.chat(systemPrompt, userMessage); + return parseIdArray(aiResponse, allCategories.stream().map(JobCategory::getId).collect(Collectors.toSet())); + } catch (Exception e) { + log.error("AI识别岗位分类失败", e); + return List.of(); + } + } + + /** + * 第二次 AI 调用:从候选标签中匹配用户技能标签 + */ + private List matchSkillTags(UserProfile profile, List educationList, + List workList, List internshipList, + List projectList, List competitionList, + List candidateTags) { + // 构建用户资料 JSON + Map profileData = new HashMap<>(); + profileData.put("name", profile.getName()); + profileData.put("workYears", profile.getWorkYears()); + profileData.put("education", educationList); + profileData.put("work", workList); + profileData.put("internship", internshipList); + profileData.put("project", projectList); + profileData.put("competition", competitionList); + + String profileJson; + try { + profileJson = HttpTool.objectMapper.writeValueAsString(profileData); + } catch (Exception e) { + log.error("用户资料序列化失败", e); + return List.of(); + } + + // 构建候选标签文本(按分类分组) + Map categoryNameMap = new HashMap<>(); + Set categoryIds = candidateTags.stream().map(SkillTag::getCategoryId).collect(Collectors.toSet()); + List categories = jobCategoryMapper.selectList(new LambdaQueryWrapper().in(JobCategory::getId, categoryIds)); + for (JobCategory category : categories) { + categoryNameMap.put(category.getId(), category.getName()); + } + + StringBuilder tagText = new StringBuilder(); + Map> groupedTags = candidateTags.stream().collect(Collectors.groupingBy(SkillTag::getCategoryId)); + for (Map.Entry> entry : groupedTags.entrySet()) { + String categoryName = categoryNameMap.getOrDefault(entry.getKey(), "未知"); + tagText.append("【").append(categoryName).append("】\n"); + for (SkillTag tag : entry.getValue()) { + tagText.append(String.format("ID: %d, 名称: %s\n", tag.getId(), tag.getName())); + } + tagText.append("\n"); + } + + // 构建 Prompt + String systemPrompt = """ + 任务:根据用户个人资料,从候选技能标签中选出匹配的标签。 + + 规则: + 1. 只选择用户明确具备或可推断的技能 + 2. 无法匹配返回空数组 [] + 3. 只返回纯数字数组 JSON,不要任何其他文字 + + 返回格式示例: + - 有匹配:[12, 34, 56] + - 无匹配:[] + """; + + String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【候选技能标签(按分类分组)】\n" + tagText; + + try { + String aiResponse = aiChatAbility.chat(systemPrompt, userMessage); + return parseIdArray(aiResponse, candidateTags.stream().map(SkillTag::getId).collect(Collectors.toSet())); + } catch (Exception e) { + log.error("AI匹配技能标签失败", e); + return List.of(); + } + } + + /** + * 解析 AI 返回的 ID 数组 + */ + private List parseIdArray(String aiResponse, Set validIds) { + String json = aiResponse.trim(); + if (json.startsWith("```")) { + json = json.replaceAll("^```\\w*\\n?", "").replaceAll("\\n?```$", "").trim(); + } + + try { + JsonNode arrayNode = HttpTool.objectMapper.readTree(json); + List result = new ArrayList<>(); + if (arrayNode.isArray()) { + for (JsonNode node : arrayNode) { + long id = node.asLong(0); + if (id > 0 && validIds.contains(id) && !result.contains(id)) { + result.add(id); + } + } + } + return result; + } catch (Exception e) { + log.warn("AI返回解析失败: {}", json, e); + return List.of(); + } + } + + /** + * 清空用户技能标签关联 + */ + public void clearRelations(Long userId) { + relationMapper.delete(new LambdaQueryWrapper().eq(UserProfileSkillTagRelation::getUserId, userId)); + } + + /** + * 全量替换用户技能标签关联 + */ + + public void replaceRelations(Long userId, List skillTagIds) { + // 先删除 + relationMapper.delete(new LambdaQueryWrapper().eq(UserProfileSkillTagRelation::getUserId, userId)); + + // 结果为空则只删除 + if (skillTagIds.isEmpty()) { + return; + } + + // 批量插入 + Instant now = Instant.now(); + List relations = skillTagIds.stream().map(tagId -> { + UserProfileSkillTagRelation relation = new UserProfileSkillTagRelation(); + relation.setUserId(userId); + relation.setSkillTagId(tagId); + relation.setCreateTime(now); + return relation; + }).collect(Collectors.toList()); + + relationMapper.batchInsert(relations); + } +}