TeamUp——一键式组队平台
文章目录
- 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 N+1 查询优化
- 3.7 AI 标题自动生成
- 四、核心亮点与设计思考
- 4.1 技能匹配:相似性与互补性的平衡艺术
- 4.2 AI 工具调用:从"能用"到"可靠"
- 4.3 SSE 流式响应 + SecurityContext 跨线程传递
- 4.4 N+1 查询消灭:一次查库,内存计算
- 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 + TypeScript | Composition API +<script setup>,类型安全 |
| Vite | HMR 快速开发 |
| Element Plus | 表格、表单、对话框、消息提示 |
| Pinia | 会话状态、用户状态 |
| Axios | JWT 拦截器、Token 自动刷新 |
1.2.2 后端架构
| 技术 | 说明 |
|---|---|
| Spring Boot 3.2 | Java 21,RESTful API |
| MyBatis-Plus | 单表 CRUD + 自定义 SQL |
| Spring Security + JWT | 登录鉴权、接口权限控制 |
| Redis | 会话详情缓存(7 天过期)、推荐结果缓存 |
| LangChain4j | Tool 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 匹配算法实现
/** * 内存中计算匹配度(不查库,解决 N+1 问题) */ 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) { Set<String> set1 = new HashSet<>(JSON.parseArray(skills1, String.class)); Set<String> set2 = new HashSet<>(JSON.parseArray(skills2, String.class)); // 计算交集与并集 Set<String> intersection = new HashSet<>(set1); intersection.retainAll(set2); Set<String> union = new HashSet<>(set1); union.addAll(set2); // 相似度:Jaccard 标准公式 = 交集 / 并集 double similarity = (intersection.size() * 100.0) / union.size(); // 互补度:对方有但我没有的技能(组队更看重互补) Set<String> 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 List<UserVO> getRecommendations(Long userId, Integer size, List<String> 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); } // 查询候选用户(只查一次,解决 N+1 问题) List<User> candidates = userMapper.selectList(wrapper); // 批量计算匹配度(内存操作,不查库) List<UserVO> 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(keyword="ACM") ↓ 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 ThreadLocal<Long> 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 ) { Page<TeamVO> page = teamService.getTeamList(1, 10, isEmpty(projectType) ? null : projectType, isEmpty(keyword) ? null : keyword, null); List<TeamVO> 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); List<TeamVO> teams = teamService.recommendTeams(userId, recommendSize); // ... 返回推荐结果 } /** * 获取我加入的团队 */ @Tool("获取当前用户已加入的所有团队列表") public String getMyTeams() { Long userId = getCurrentUserId(); if (userId == null) return "无法获取当前用户信息,请先登录。"; List<TeamVO> 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()); List<ChatMessage> messages = buildMessages(request, finalSessionId); saveMessage(userId, finalSessionId, ChatHistory.ROLE_USER, request.getMessage()); StringBuilder fullResponse = new StringBuilder(); streamingChatModel.generate(messages, new StreamingResponseHandler<AiMessage>() { @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(Response<AiMessage> 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:匹配结果展示、邀请加入
- 聊天页 Chat:AI 对话、流式显示、历史会话切换
三、技术措施
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 N+1 查询优化
匹配推荐时只查一次用户表,后续匹配度计算全部在内存中完成,避免对每个候选用户都发起一次数据库查询。
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(projectType=null, keyword="ACM")) - 运行层: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 StreamingResponseHandler<AiMessage>() { @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 N+1 查询消灭:一次查库,内存计算
匹配推荐如果对每个候选用户都查一次数据库,100 个候选就是 101 条 SQL。我把查询改为一次批量查回所有候选用户,后续的匹配度计算(Jaccard、互补率、成绩差、专业匹配、活跃度)全部在内存中完成。配合 Redis 缓存推荐结果,典型请求的响应时间从秒级降到毫秒级。
4.5 前后端契约统一:接口由消费者定义
MyBatis-Plus 的Page<T>返回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)
- 性能优化:候选用户预筛选(先按专业/成绩粗筛,再精细匹配)
