文章目录TeamUp — 校园智能组队匹配平台一、项目背景1.1 需求分析1.2 技术选型1.2.1 前端框架1.2.2 后端架构1.2.3 项目结构二、核心实现2.1 四维加权匹配算法2.1.1 匹配算法实现2.1.2 技能匹配相似性 互补性2.1.3 活跃度计算2.1.4 权重可配置化2.1.5 推荐结果缓存2.2 AI 助手小队2.2.1 架构设计2.2.2 AI 助手接口2.2.3 Tool 定义2.2.4 个性化系统提示词2.3 聊天与会话管理2.3.1 SSE 流式响应2.4 前端页面三、技术措施3.1 JWT 双 Token 机制3.2 Redis 缓存策略3.3 MyBatis-Plus 分页3.4 跨域配置3.5 Spring Security 权限3.6 N1 查询优化3.7 AI 标题自动生成四、核心亮点与设计思考4.1 技能匹配相似性与互补性的平衡艺术4.2 AI 工具调用从能用到可靠4.3 SSE 流式响应 SecurityContext 跨线程传递4.4 N1 查询消灭一次查库内存计算4.5 前后端契约统一接口由消费者定义4.6 ThreadLocal 的线程安全边界五、总结与展望5.1 项目成果5.2 未来优化方向TeamUp — 校园智能组队匹配平台整理时间2026-08-05一、项目背景1.1 需求分析校园组队场景下学生寻找合适的队友面临三大痛点信息分散组队需求散落在微信群、QQ 群、贴吧无法集中检索匹配低效仅凭群聊发言难以判断对方技能水平、专业背景是否匹配沟通成本高找到人后还需要反复确认时间、能力、意愿效率极低SchoolTeamUp 致力于解决上述问题——通过四维加权匹配算法智能推荐队友通过AI 助手小队用自然语言降低操作门槛让组队从大海捞针变成精准推荐。1.2 技术选型1.2.1 前端框架技术说明Vue 3 TypeScriptComposition API script setup类型安全ViteHMR 快速开发Element Plus表格、表单、对话框、消息提示Pinia会话状态、用户状态AxiosJWT 拦截器、Token 自动刷新1.2.2 后端架构技术说明Spring Boot 3.2Java 21RESTful APIMyBatis-Plus单表 CRUD 自定义 SQLSpring Security JWT登录鉴权、接口权限控制Redis会话详情缓存7 天过期、推荐结果缓存LangChain4jTool Calling、会话记忆、流式响应GLM-5.1百度千帆自然语言理解与推荐MySQL持久化存储1.2.3 项目结构后端 com.school.teamup ├── assistant/ # AI 助手接口定义 │ └── ChatAssistant.java ├── tools/ # LangChain4j 工具类 │ └── TeamTools.java ├── service/ # 业务服务 │ ├── ChatService.java │ ├── MatchingService.java │ └── impl/MatchingServiceImpl.java ├── controller/ # REST 接口 ├── entity/ # 数据实体 ├── dto/ # 请求/响应对象 ├── mapper/ # MyBatis-Plus Mapper ├── config/ # 配置类 └── util/ # 工具类RedisService 等 前端 src/ ├── views/ │ ├── Home.vue # 首页 │ ├── Matching.vue # 匹配页 │ └── Chat.vue # 聊天页 ├── stores/ # Pinia Store ├── api/ # 接口请求 └── utils/request.ts # Axios 封装二、核心实现2.1 四维加权匹配算法组队匹配是本项目的核心算法从四个维度计算两个用户之间的匹配度总匹配分 技能维度(35%) 成绩维度(25%) 专业维度(25%) 活跃度维度(15%)技能维度进一步拆分技能分 技能相似性(Jaccard系数 × 0.4) 技能互补性(互补率 × 0.6)相似性用 Jaccard 系数衡量两人技能集合的重合度确保队友能互相理解互补性衡量一人拥有而另一人缺乏的技能占比鼓励组队后能力补全成绩维度成绩越接近匹配分越高避免实力悬殊导致的协作失衡。专业维度跨专业组队得分更高促进学科交叉。活跃度维度近期登录/参与活动频率越高得分越高确保队友靠谱。2.1.1 匹配算法实现/** * 内存中计算匹配度不查库解决 N1 问题 */ private Double calculateMatchScoreInMemory(User user1, User user2) { double score 0.0; // 1. 技能匹配度区分相似度和互补度 double skillScore calculateSkillMatchScore(user1.getSkillTags(), user2.getSkillTags()); score skillScore * skillWeight; // 默认 0.35 // 2. 综合评分匹配度 double totalScoreMatch calculateTotalScoreMatch(user1.getTotalScore(), user2.getTotalScore()); score totalScoreMatch * totalScoreWeight; // 默认 0.25 // 3. 专业匹配度 double majorMatch calculateMajorMatch(user1.getMajor(), user2.getMajor()); score majorMatch * majorWeight; // 默认 0.25 // 4. 活跃度因素 double activityScore calculateActivityScore(user2); score activityScore * activityWeight; // 默认 0.15 return Math.round(score * 100.0) / 100.0; // 保留两位小数 }2.1.2 技能匹配相似性 互补性/** * 改进的技能匹配算法 * 结合相似度Jaccard系数和互补度 */ private double calculateSkillMatchScore(String skills1, String skills2) { SetString set1 new HashSet(JSON.parseArray(skills1, String.class)); SetString set2 new HashSet(JSON.parseArray(skills2, String.class)); // 计算交集与并集 SetString intersection new HashSet(set1); intersection.retainAll(set2); SetString union new HashSet(set1); union.addAll(set2); // 相似度Jaccard 标准公式 交集 / 并集 double similarity (intersection.size() * 100.0) / union.size(); // 互补度对方有但我没有的技能组队更看重互补 SetString complementary new HashSet(set2); complementary.removeAll(set1); double complementarity (complementary.size() * 100.0) / set2.size(); // 最终技能分数 相似度 × 0.4 互补度 × 0.6 // 互补更重要因为组队需要技能互补 return similarity * 0.4 complementarity * 0.6; }2.1.3 活跃度计算/** * 计算活跃度基于最近登录时间 */ private double calculateActivityScore(User user) { if (user.getLastLoginTime() null) { return 30.0; // 从未登录 } long daysSinceLogin ChronoUnit.DAYS.between( user.getLastLoginTime(), LocalDateTime.now() ); if (daysSinceLogin 7) return 100.0; // 一周内活跃 if (daysSinceLogin 14) return 80.0; // 两周内 if (daysSinceLogin 30) return 60.0; // 一个月内 return 40.0; // 超过一个月 }2.1.4 权重可配置化# application.yml — 权重可通过配置文件调整无需改代码 matching: weights: skill: 0.35 total-score: 0.25 major: 0.25 activity: 0.15 Value(${matching.weights.skill:0.35}) private double skillWeight; Value(${matching.weights.total-score:0.25}) private double totalScoreWeight; Value(${matching.weights.major:0.25}) private double majorWeight; Value(${matching.weights.activity:0.15}) private double activityWeight;2.1.5 推荐结果缓存Override public ListUserVO getRecommendations(Long userId, Integer size, ListString skills) { // 尝试从 Redis 缓存获取 String cacheKey skills null ? null : String.join(,, skills); String cachedResult redisService.getMatchRecommend(userId, cacheKey); if (cachedResult ! null) { return JSON.parseArray(cachedResult, UserVO.class); } // 查询候选用户只查一次解决 N1 问题 ListUser candidates userMapper.selectList(wrapper); // 批量计算匹配度内存操作不查库 ListUserVO result candidates.stream() .map(candidate - { UserVO vo convertToVO(candidate); vo.setMatchScore(calculateMatchScoreInMemory(currentUser, candidate)); vo.setMatchReasons(generateMatchReasons(currentUser, candidate)); return vo; }) .sorted(Comparator.comparing(UserVO::getMatchScore).reversed()) .limit(size) .collect(Collectors.toList()); // 存入缓存 redisService.saveMatchRecommend(userId, cacheKey, JSON.toJSONString(result)); return result; }2.2 AI 助手小队基于 LangChain4j 构建的对话式 AI 助手用户可用自然语言描述需求AI 自动调用工具完成操作。2.2.1 架构设计用户输入 帮我找个ACM的队伍 ↓ ChatAssistant.chat() — LangChain4j AiServices ↓ LLM 意图识别 → 决定调用 searchTeams(keywordACM) ↓ TeamTools.searchTeams() — 查询数据库 ↓ 返回结构化结果 → LLM 组织自然语言回复 ↓ 为你找到 3 支 ACM 相关的队伍1.【算法冲冲冲】...2.2.2 AI 助手接口/** * AI 聊天助手接口 * 使用 LangChain4j AiServices 构建支持 Tool Calling 和会话记忆 */ public interface ChatAssistant { SystemMessage( {{systemPrompt}} ## 你的工具能力 你可以使用以下工具来帮助用户 1. searchTeams - 搜索组队广场中的团队 2. getTeamDetail - 获取团队详细信息 3. recommendTeams - 智能推荐团队 4. getMyTeams - 获取用户已加入的团队 ## 使用工具的时机 - 帮我找个团队、有什么团队推荐 → recommendTeams - 搜索XX类型的团队、有没有XX竞赛 → searchTeams - 这个团队怎么样、团队详情 → getTeamDetail - 我的团队、我加入了哪些团队 → getMyTeams ) String chat( V(systemPrompt) String systemPrompt, MemoryId String sessionId, UserMessage String userMessage ); }2.2.3 Tool 定义Slf4j Component RequiredArgsConstructor public class TeamTools { private final TeamService teamService; // 当前用户ID通过 ThreadLocal 传递保证线程安全 private static final ThreadLocalLong currentUserId new ThreadLocal(); public static void setCurrentUserId(Long userId) { currentUserId.set(userId); } public static void clearCurrentUserId() { currentUserId.remove(); } /** * 搜索组队广场中的团队 */ Tool(搜索组队广场中正在招募的团队。当用户询问特定类型的团队时请使用关键词搜索。) public String searchTeams( P(项目类型学科竞赛、课程设计、创新创业等可选) String projectType, P(搜索关键词团队名称、竞赛名称如ACM、蓝桥杯推荐使用) String keyword ) { PageTeamVO page teamService.getTeamList(1, 10, isEmpty(projectType) ? null : projectType, isEmpty(keyword) ? null : keyword, null); ListTeamVO teams page.getRecords(); if (teams.isEmpty()) { return 没有找到符合条件的团队。; } StringBuilder result new StringBuilder(); result.append(找到 ).append(teams.size()).append( 个相关团队\n\n); for (int i 0; i teams.size(); i) { result.append(i 1).append(. ).append(formatTeamBasic(teams.get(i))).append(\n); } return result.toString(); } /** * 获取团队详细信息 */ Tool(获取指定团队的详细信息包括成员列表、项目要求等) public String getTeamDetail(P(团队ID) Long teamId) { TeamVO team teamService.getTeamDetail(teamId); if (team null) return 团队不存在。; // ... 返回格式化的团队详情 } /** * 智能推荐团队 */ Tool(根据当前用户的技能、兴趣等信息智能推荐最适合的团队) public String recommendTeams(P(推荐数量默认5个) Integer size) { Long userId getCurrentUserId(); if (userId null) return 无法获取当前用户信息请先登录。; int recommendSize (size null || size 0) ? 5 : Math.min(size, 10); ListTeamVO teams teamService.recommendTeams(userId, recommendSize); // ... 返回推荐结果 } /** * 获取我加入的团队 */ Tool(获取当前用户已加入的所有团队列表) public String getMyTeams() { Long userId getCurrentUserId(); if (userId null) return 无法获取当前用户信息请先登录。; ListTeamVO teams teamService.getMyTeams(userId); // ... 返回团队列表 } }2.2.4 个性化系统提示词/** * 构建系统提示词 — 注入用户上下文让 AI 更懂用户 */ private String buildSystemPrompt(ChatRequest request) { StringBuilder prompt new StringBuilder(); prompt.append(你是学校智能化组队系统的智能助手名叫\小队\。\n\n); // 注入学生信息上下文 if (request.getStudentContext() ! null) { ChatRequest.StudentContext ctx request.getStudentContext(); prompt.append(## 当前用户信息\n\n); if (ctx.getMajor() ! null) prompt.append(**专业**).append(ctx.getMajor()).append(\n); if (ctx.getSkills() ! null !ctx.getSkills().isEmpty()) prompt.append(**技能标签**).append(String.join(、, ctx.getSkills())).append(\n); if (ctx.getInterests() ! null !ctx.getInterests().isEmpty()) prompt.append(**兴趣方向**).append(String.join(、, ctx.getInterests())).append(\n); // 根据用户信息生成个性化建议提示 if (ctx.getGrade() ! null ctx.getGrade().contains(大四)) { prompt.append(大四学生可能需要毕业设计相关的团队。\n); } } return prompt.toString(); }2.3 聊天与会话管理会话 CRUD创建、列表、删除流式消息SSE 实时推送 AI 回复消息限制每个会话展示最近 20 条Redis 缓存会话详情缓存 7 天新消息到达自动失效2.3.1 SSE 流式响应public SseEmitter sendMessageStream(ChatRequest request) { SseEmitter emitter new SseEmitter(60000L); // 60秒超时 // 捕获当前线程的 SecurityContext用于异步回调 final SecurityContext securityContext SecurityContextHolder.getContext(); TeamTools.setCurrentUserId(request.getUserId()); ListChatMessage messages buildMessages(request, finalSessionId); saveMessage(userId, finalSessionId, ChatHistory.ROLE_USER, request.getMessage()); StringBuilder fullResponse new StringBuilder(); streamingChatModel.generate(messages, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { // 在异步回调中恢复 SecurityContext SecurityContextHolder.setContext(securityContext); try { fullResponse.append(token); emitter.send(SseEmitter.event().name(message).data(token)); } catch (IOException e) { emitter.completeWithError(e); } finally { SecurityContextHolder.clearContext(); } } Override public void onComplete(ResponseAiMessage response) { SecurityContextHolder.setContext(securityContext); try { // 保存完整的 AI 回复 saveMessage(userId, finalSessionId, ChatHistory.ROLE_ASSISTANT, fullResponse.toString()); emitter.send(SseEmitter.event().name(complete).data(finalSessionId)); emitter.complete(); } finally { SecurityContextHolder.clearContext(); TeamTools.clearCurrentUserId(); } } Override public void onError(Throwable error) { emitter.send(SseEmitter.event().name(error).data(服务暂时不可用)); emitter.completeWithError(error); } }); return emitter; }2.4 前端页面首页 Home队伍列表、搜索、快速匹配入口匹配页 Matching匹配结果展示、邀请加入聊天页 ChatAI 对话、流式显示、历史会话切换三、技术措施3.1 JWT 双 Token 机制Access Token短效 Refresh Token长效前端 Axios 拦截器自动检测 401 并刷新用户无感续期。3.2 Redis 缓存策略会话详情以session:{id}为 key 缓存 7 天新消息写入时主动删除缓存保证一致性。推荐结果也缓存到 Redis避免重复计算。3.3 MyBatis-Plus 分页后端Page对象返回records前端映射为list统一分页参数。3.4 跨域配置开发环境 Vite proxy 转发/api到后端 3000 端口。3.5 Spring Security 权限接口级鉴权未登录请求拦截并返回 401。3.6 N1 查询优化匹配推荐时只查一次用户表后续匹配度计算全部在内存中完成避免对每个候选用户都发起一次数据库查询。3.7 AI 标题自动生成新会话创建时调用 LLM 根据第一条消息自动生成会话标题失败时 fallback 到截取前 15 字。四、核心亮点与设计思考4.1 技能匹配相似性与互补性的平衡艺术组队场景下纯相似度推荐会让队伍能力雷同纯互补度推荐又可能匹配到完全无法沟通的人。我将技能维度拆为相似性(Jaccard × 0.4) 互补性(互补率 × 0.6)两部分加权——相似保证能协作互补保证能补位。0.4 : 0.6 的比例不是拍脑袋而是校园场景下多数同学倾向找补短板队友的直觉反馈。权重通过application.yml配置化后续可根据 A/B 测试数据随时调整。matching: weights: skill: 0.35 # 技能维度权重 total-score: 0.25 # 成绩维度权重 major: 0.25 # 专业维度权重 activity: 0.15 # 活跃度维度权重4.2 AI 工具调用从能用到可靠LLM 做 Tool Calling 最大的坑不是调不通而是生产环境下各种边界情况——生成不存在的工具名、参数格式错误、该调用时不调用。我的做法是三层防御定义层Tool注解严格定义签名P注解描述参数语义让 LLM 知道每个参数该传什么提示层System Prompt 中明确指定可用工具及触发条件包含具体示例如searchTeams(projectTypenull, keywordACM)运行层Tool 方法内部做参数校验异常返回友好提示而非 500这样即使 LLM 偶尔出错系统也不会崩用户看到的仍然是合理的回复。4.3 SSE 流式响应 SecurityContext 跨线程传递Spring 的SecurityContextHolder默认基于ThreadLocal但 SSE 的回调在异步线程执行上下文直接丢失。解决方案是在发起异步请求前手动捕获SecurityContext在onNext/onComplete/onError回调中手动恢复finally 中清理确保安全上下文在异步链路中完整传递同时不泄漏到其他请求// 捕获当前线程的 SecurityContext final SecurityContext securityContext SecurityContextHolder.getContext(); streamingChatModel.generate(messages, new StreamingResponseHandlerAiMessage() { Override public void onNext(String token) { SecurityContextHolder.setContext(securityContext); // 恢复 try { emitter.send(SseEmitter.event().name(message).data(token)); } finally { SecurityContextHolder.clearContext(); // 清理 } } // onComplete / onError 同理 });4.4 N1 查询消灭一次查库内存计算匹配推荐如果对每个候选用户都查一次数据库100 个候选就是 101 条 SQL。我把查询改为一次批量查回所有候选用户后续的匹配度计算Jaccard、互补率、成绩差、专业匹配、活跃度全部在内存中完成。配合 Redis 缓存推荐结果典型请求的响应时间从秒级降到毫秒级。4.5 前后端契约统一接口由消费者定义MyBatis-Plus 的PageT返回records字段但前端 Element Plus 表格约定list。不是让前端适配后端也不是让后端迁就前端——而是在 VO 层做统一映射后端返回的 JSON 始终是前端期望的字段名。接口契约由消费者决定生产者适配这是 RESTful 实践中最容易被忽略但最值得坚持的原则。4.6 ThreadLocal 的线程安全边界TeamTools用 ThreadLocal 存储当前用户 ID 供 AI Tool 使用这在同步模式下没问题。但流式响应的回调在另一个线程执行ThreadLocal 取不到值。我的取舍是同步模式支持 Tool Calling流式模式纯对话。这样既保证了 Tool 调用时用户上下文的可靠性又避免了跨线程传递 ThreadLocal 带来的复杂性和泄漏风险。每一行clearCurrentUserId()都放在 finally 块中即使异常也不会泄漏。五、总结与展望5.1 项目成果实现了四维加权匹配算法技能维度拆分相似性互补性权重可配置化基于 LangChain4j 构建了 AI 助手小队支持 4 个 Tool Calling自然语言交互SSE 流式响应 SecurityContext 跨线程传递保证异步链路安全Redis 缓存推荐结果 会话详情响应时间从秒级降到毫秒级5.2 未来优化方向匹配算法迭代引入协同过滤根据组队后评价反馈调整权重AI 能力扩展支持自动组队AI 根据多人技能自动分组、组队后任务分配建议前端优化匹配结果可视化雷达图、实时通知WebSocket性能优化候选用户预筛选先按专业/成绩粗筛再精细匹配