添加用户个人技能标签提取

This commit is contained in:
zk
2026-03-20 11:34:12 +08:00
parent dc2e241151
commit 971694d648
7 changed files with 466 additions and 6 deletions
@@ -126,3 +126,29 @@ inclusion: manual
- 含有 TypeHandler 字段的 PO`@TableName` 必须加 `autoResultMap = true`,如 `@TableName(value = "bg_xxx", autoResultMap = true)`
- JSON 数组存简单值用 `List<String>` 或 `List<Long>`,存复杂结构则抽象为独立的 VO 类(如 `DescriptionParagraph`),放在 `manager/pojo/vo/` 下
- Param 和 Dto 中对应字段直接使用相同的 Java 类型,Controller 层通过 `BeanUtils.copyProperties` 直接拷贝,不做手动 JSON 转换
## 代码格式规范
### 紧凑风格
- 避免过度换行,保持代码紧凑易读
- Lambda 表达式和 Stream 操作尽量写在一行,除非超过 120 字符
- 方法参数列表较多时,可适当换行但保持紧凑,每行放多个参数
- 字符串拼接优先写在一行,除非过长影响可读性
### 示例
**推荐(紧凑风格):**
```java
// 查询语句一行
List<JobCategory> categories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 2));
// Stream 操作一行
List<Long> ids = categories.stream().map(JobCategory::getId).collect(Collectors.toList());
// 方法参数紧凑排列
private List<Long> identifyCategories(UserProfile profile, List<UserProfileEducation> educationList,
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList) {
// 字符串拼接一行
String userMessage = "【用户个人资料】\n" + profileJson + "\n\n【二级岗位分类列表】\n" + categoryText;
+33 -5
View File
@@ -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/ # ViewObjectOssUrlVo、DescriptionParagraph 等)
└─ service/ # 业务 ServiceOssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService 等)
└─ service/ # 业务 ServiceOssService、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 <br> - 启动 Spring Boot 应用 <br> - 短信验证码登录(含自动注册、邀请码绑定) <br> - **功能权限校验**:注解 + 切面 + 权限服务(校验、扣减、回退) <br> - **路由菜单**:获取用户有效菜单树 | `ClientApplication``LoginController``RouteMenuController``FuncPermission``FuncPermissionAspect``FuncPermissionService``RouteMenuService``UserRegisterService``RouteMenuVo` |
| **common** | - **统一配置**OSS、Redis、Security、WxPay、Sms 等 <br> - **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等 <br> - **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印 <br> - **统一异常/响应**`GlobalExceptionAdvice``UnifiedResponse` <br> - **业务抽象**:邮件发送、微信支付(Native/JS/Transfer <br> - **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` |
| **common** | - **统一配置**OSS、Redis、Security、WxPay、Sms、Async 等 <br> - **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等 <br> - **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印 <br> - **统一异常/响应**`GlobalExceptionAdvice``UnifiedResponse` <br> - **业务抽象**:邮件发送、微信支付(Native/JS/Transfer、异步任务 <br> - **公共 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` <br> - **MyBatis Mapper**`UserMapper``OssFileMapper``UserInviteMapper``RouteMenuMapper``FuncPermissionMapper``UserRouteMenuStockMapper``UserFuncPermissionStockMapper``UserFuncUsageLogMapper``ChinaRegionsCodeMapper``JobCategoryMapper``CompanyMapper``JobMapper``JobRegionRelationMapper``IndustryMapper``SkillTagMapper``UserJobFavoriteMapper``UserJobApplicationMapper``UserJobDislikeMapper``AppJobDataMapper` <br> - **业务 API**:文件上传/下载、健康检查等 <br> - **业务逻辑**:服务层、工具类等 <br> - **既供 B 端 UI(待实现)使用,也供 C 端业务直接调用** | `controller/`, `mapper/`, `pojo/po/`, `pojo/vo/`, `service/`, `constant/` |
## 3️⃣ 关键业务实体
@@ -162,6 +164,7 @@ offerpie/back-end
| `UserProfileProject` | manager | 用户项目经历表(bg_user_profile_projectprofile子表),公司、项目名、角色、起止时间、描述段落(JSON对象数组)。 |
| `UserProfileCompetition` | manager | 用户竞赛经历表(bg_user_profile_competitionprofile子表),竞赛名、奖项、获奖时间、描述段落(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张)
| 表名 | 说明 |
|------|------|
@@ -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);
}
// ==================== 内部方法 ====================
@@ -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;
}
}
@@ -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<UserProfileSkillTagRelation> {
}
@@ -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;
}
@@ -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;
/**
* 用户技能标签匹配服务
* <p>主要功能:根据用户个人资料,AI识别匹配的技能标签</p>
* <p>依赖:AiChatAbilityAI调用)</p>
* <p>使用表:bg_user_profile(主表)、bg_user_profile_*5张子表)、
* bg_job_category(岗位分类)、bg_skill_tag(技能标签)、
* bg_user_profile_skill_tag_relation(关联表)</p>
*
* @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;
/**
* 异步匹配用户技能标签
* <p>1. 查询用户完整个人资料 2. 第一次AI调用识别二级分类 3. 查询三级分类
* 4. 查询候选技能标签 5. 第二次AI调用匹配技能标签 6. 全量替换关联表</p>
*/
@Async("userProfileAsyncExecutor")
public void matchUserSkillTags(Long userId) {
try {
log.info("开始匹配用户技能标签, userId={}", userId);
// 1. 查询用户完整个人资料
UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId));
if (profile == null) {
log.info("用户主表为空,清空技能标签, userId={}", userId);
clearRelations(userId);
return;
}
List<UserProfileEducation> educationList = educationMapper.selectList(new LambdaQueryWrapper<UserProfileEducation>().eq(UserProfileEducation::getUserId, userId));
List<UserProfileWork> workList = workMapper.selectList(new LambdaQueryWrapper<UserProfileWork>().eq(UserProfileWork::getUserId, userId));
List<UserProfileInternship> internshipList = internshipMapper.selectList(new LambdaQueryWrapper<UserProfileInternship>().eq(UserProfileInternship::getUserId, userId));
List<UserProfileProject> projectList = projectMapper.selectList(new LambdaQueryWrapper<UserProfileProject>().eq(UserProfileProject::getUserId, userId));
List<UserProfileCompetition> competitionList = competitionMapper.selectList(new LambdaQueryWrapper<UserProfileCompetition>().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<Long> categoryIds = identifyCategories(profile, educationList, workList, internshipList, projectList, competitionList);
if (categoryIds.isEmpty()) {
log.info("AI未识别出岗位分类,清空技能标签, userId={}", userId);
clearRelations(userId);
return;
}
// 4. 查询三级分类(技能标签挂在三级分类下)
List<JobCategory> level3Categories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 3).in(JobCategory::getParentId, categoryIds));
if (level3Categories.isEmpty()) {
log.info("二级分类下无三级分类,清空技能标签, userId={}", userId);
clearRelations(userId);
return;
}
// 5. 查询候选技能标签(使用三级分类ID)
List<Long> level3CategoryIds = level3Categories.stream().map(JobCategory::getId).collect(Collectors.toList());
List<SkillTag> candidateTags = skillTagMapper.selectList(new LambdaQueryWrapper<SkillTag>().in(SkillTag::getCategoryId, level3CategoryIds));
if (candidateTags.isEmpty()) {
log.info("候选技能标签为空,清空技能标签, userId={}", userId);
clearRelations(userId);
return;
}
// 6. 第二次 AI 调用(匹配技能标签)
List<Long> 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<Long> identifyCategories(UserProfile profile, List<UserProfileEducation> educationList,
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList) {
// 构建用户资料 JSON
Map<String, Object> 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<JobCategory> allCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().eq(JobCategory::getLevel, 2));
// 构建分类列表文本(带一级分类名称)
Map<Long, String> parentNameMap = new HashMap<>();
List<JobCategory> parentCategories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().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<Long> matchSkillTags(UserProfile profile, List<UserProfileEducation> educationList,
List<UserProfileWork> workList, List<UserProfileInternship> internshipList,
List<UserProfileProject> projectList, List<UserProfileCompetition> competitionList,
List<SkillTag> candidateTags) {
// 构建用户资料 JSON
Map<String, Object> 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<Long, String> categoryNameMap = new HashMap<>();
Set<Long> categoryIds = candidateTags.stream().map(SkillTag::getCategoryId).collect(Collectors.toSet());
List<JobCategory> categories = jobCategoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().in(JobCategory::getId, categoryIds));
for (JobCategory category : categories) {
categoryNameMap.put(category.getId(), category.getName());
}
StringBuilder tagText = new StringBuilder();
Map<Long, List<SkillTag>> groupedTags = candidateTags.stream().collect(Collectors.groupingBy(SkillTag::getCategoryId));
for (Map.Entry<Long, List<SkillTag>> 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<Long> parseIdArray(String aiResponse, Set<Long> validIds) {
String json = aiResponse.trim();
if (json.startsWith("```")) {
json = json.replaceAll("^```\\w*\\n?", "").replaceAll("\\n?```$", "").trim();
}
try {
JsonNode arrayNode = HttpTool.objectMapper.readTree(json);
List<Long> 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<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
}
/**
* 全量替换用户技能标签关联
*/
public void replaceRelations(Long userId, List<Long> skillTagIds) {
// 先删除
relationMapper.delete(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
// 结果为空则只删除
if (skillTagIds.isEmpty()) {
return;
}
// 批量插入
Instant now = Instant.now();
List<UserProfileSkillTagRelation> 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);
}
}