| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- package com.zhentao.service.impl;
- import com.zhentao.config.RecommendProps;
- import com.zhentao.mapper.RecommendMapper;
- import com.zhentao.mapper.UsersMapper;
- import com.zhentao.pojo.Users;
- import com.zhentao.service.RecommendService;
- import com.zhentao.vo.RecommendUserVO;
- import com.zhentao.dto.UserSearchQuery;
- import org.springframework.stereotype.Service;
- import org.slf4j.Logger;
- import org.slf4j.LoggerFactory;
- import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
- import javax.annotation.Resource;
- import java.util.List;
- import java.util.ArrayList;
- import java.util.Comparator;
- import java.util.HashSet;
- import java.util.Set;
- import java.util.concurrent.TimeUnit;
- import com.fasterxml.jackson.core.type.TypeReference;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import org.springframework.data.redis.core.StringRedisTemplate;
- @Service
- public class RecommendServiceImpl implements RecommendService {
- private static final Logger log = LoggerFactory.getLogger(RecommendServiceImpl.class);
- @Resource
- private RecommendMapper recommendMapper;
- @Resource
- private UsersMapper usersMapper;
- @Resource
- private RecommendProps recommendProps;
- private final ObjectMapper objectMapper = new ObjectMapper();
- @Resource
- private StringRedisTemplate stringRedisTemplate;
- @Override
- public List<RecommendUserVO> getRecommendedUsers(Integer userId, Integer oppoOnly, Integer limit) {
- if (userId == null) {
- throw new IllegalArgumentException("userId cannot be null");
- }
- // 强制只推荐异性,不允许推荐同性
- oppoOnly = 1; // 始终强制设置为1,只推荐异性
- if (limit == null || limit <= 0) {
- limit = 50;
- }
- // 先查询当前用户的性别信息,用于清除缓存和调试
- Users currentUser = null;
- try {
- currentUser = usersMapper.selectById(userId);
- if (currentUser != null) {
- log.info("当前用户ID: {}, 性别: {}", userId, currentUser.getGender());
- } else {
- log.warn("当前用户不存在,userId: {}", userId);
- }
- } catch (Exception ex) {
- log.warn("获取当前用户信息失败", ex);
- }
-
- // 清除可能存在的旧缓存(当用户性别改变时,缓存可能包含错误的数据)
- // 读取或生成候选池缓存
- int poolSize = Math.min(Math.max(limit * 5, 100), 500);
- String poolKey = "rec:pool:" + userId + ":" + oppoOnly + ":" + poolSize;
- List<RecommendUserVO> pool = null;
-
- // 如果启用了缓存,先尝试清除可能存在的旧缓存(基于用户ID的所有缓存)
- if (recommendProps.isCacheEnabled()) {
- try {
- // 清除该用户的所有推荐池缓存,确保获取最新数据
- String cachePattern = "rec:pool:" + userId + ":*";
- java.util.Set<String> keys = stringRedisTemplate.keys(cachePattern);
- if (keys != null && !keys.isEmpty()) {
- stringRedisTemplate.delete(keys);
- log.info("已清除用户 {} 的推荐缓存,共 {} 个键", userId, keys.size());
- }
- } catch (Exception ignore) {
- log.warn("清除缓存失败", ignore);
- }
- }
- // 始终从数据库查询最新数据,不依赖缓存(确保性别变更后能立即生效)
- try {
- pool = recommendMapper.selectRecommendedUsers(userId, oppoOnly, poolSize);
- log.info("推荐查询返回数据量: {}, oppoOnly: {}, userId: {}", pool != null ? pool.size() : 0, oppoOnly, userId);
- if (pool == null || pool.isEmpty()) {
- log.warn("推荐查询返回空结果,可能原因:1.当前用户性别未设置 2.数据库中没有异性用户 3.SQL查询条件过严");
- // 如果查询返回空,尝试直接查询数据库中的异性用户数量
- try {
- QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
- queryWrapper.eq("status", 1)
- .isNotNull("gender")
- .ne("gender", 0)
- .ne("user_id", userId);
- if (currentUser != null && currentUser.getGender() != null && currentUser.getGender() != 0) {
- queryWrapper.ne("gender", currentUser.getGender());
- }
- long oppositeGenderCount = usersMapper.selectList(queryWrapper).size();
- log.info("数据库中符合条件的异性用户数量: {}", oppositeGenderCount);
- if (oppositeGenderCount > 0) {
- log.warn("数据库中有 {} 个异性用户,但SQL查询返回空,可能是SQL查询逻辑有问题", oppositeGenderCount);
- }
- } catch (Exception e) {
- log.error("查询异性用户数量失败", e);
- }
- } else {
- log.info("返回数据中的性别分布: 男性={}, 女性={}, 未知={}",
- pool.stream().filter(u -> u.getGender() != null && u.getGender() == 1).count(),
- pool.stream().filter(u -> u.getGender() != null && u.getGender() == 2).count(),
- pool.stream().filter(u -> u.getGender() == null || u.getGender() == 0).count());
- }
- } catch (Exception ex) {
- log.error("selectRecommendedUsers failed, fallback to empty list (possibly DB not available)", ex);
- ex.printStackTrace();
- return java.util.Collections.emptyList();
- }
-
- // 查询成功后,更新缓存(可选,用于性能优化)
- if (recommendProps.isCacheEnabled() && pool != null && !pool.isEmpty()) {
- try {
- String json = objectMapper.writeValueAsString(pool);
- stringRedisTemplate.opsForValue().set(poolKey, json, recommendProps.getCacheTtlSeconds(), TimeUnit.SECONDS);
- } catch (Exception ignore) {}
- }
- // 过滤用户的"不喜欢"集合,避免再次出现
- try {
- java.util.Set<String> disliked = stringRedisTemplate.opsForSet().members("rec:dislike:user:" + userId);
- if (disliked != null && !disliked.isEmpty()) {
- pool.removeIf(u -> u.getUserId() != null && disliked.contains(String.valueOf(u.getUserId())));
- }
- } catch (Exception ignore) {}
-
- // 再次过滤:确保不包含当前用户本身和未完善性别信息的用户,以及同性用户
- if (pool == null) {
- pool = new ArrayList<>();
- }
- final Integer finalCurrentUserGender = currentUser != null ? currentUser.getGender() : null;
- pool.removeIf(u -> {
- if (u.getUserId() == null || u.getUserId().equals(userId)) {
- log.debug("过滤当前用户自己: userId={}", u.getUserId());
- return true;
- }
- if (u.getGender() == null || u.getGender() == 0) {
- log.debug("过滤未设置性别的用户: userId={}", u.getUserId());
- return true;
- }
- // 如果当前用户性别已设置,排除同性
- if (finalCurrentUserGender != null && finalCurrentUserGender != 0) {
- if (u.getGender().equals(finalCurrentUserGender)) {
- log.debug("过滤同性用户: userId={}, gender={}, 当前用户gender={}", u.getUserId(), u.getGender(), finalCurrentUserGender);
- return true;
- }
- }
- return false;
- });
- log.info("过滤后的推荐列表数量: {}", pool.size());
-
- log.info("过滤后的推荐列表数量: {}", pool != null ? pool.size() : 0);
- // 迭代式 MMR:每次从候选中选择 reRank 分最高者加入结果集
- double lambda = recommendProps.getMmrLambda();
- List<RecommendUserVO> result = new ArrayList<>();
- Set<String> seenBuckets = new HashSet<>();
- // 先按原始分降序,作为MMR的初始候选顺序(空值按0处理)
- pool.sort(Comparator.<RecommendUserVO>comparingDouble((RecommendUserVO u) -> safeScore(u.getCompatibilityScore())).reversed());
- while (result.size() < limit && !pool.isEmpty()) {
- double bestScore = Double.NEGATIVE_INFINITY;
- int bestIdx = -1;
- for (int i = 0; i < Math.min(pool.size(), 200); i++) { // 每轮评估前200个
- RecommendUserVO cand = pool.get(i);
- // 桶去重:过多重复的城/校/职,不再考虑
- String bucket = (cand.getCityId() == null ? "-" : cand.getCityId()) + "|"
- + (cand.getSchoolName() == null ? "-" : cand.getSchoolName()) + "|"
- + (cand.getJobTitle() == null ? "-" : cand.getJobTitle());
- if (seenBuckets.contains(bucket)) {
- continue;
- }
- double maxSim = 0.0;
- for (RecommendUserVO chosen : result) {
- double sim = 0.0;
- // 余弦相似度扩展:兴趣 + 职位 + 院校 三路向量加权
- double hobbyCos = cosineSimilarity(tokenizeHobby(cand.getHobby()), tokenizeHobby(chosen.getHobby()));
- double jobCos = cosineSimilarity(tokenizeText(cand.getJobTitle()), tokenizeText(chosen.getJobTitle()));
- double schoolCos = cosineSimilarity(tokenizeText(cand.getSchoolName()), tokenizeText(chosen.getSchoolName()));
- double cosCombined = 0.5 * hobbyCos + 0.3 * jobCos + 0.2 * schoolCos; // 权重可后续配置化
- sim += cosCombined;
- if (sim > maxSim) maxSim = sim;
- }
- double reRankScore = safeScore(cand.getCompatibilityScore()) - lambda * maxSim * 100.0;
- if (reRankScore > bestScore) {
- bestScore = reRankScore;
- bestIdx = i;
- }
- }
- if (bestIdx >= 0) {
- RecommendUserVO chosen = pool.remove(bestIdx);
- String bucket = (chosen.getCityId() == null ? "-" : chosen.getCityId()) + "|"
- + (chosen.getSchoolName() == null ? "-" : chosen.getSchoolName()) + "|"
- + (chosen.getJobTitle() == null ? "-" : chosen.getJobTitle());
- seenBuckets.add(bucket);
- result.add(chosen);
- } else {
- break;
- }
- }
- // 写入最终 TopN 缓存
- if (recommendProps.isCacheEnabled() && !result.isEmpty()) {
- try {
- String topKey = "rec:top:" + userId + ":" + oppoOnly + ":" + limit;
- String json = objectMapper.writeValueAsString(result);
- stringRedisTemplate.opsForValue().set(topKey, json, recommendProps.getCacheTtlSeconds(), TimeUnit.SECONDS);
- } catch (Exception ignore) {}
- }
- return result;
- }
- @Override
- public List<RecommendUserVO> getRecommendedUsers(Integer userId, Integer oppoOnly, Integer limit, List<Integer> excludeIds) {
- // 调用原方法获取推荐列表
- List<RecommendUserVO> allUsers = getRecommendedUsers(userId, oppoOnly, limit * 10); // 获取更多数据以便过滤
-
- if (excludeIds == null || excludeIds.isEmpty()) {
- // 如果没有排除列表,直接返回前limit个
- return allUsers.stream().limit(limit).collect(java.util.stream.Collectors.toList());
- }
-
- // 过滤掉已显示的用户
- java.util.Set<Integer> excludeSet = new java.util.HashSet<>(excludeIds);
- List<RecommendUserVO> filtered = allUsers.stream()
- .filter(u -> u.getUserId() != null && !excludeSet.contains(u.getUserId()))
- .limit(limit)
- .collect(java.util.stream.Collectors.toList());
-
- log.info("排除已显示用户后,返回 {} 个用户(排除 {} 个用户ID)", filtered.size(), excludeIds.size());
-
- // 如果过滤后数量不足,说明所有用户都显示过了,返回空列表让前端重新开始
- if (filtered.size() < limit && allUsers.size() > 0) {
- log.info("所有用户都已显示过,建议前端重新开始");
- }
-
- return filtered;
- }
- @Override
- public List<RecommendUserVO> searchByRules(UserSearchQuery q) {
- if (q == null || q.getUserId() == null) {
- throw new IllegalArgumentException("userId cannot be null");
- }
- int limit = (q.getLimit() == null || q.getLimit() <= 0) ? 20 : q.getLimit();
- int offset = (q.getOffset() == null || q.getOffset() < 0) ? 0 : q.getOffset();
- // 计算出生日期范围(年龄 -> 出生日期)
- String birthMin = null, birthMax = null; // YYYY-MM-DD
- java.time.LocalDate today = java.time.LocalDate.now();
- if (q.getAgeMax() != null) {
- birthMin = today.minusYears(q.getAgeMax()).toString();
- }
- if (q.getAgeMin() != null) {
- birthMax = today.minusYears(q.getAgeMin()).toString();
- }
- // 兴趣标签转 JSON 数组字符串
- String hobbyJson = null;
- if (q.getHobbyTags() != null && !q.getHobbyTags().isEmpty()) {
- try { hobbyJson = objectMapper.writeValueAsString(q.getHobbyTags()); } catch (Exception ignore) {}
- }
- // 读取不喜欢集合,作为排除列表
- java.util.List<Integer> excludeIds = new java.util.ArrayList<>();
- try {
- java.util.Set<String> disliked = stringRedisTemplate.opsForSet().members("rec:dislike:user:" + q.getUserId());
- if (disliked != null) {
- for (String s : disliked) { try { excludeIds.add(Integer.parseInt(s)); } catch (Exception ignore) {} }
- }
- } catch (Exception ignore) {}
- // 获取当前用户的性别信息
- Integer currentUserGender = null;
- try {
- Users currentUser = usersMapper.selectById(q.getUserId());
- if (currentUser != null) {
- currentUserGender = currentUser.getGender();
- }
- } catch (Exception ex) {
- log.warn("Failed to get current user gender, userId: " + q.getUserId(), ex);
- }
-
- // 如果当前用户未完善性别信息,返回空列表
- if (currentUserGender == null || currentUserGender == 0) {
- log.warn("Current user has no gender information, userId: " + q.getUserId());
- return java.util.Collections.emptyList();
- }
-
- // 召回
- List<RecommendUserVO> pool;
- try {
- pool = recommendMapper.selectByRules(q, birthMin, birthMax, hobbyJson, excludeIds, Math.min(limit * 5, 200), offset);
- } catch (Exception ex) {
- log.error("selectByRules failed, fallback to empty list (possibly DB not available)", ex);
- return java.util.Collections.emptyList();
- }
-
- // 过滤:确保不包含当前用户本身和未完善性别信息的用户,只推荐异性
- if (pool != null && !pool.isEmpty()) {
- final Integer finalCurrentUserGender = currentUserGender;
- pool.removeIf(user ->
- user.getUserId() == null ||
- user.getUserId().equals(q.getUserId()) ||
- user.getGender() == null ||
- user.getGender() == 0 ||
- user.getGender().equals(finalCurrentUserGender) // 排除同性
- );
- }
- // 重排(沿用 MMR)
- double lambda = recommendProps.getMmrLambda();
- java.util.List<RecommendUserVO> result = new java.util.ArrayList<>();
- java.util.Set<String> seenBuckets = new java.util.HashSet<>();
- pool.sort(java.util.Comparator.<RecommendUserVO>comparingDouble((RecommendUserVO u) -> safeScore(u.getCompatibilityScore())).reversed());
- while (result.size() < limit && !pool.isEmpty()) {
- double bestScore = Double.NEGATIVE_INFINITY; int bestIdx = -1;
- for (int i = 0; i < Math.min(pool.size(), 200); i++) {
- RecommendUserVO cand = pool.get(i);
- String bucket = (cand.getCityId() == null ? "-" : cand.getCityId()) + "|"
- + (cand.getSchoolName() == null ? "-" : cand.getSchoolName()) + "|"
- + (cand.getJobTitle() == null ? "-" : cand.getJobTitle());
- if (seenBuckets.contains(bucket)) continue;
- double hobbyCos = cosineSimilarity(tokenizeHobby(cand.getHobby()), new String[0]);
- double jobCos = cosineSimilarity(tokenizeText(cand.getJobTitle()), new String[0]);
- double schoolCos = cosineSimilarity(tokenizeText(cand.getSchoolName()), new String[0]);
- double maxSim = 0.5 * hobbyCos + 0.3 * jobCos + 0.2 * schoolCos;
- double reRankScore = safeScore(cand.getCompatibilityScore()) - lambda * maxSim * 100.0;
- if (reRankScore > bestScore) { bestScore = reRankScore; bestIdx = i; }
- }
- if (bestIdx >= 0) {
- RecommendUserVO chosen = pool.remove(bestIdx);
- String bucket = (chosen.getCityId() == null ? "-" : chosen.getCityId()) + "|"
- + (chosen.getSchoolName() == null ? "-" : chosen.getSchoolName()) + "|"
- + (chosen.getJobTitle() == null ? "-" : chosen.getJobTitle());
- seenBuckets.add(bucket); result.add(chosen);
- } else break;
- }
- return result;
- }
- @Override
- public List<com.zhentao.pojo.Province> getAllProvinces() {
- return recommendMapper.selectAllProvinces();
- }
- @Override
- public List<com.zhentao.pojo.City> getAllCities() {
- return recommendMapper.selectAllCities();
- }
- @Override
- public List<com.zhentao.pojo.City> getCitiesByProvince(Integer provinceId) {
- if (provinceId == null) return java.util.Collections.emptyList();
- return recommendMapper.selectCitiesByProvince(provinceId);
- }
- @Override
- public List<com.zhentao.pojo.Area> getAreasByCity(Integer cityId) {
- if (cityId == null) return java.util.Collections.emptyList();
- return recommendMapper.selectAreasByCity(cityId);
- }
- private boolean safeEq(String a, String b) {
- if (a == null || b == null) return false;
- return a.equals(b);
- }
- private boolean jobSimilar(String a, String b) {
- if (a == null || b == null) return false;
- String as = a.toLowerCase();
- String bs = b.toLowerCase();
- return as.equals(bs) || as.contains(bs) || bs.contains(as);
- }
- private boolean hobbyOverlap(String a, String b) {
- if (a == null || b == null) return false;
- String as = a.toLowerCase();
- String bs = b.toLowerCase();
- String[] tokens = as.replace('[',' ').replace(']',' ').replace('"',' ').split("[, ]+");
- for (String t : tokens) {
- if (t == null || t.trim().isEmpty()) continue;
- if (bs.contains(t.trim())) return true;
- }
- return false;
- }
- private String[] tokenizeHobby(String hobbyJson) {
- if (hobbyJson == null) return new String[0];
- String merged = hobbyJson.toLowerCase().replace('[',' ').replace(']',' ').replace('"',' ');
- return merged.split("[^a-z0-9\\u4e00-\\u9fa5]+");
- }
- private String[] tokenizeText(String text) {
- if (text == null) return new String[0];
- return text.toLowerCase().split("[^a-z0-9\\u4e00-\\u9fa5]+");
- }
- private double cosineSimilarity(String[] a, String[] b) {
- if (a == null || b == null || a.length == 0 || b.length == 0) return 0.0;
- java.util.Map<String, int[]> map = new java.util.HashMap<>();
- for (String t : a) {
- if (t == null || t.isEmpty()) continue;
- map.computeIfAbsent(t, k -> new int[2])[0]++;
- }
- for (String t : b) {
- if (t == null || t.isEmpty()) continue;
- map.computeIfAbsent(t, k -> new int[2])[1]++;
- }
- double dot = 0, na = 0, nb = 0;
- for (int[] v : map.values()) {
- dot += v[0] * v[1];
- na += v[0] * v[0];
- nb += v[1] * v[1];
- }
- if (na == 0 || nb == 0) return 0.0;
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
- }
- private double safeScore(Number n) {
- if (n == null) return 0.0;
- try { return n.doubleValue(); } catch (Exception ignore) { return 0.0; }
- }
- @Override
- public boolean saveUserLike(Integer userId, Integer likeUserId) {
- if (userId == null || likeUserId == null) {
- throw new IllegalArgumentException("userId and likeUserId cannot be null");
- }
- try {
- int result = recommendMapper.insertUserLike(userId, likeUserId);
- return result > 0;
- } catch (Exception ex) {
- log.error("saveUserLike failed", ex);
- return false;
- }
- }
- @Override
- public boolean deleteUserLike(Integer userId, Integer likeUserId) {
- if (userId == null || likeUserId == null) {
- throw new IllegalArgumentException("userId and likeUserId cannot be null");
- }
- try {
- int result = recommendMapper.deleteUserLike(userId, likeUserId);
- return result > 0;
- } catch (Exception ex) {
- log.error("deleteUserLike failed", ex);
- return false;
- }
- }
- @Override
- public List<RecommendUserVO> getLikedUsers(Integer userId, Integer offset, Integer limit) {
- if (userId == null) {
- throw new IllegalArgumentException("userId cannot be null");
- }
- if (limit == null || limit <= 0) {
- limit = 20;
- }
- if (offset == null || offset < 0) {
- offset = 0;
- }
- try {
- return recommendMapper.selectLikedUsers(userId, offset, limit);
- } catch (Exception ex) {
- log.error("getLikedUsers failed", ex);
- return java.util.Collections.emptyList();
- }
- }
- @Override
- public Integer countLikedUsers(Integer userId) {
- if (userId == null) {
- throw new IllegalArgumentException("userId cannot be null");
- }
- try {
- return recommendMapper.countLikedUsers(userId);
- } catch (Exception ex) {
- log.error("countLikedUsers failed", ex);
- return 0;
- }
- }
- }
|