RecommendServiceImpl.java 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. package com.zhentao.service.impl;
  2. import com.zhentao.config.RecommendProps;
  3. import com.zhentao.mapper.RecommendMapper;
  4. import com.zhentao.mapper.UsersMapper;
  5. import com.zhentao.pojo.Users;
  6. import com.zhentao.service.RecommendService;
  7. import com.zhentao.vo.RecommendUserVO;
  8. import com.zhentao.dto.UserSearchQuery;
  9. import org.springframework.stereotype.Service;
  10. import org.slf4j.Logger;
  11. import org.slf4j.LoggerFactory;
  12. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  13. import javax.annotation.Resource;
  14. import java.util.List;
  15. import java.util.ArrayList;
  16. import java.util.Comparator;
  17. import java.util.HashSet;
  18. import java.util.Set;
  19. import java.util.concurrent.TimeUnit;
  20. import com.fasterxml.jackson.core.type.TypeReference;
  21. import com.fasterxml.jackson.databind.ObjectMapper;
  22. import org.springframework.data.redis.core.StringRedisTemplate;
  23. @Service
  24. public class RecommendServiceImpl implements RecommendService {
  25. private static final Logger log = LoggerFactory.getLogger(RecommendServiceImpl.class);
  26. @Resource
  27. private RecommendMapper recommendMapper;
  28. @Resource
  29. private UsersMapper usersMapper;
  30. @Resource
  31. private RecommendProps recommendProps;
  32. private final ObjectMapper objectMapper = new ObjectMapper();
  33. @Resource
  34. private StringRedisTemplate stringRedisTemplate;
  35. @Override
  36. public List<RecommendUserVO> getRecommendedUsers(Integer userId, Integer oppoOnly, Integer limit) {
  37. if (userId == null) {
  38. throw new IllegalArgumentException("userId cannot be null");
  39. }
  40. // 强制只推荐异性,不允许推荐同性
  41. oppoOnly = 1; // 始终强制设置为1,只推荐异性
  42. if (limit == null || limit <= 0) {
  43. limit = 50;
  44. }
  45. // 先查询当前用户的性别信息,用于清除缓存和调试
  46. Users currentUser = null;
  47. try {
  48. currentUser = usersMapper.selectById(userId);
  49. if (currentUser != null) {
  50. log.info("当前用户ID: {}, 性别: {}", userId, currentUser.getGender());
  51. } else {
  52. log.warn("当前用户不存在,userId: {}", userId);
  53. }
  54. } catch (Exception ex) {
  55. log.warn("获取当前用户信息失败", ex);
  56. }
  57. // 清除可能存在的旧缓存(当用户性别改变时,缓存可能包含错误的数据)
  58. // 读取或生成候选池缓存
  59. int poolSize = Math.min(Math.max(limit * 5, 100), 500);
  60. String poolKey = "rec:pool:" + userId + ":" + oppoOnly + ":" + poolSize;
  61. List<RecommendUserVO> pool = null;
  62. // 如果启用了缓存,先尝试清除可能存在的旧缓存(基于用户ID的所有缓存)
  63. if (recommendProps.isCacheEnabled()) {
  64. try {
  65. // 清除该用户的所有推荐池缓存,确保获取最新数据
  66. String cachePattern = "rec:pool:" + userId + ":*";
  67. java.util.Set<String> keys = stringRedisTemplate.keys(cachePattern);
  68. if (keys != null && !keys.isEmpty()) {
  69. stringRedisTemplate.delete(keys);
  70. log.info("已清除用户 {} 的推荐缓存,共 {} 个键", userId, keys.size());
  71. }
  72. } catch (Exception ignore) {
  73. log.warn("清除缓存失败", ignore);
  74. }
  75. }
  76. // 始终从数据库查询最新数据,不依赖缓存(确保性别变更后能立即生效)
  77. try {
  78. pool = recommendMapper.selectRecommendedUsers(userId, oppoOnly, poolSize);
  79. log.info("推荐查询返回数据量: {}, oppoOnly: {}, userId: {}", pool != null ? pool.size() : 0, oppoOnly, userId);
  80. if (pool == null || pool.isEmpty()) {
  81. log.warn("推荐查询返回空结果,可能原因:1.当前用户性别未设置 2.数据库中没有异性用户 3.SQL查询条件过严");
  82. // 如果查询返回空,尝试直接查询数据库中的异性用户数量
  83. try {
  84. QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
  85. queryWrapper.eq("status", 1)
  86. .isNotNull("gender")
  87. .ne("gender", 0)
  88. .ne("user_id", userId);
  89. if (currentUser != null && currentUser.getGender() != null && currentUser.getGender() != 0) {
  90. queryWrapper.ne("gender", currentUser.getGender());
  91. }
  92. long oppositeGenderCount = usersMapper.selectList(queryWrapper).size();
  93. log.info("数据库中符合条件的异性用户数量: {}", oppositeGenderCount);
  94. if (oppositeGenderCount > 0) {
  95. log.warn("数据库中有 {} 个异性用户,但SQL查询返回空,可能是SQL查询逻辑有问题", oppositeGenderCount);
  96. }
  97. } catch (Exception e) {
  98. log.error("查询异性用户数量失败", e);
  99. }
  100. } else {
  101. log.info("返回数据中的性别分布: 男性={}, 女性={}, 未知={}",
  102. pool.stream().filter(u -> u.getGender() != null && u.getGender() == 1).count(),
  103. pool.stream().filter(u -> u.getGender() != null && u.getGender() == 2).count(),
  104. pool.stream().filter(u -> u.getGender() == null || u.getGender() == 0).count());
  105. }
  106. } catch (Exception ex) {
  107. log.error("selectRecommendedUsers failed, fallback to empty list (possibly DB not available)", ex);
  108. ex.printStackTrace();
  109. return java.util.Collections.emptyList();
  110. }
  111. // 查询成功后,更新缓存(可选,用于性能优化)
  112. if (recommendProps.isCacheEnabled() && pool != null && !pool.isEmpty()) {
  113. try {
  114. String json = objectMapper.writeValueAsString(pool);
  115. stringRedisTemplate.opsForValue().set(poolKey, json, recommendProps.getCacheTtlSeconds(), TimeUnit.SECONDS);
  116. } catch (Exception ignore) {}
  117. }
  118. // 过滤用户的"不喜欢"集合,避免再次出现
  119. try {
  120. java.util.Set<String> disliked = stringRedisTemplate.opsForSet().members("rec:dislike:user:" + userId);
  121. if (disliked != null && !disliked.isEmpty()) {
  122. pool.removeIf(u -> u.getUserId() != null && disliked.contains(String.valueOf(u.getUserId())));
  123. }
  124. } catch (Exception ignore) {}
  125. // 再次过滤:确保不包含当前用户本身和未完善性别信息的用户,以及同性用户
  126. if (pool == null) {
  127. pool = new ArrayList<>();
  128. }
  129. final Integer finalCurrentUserGender = currentUser != null ? currentUser.getGender() : null;
  130. pool.removeIf(u -> {
  131. if (u.getUserId() == null || u.getUserId().equals(userId)) {
  132. log.debug("过滤当前用户自己: userId={}", u.getUserId());
  133. return true;
  134. }
  135. if (u.getGender() == null || u.getGender() == 0) {
  136. log.debug("过滤未设置性别的用户: userId={}", u.getUserId());
  137. return true;
  138. }
  139. // 如果当前用户性别已设置,排除同性
  140. if (finalCurrentUserGender != null && finalCurrentUserGender != 0) {
  141. if (u.getGender().equals(finalCurrentUserGender)) {
  142. log.debug("过滤同性用户: userId={}, gender={}, 当前用户gender={}", u.getUserId(), u.getGender(), finalCurrentUserGender);
  143. return true;
  144. }
  145. }
  146. return false;
  147. });
  148. log.info("过滤后的推荐列表数量: {}", pool.size());
  149. log.info("过滤后的推荐列表数量: {}", pool != null ? pool.size() : 0);
  150. // 迭代式 MMR:每次从候选中选择 reRank 分最高者加入结果集
  151. double lambda = recommendProps.getMmrLambda();
  152. List<RecommendUserVO> result = new ArrayList<>();
  153. Set<String> seenBuckets = new HashSet<>();
  154. // 先按原始分降序,作为MMR的初始候选顺序(空值按0处理)
  155. pool.sort(Comparator.<RecommendUserVO>comparingDouble((RecommendUserVO u) -> safeScore(u.getCompatibilityScore())).reversed());
  156. while (result.size() < limit && !pool.isEmpty()) {
  157. double bestScore = Double.NEGATIVE_INFINITY;
  158. int bestIdx = -1;
  159. for (int i = 0; i < Math.min(pool.size(), 200); i++) { // 每轮评估前200个
  160. RecommendUserVO cand = pool.get(i);
  161. // 桶去重:过多重复的城/校/职,不再考虑
  162. String bucket = (cand.getCityId() == null ? "-" : cand.getCityId()) + "|"
  163. + (cand.getSchoolName() == null ? "-" : cand.getSchoolName()) + "|"
  164. + (cand.getJobTitle() == null ? "-" : cand.getJobTitle());
  165. if (seenBuckets.contains(bucket)) {
  166. continue;
  167. }
  168. double maxSim = 0.0;
  169. for (RecommendUserVO chosen : result) {
  170. double sim = 0.0;
  171. // 余弦相似度扩展:兴趣 + 职位 + 院校 三路向量加权
  172. double hobbyCos = cosineSimilarity(tokenizeHobby(cand.getHobby()), tokenizeHobby(chosen.getHobby()));
  173. double jobCos = cosineSimilarity(tokenizeText(cand.getJobTitle()), tokenizeText(chosen.getJobTitle()));
  174. double schoolCos = cosineSimilarity(tokenizeText(cand.getSchoolName()), tokenizeText(chosen.getSchoolName()));
  175. double cosCombined = 0.5 * hobbyCos + 0.3 * jobCos + 0.2 * schoolCos; // 权重可后续配置化
  176. sim += cosCombined;
  177. if (sim > maxSim) maxSim = sim;
  178. }
  179. double reRankScore = safeScore(cand.getCompatibilityScore()) - lambda * maxSim * 100.0;
  180. if (reRankScore > bestScore) {
  181. bestScore = reRankScore;
  182. bestIdx = i;
  183. }
  184. }
  185. if (bestIdx >= 0) {
  186. RecommendUserVO chosen = pool.remove(bestIdx);
  187. String bucket = (chosen.getCityId() == null ? "-" : chosen.getCityId()) + "|"
  188. + (chosen.getSchoolName() == null ? "-" : chosen.getSchoolName()) + "|"
  189. + (chosen.getJobTitle() == null ? "-" : chosen.getJobTitle());
  190. seenBuckets.add(bucket);
  191. result.add(chosen);
  192. } else {
  193. break;
  194. }
  195. }
  196. // 写入最终 TopN 缓存
  197. if (recommendProps.isCacheEnabled() && !result.isEmpty()) {
  198. try {
  199. String topKey = "rec:top:" + userId + ":" + oppoOnly + ":" + limit;
  200. String json = objectMapper.writeValueAsString(result);
  201. stringRedisTemplate.opsForValue().set(topKey, json, recommendProps.getCacheTtlSeconds(), TimeUnit.SECONDS);
  202. } catch (Exception ignore) {}
  203. }
  204. return result;
  205. }
  206. @Override
  207. public List<RecommendUserVO> getRecommendedUsers(Integer userId, Integer oppoOnly, Integer limit, List<Integer> excludeIds) {
  208. // 调用原方法获取推荐列表
  209. List<RecommendUserVO> allUsers = getRecommendedUsers(userId, oppoOnly, limit * 10); // 获取更多数据以便过滤
  210. if (excludeIds == null || excludeIds.isEmpty()) {
  211. // 如果没有排除列表,直接返回前limit个
  212. return allUsers.stream().limit(limit).collect(java.util.stream.Collectors.toList());
  213. }
  214. // 过滤掉已显示的用户
  215. java.util.Set<Integer> excludeSet = new java.util.HashSet<>(excludeIds);
  216. List<RecommendUserVO> filtered = allUsers.stream()
  217. .filter(u -> u.getUserId() != null && !excludeSet.contains(u.getUserId()))
  218. .limit(limit)
  219. .collect(java.util.stream.Collectors.toList());
  220. log.info("排除已显示用户后,返回 {} 个用户(排除 {} 个用户ID)", filtered.size(), excludeIds.size());
  221. // 如果过滤后数量不足,说明所有用户都显示过了,返回空列表让前端重新开始
  222. if (filtered.size() < limit && allUsers.size() > 0) {
  223. log.info("所有用户都已显示过,建议前端重新开始");
  224. }
  225. return filtered;
  226. }
  227. @Override
  228. public List<RecommendUserVO> searchByRules(UserSearchQuery q) {
  229. if (q == null || q.getUserId() == null) {
  230. throw new IllegalArgumentException("userId cannot be null");
  231. }
  232. int limit = (q.getLimit() == null || q.getLimit() <= 0) ? 20 : q.getLimit();
  233. int offset = (q.getOffset() == null || q.getOffset() < 0) ? 0 : q.getOffset();
  234. // 计算出生日期范围(年龄 -> 出生日期)
  235. String birthMin = null, birthMax = null; // YYYY-MM-DD
  236. java.time.LocalDate today = java.time.LocalDate.now();
  237. if (q.getAgeMax() != null) {
  238. birthMin = today.minusYears(q.getAgeMax()).toString();
  239. }
  240. if (q.getAgeMin() != null) {
  241. birthMax = today.minusYears(q.getAgeMin()).toString();
  242. }
  243. // 兴趣标签转 JSON 数组字符串
  244. String hobbyJson = null;
  245. if (q.getHobbyTags() != null && !q.getHobbyTags().isEmpty()) {
  246. try { hobbyJson = objectMapper.writeValueAsString(q.getHobbyTags()); } catch (Exception ignore) {}
  247. }
  248. // 读取不喜欢集合,作为排除列表
  249. java.util.List<Integer> excludeIds = new java.util.ArrayList<>();
  250. try {
  251. java.util.Set<String> disliked = stringRedisTemplate.opsForSet().members("rec:dislike:user:" + q.getUserId());
  252. if (disliked != null) {
  253. for (String s : disliked) { try { excludeIds.add(Integer.parseInt(s)); } catch (Exception ignore) {} }
  254. }
  255. } catch (Exception ignore) {}
  256. // 获取当前用户的性别信息
  257. Integer currentUserGender = null;
  258. try {
  259. Users currentUser = usersMapper.selectById(q.getUserId());
  260. if (currentUser != null) {
  261. currentUserGender = currentUser.getGender();
  262. }
  263. } catch (Exception ex) {
  264. log.warn("Failed to get current user gender, userId: " + q.getUserId(), ex);
  265. }
  266. // 如果当前用户未完善性别信息,返回空列表
  267. if (currentUserGender == null || currentUserGender == 0) {
  268. log.warn("Current user has no gender information, userId: " + q.getUserId());
  269. return java.util.Collections.emptyList();
  270. }
  271. // 召回
  272. List<RecommendUserVO> pool;
  273. try {
  274. pool = recommendMapper.selectByRules(q, birthMin, birthMax, hobbyJson, excludeIds, Math.min(limit * 5, 200), offset);
  275. } catch (Exception ex) {
  276. log.error("selectByRules failed, fallback to empty list (possibly DB not available)", ex);
  277. return java.util.Collections.emptyList();
  278. }
  279. // 过滤:确保不包含当前用户本身和未完善性别信息的用户,只推荐异性
  280. if (pool != null && !pool.isEmpty()) {
  281. final Integer finalCurrentUserGender = currentUserGender;
  282. pool.removeIf(user ->
  283. user.getUserId() == null ||
  284. user.getUserId().equals(q.getUserId()) ||
  285. user.getGender() == null ||
  286. user.getGender() == 0 ||
  287. user.getGender().equals(finalCurrentUserGender) // 排除同性
  288. );
  289. }
  290. // 重排(沿用 MMR)
  291. double lambda = recommendProps.getMmrLambda();
  292. java.util.List<RecommendUserVO> result = new java.util.ArrayList<>();
  293. java.util.Set<String> seenBuckets = new java.util.HashSet<>();
  294. pool.sort(java.util.Comparator.<RecommendUserVO>comparingDouble((RecommendUserVO u) -> safeScore(u.getCompatibilityScore())).reversed());
  295. while (result.size() < limit && !pool.isEmpty()) {
  296. double bestScore = Double.NEGATIVE_INFINITY; int bestIdx = -1;
  297. for (int i = 0; i < Math.min(pool.size(), 200); i++) {
  298. RecommendUserVO cand = pool.get(i);
  299. String bucket = (cand.getCityId() == null ? "-" : cand.getCityId()) + "|"
  300. + (cand.getSchoolName() == null ? "-" : cand.getSchoolName()) + "|"
  301. + (cand.getJobTitle() == null ? "-" : cand.getJobTitle());
  302. if (seenBuckets.contains(bucket)) continue;
  303. double hobbyCos = cosineSimilarity(tokenizeHobby(cand.getHobby()), new String[0]);
  304. double jobCos = cosineSimilarity(tokenizeText(cand.getJobTitle()), new String[0]);
  305. double schoolCos = cosineSimilarity(tokenizeText(cand.getSchoolName()), new String[0]);
  306. double maxSim = 0.5 * hobbyCos + 0.3 * jobCos + 0.2 * schoolCos;
  307. double reRankScore = safeScore(cand.getCompatibilityScore()) - lambda * maxSim * 100.0;
  308. if (reRankScore > bestScore) { bestScore = reRankScore; bestIdx = i; }
  309. }
  310. if (bestIdx >= 0) {
  311. RecommendUserVO chosen = pool.remove(bestIdx);
  312. String bucket = (chosen.getCityId() == null ? "-" : chosen.getCityId()) + "|"
  313. + (chosen.getSchoolName() == null ? "-" : chosen.getSchoolName()) + "|"
  314. + (chosen.getJobTitle() == null ? "-" : chosen.getJobTitle());
  315. seenBuckets.add(bucket); result.add(chosen);
  316. } else break;
  317. }
  318. return result;
  319. }
  320. @Override
  321. public List<com.zhentao.pojo.Province> getAllProvinces() {
  322. return recommendMapper.selectAllProvinces();
  323. }
  324. @Override
  325. public List<com.zhentao.pojo.City> getAllCities() {
  326. return recommendMapper.selectAllCities();
  327. }
  328. @Override
  329. public List<com.zhentao.pojo.City> getCitiesByProvince(Integer provinceId) {
  330. if (provinceId == null) return java.util.Collections.emptyList();
  331. return recommendMapper.selectCitiesByProvince(provinceId);
  332. }
  333. @Override
  334. public List<com.zhentao.pojo.Area> getAreasByCity(Integer cityId) {
  335. if (cityId == null) return java.util.Collections.emptyList();
  336. return recommendMapper.selectAreasByCity(cityId);
  337. }
  338. private boolean safeEq(String a, String b) {
  339. if (a == null || b == null) return false;
  340. return a.equals(b);
  341. }
  342. private boolean jobSimilar(String a, String b) {
  343. if (a == null || b == null) return false;
  344. String as = a.toLowerCase();
  345. String bs = b.toLowerCase();
  346. return as.equals(bs) || as.contains(bs) || bs.contains(as);
  347. }
  348. private boolean hobbyOverlap(String a, String b) {
  349. if (a == null || b == null) return false;
  350. String as = a.toLowerCase();
  351. String bs = b.toLowerCase();
  352. String[] tokens = as.replace('[',' ').replace(']',' ').replace('"',' ').split("[, ]+");
  353. for (String t : tokens) {
  354. if (t == null || t.trim().isEmpty()) continue;
  355. if (bs.contains(t.trim())) return true;
  356. }
  357. return false;
  358. }
  359. private String[] tokenizeHobby(String hobbyJson) {
  360. if (hobbyJson == null) return new String[0];
  361. String merged = hobbyJson.toLowerCase().replace('[',' ').replace(']',' ').replace('"',' ');
  362. return merged.split("[^a-z0-9\\u4e00-\\u9fa5]+");
  363. }
  364. private String[] tokenizeText(String text) {
  365. if (text == null) return new String[0];
  366. return text.toLowerCase().split("[^a-z0-9\\u4e00-\\u9fa5]+");
  367. }
  368. private double cosineSimilarity(String[] a, String[] b) {
  369. if (a == null || b == null || a.length == 0 || b.length == 0) return 0.0;
  370. java.util.Map<String, int[]> map = new java.util.HashMap<>();
  371. for (String t : a) {
  372. if (t == null || t.isEmpty()) continue;
  373. map.computeIfAbsent(t, k -> new int[2])[0]++;
  374. }
  375. for (String t : b) {
  376. if (t == null || t.isEmpty()) continue;
  377. map.computeIfAbsent(t, k -> new int[2])[1]++;
  378. }
  379. double dot = 0, na = 0, nb = 0;
  380. for (int[] v : map.values()) {
  381. dot += v[0] * v[1];
  382. na += v[0] * v[0];
  383. nb += v[1] * v[1];
  384. }
  385. if (na == 0 || nb == 0) return 0.0;
  386. return dot / (Math.sqrt(na) * Math.sqrt(nb));
  387. }
  388. private double safeScore(Number n) {
  389. if (n == null) return 0.0;
  390. try { return n.doubleValue(); } catch (Exception ignore) { return 0.0; }
  391. }
  392. @Override
  393. public boolean saveUserLike(Integer userId, Integer likeUserId) {
  394. if (userId == null || likeUserId == null) {
  395. throw new IllegalArgumentException("userId and likeUserId cannot be null");
  396. }
  397. try {
  398. int result = recommendMapper.insertUserLike(userId, likeUserId);
  399. return result > 0;
  400. } catch (Exception ex) {
  401. log.error("saveUserLike failed", ex);
  402. return false;
  403. }
  404. }
  405. @Override
  406. public boolean deleteUserLike(Integer userId, Integer likeUserId) {
  407. if (userId == null || likeUserId == null) {
  408. throw new IllegalArgumentException("userId and likeUserId cannot be null");
  409. }
  410. try {
  411. int result = recommendMapper.deleteUserLike(userId, likeUserId);
  412. return result > 0;
  413. } catch (Exception ex) {
  414. log.error("deleteUserLike failed", ex);
  415. return false;
  416. }
  417. }
  418. @Override
  419. public List<RecommendUserVO> getLikedUsers(Integer userId, Integer offset, Integer limit) {
  420. if (userId == null) {
  421. throw new IllegalArgumentException("userId cannot be null");
  422. }
  423. if (limit == null || limit <= 0) {
  424. limit = 20;
  425. }
  426. if (offset == null || offset < 0) {
  427. offset = 0;
  428. }
  429. try {
  430. return recommendMapper.selectLikedUsers(userId, offset, limit);
  431. } catch (Exception ex) {
  432. log.error("getLikedUsers failed", ex);
  433. return java.util.Collections.emptyList();
  434. }
  435. }
  436. @Override
  437. public Integer countLikedUsers(Integer userId) {
  438. if (userId == null) {
  439. throw new IllegalArgumentException("userId cannot be null");
  440. }
  441. try {
  442. return recommendMapper.countLikedUsers(userId);
  443. } catch (Exception ex) {
  444. log.error("countLikedUsers failed", ex);
  445. return 0;
  446. }
  447. }
  448. }