RecommendationServiceImpl.java
package org.darkroomlibrary.service.impl;
import org.darkroomlibrary.context.CurrentUserContext;
import org.darkroomlibrary.domain.model.RecommendationBatch;
import org.darkroomlibrary.domain.model.RecommendationEvent;
import org.darkroomlibrary.domain.model.RecommendationItem;
import org.darkroomlibrary.domain.model.RecommendationSetting;
import org.darkroomlibrary.domain.model.User;
import org.darkroomlibrary.domain.recommendation.RecommendationBookProfile;
import org.darkroomlibrary.domain.recommendation.RecommendationFavoriteLink;
import org.darkroomlibrary.domain.recommendation.RecommendationUserSignal;
import org.darkroomlibrary.domain.type.AccountStatus;
import org.darkroomlibrary.domain.type.UserRole;
import org.darkroomlibrary.mapper.RecommendationMapper;
import org.darkroomlibrary.mapper.UserMapper;
import org.darkroomlibrary.service.RecommendationService;
import org.darkroomlibrary.service.support.RecommendationRankingEngine;
import org.darkroomlibrary.service.support.RecommendationSourceVersionService;
import org.darkroomlibrary.service.support.RecommendationRankingEngine.RankedRecommendation;
import org.darkroomlibrary.service.support.RecommendationRankingEngine.RecommendationPlan;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.web.view.RecommendationFeedView;
import org.darkroomlibrary.web.view.RecommendationItemView;
import org.darkroomlibrary.web.view.RecommendationSettingView;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class RecommendationServiceImpl implements RecommendationService {
private static final int DEFAULT_FEED_SIZE = 6;
private static final int MAX_GENERATED_ITEMS = 8;
private static final int PERSONALIZATION_THRESHOLD = 3;
private static final int DISMISS_RETENTION_DAYS = 30;
private static final String DATA_SCOPE = "只使用你主动留下的收藏、借阅与评分,不记录无关浏览行为。";
private static final String CLEAR_EFFECT = "清除曝光、点击与计算结果,不删除收藏、借阅或书评。";
@Resource
private RecommendationMapper recommendationMapper;
@Resource
private RecommendationRankingEngine rankingEngine;
@Resource
private RecommendationSourceVersionService sourceVersionService;
@Resource
private UserMapper userMapper;
@Override
@Transactional
public ApiResponse<RecommendationFeedView> feed(Integer requestedSize) {
Integer userId = CurrentUserContext.userId();
if (!isReader(userId)) return ApiResponse.error("当前身份不能读取读者推荐");
int size = requestedSize == null
? DEFAULT_FEED_SIZE : Math.max(1, Math.min(requestedSize, MAX_GENERATED_ITEMS));
RecommendationSetting setting = recommendationMapper.findSetting(userId);
boolean enabled = setting == null || Boolean.TRUE.equals(setting.getEnabled());
LocalDateTime now = now();
Optional<String> sourceSeed = sourceVersionService.currentSeed(userId);
String fingerprint = sourceSeed.map(seed -> fingerprint(enabled, seed)).orElse(null);
RecommendationBatch batch = fingerprint == null
? null : recommendationMapper.findReusableBatch(userId, fingerprint, now);
if (batch == null) {
Set<Integer> dismissedBookIds = recommendationMapper.findDismissedBookIds(
userId, now.minusDays(DISMISS_RETENTION_DAYS)).stream().collect(Collectors.toSet());
List<RecommendationBookProfile> books = recommendationMapper.findActiveBookProfiles()
.stream().filter(book -> !dismissedBookIds.contains(book.getId())).toList();
List<RecommendationUserSignal> signals = enabled
? recommendationMapper.findUserSignals(userId) : List.of();
long favoriteCount = signals.stream()
.filter(signal -> signal.getFavoriteCount() != null && signal.getFavoriteCount() > 0)
.count();
List<RecommendationFavoriteLink> links = favoriteCount >= PERSONALIZATION_THRESHOLD
? recommendationMapper.findFavoriteLinks(userId) : List.of();
if (fingerprint == null) {
fingerprint = fingerprint(enabled, books, signals, links, dismissedBookIds);
batch = recommendationMapper.findReusableBatch(userId, fingerprint, now);
}
if (batch != null) {
return feedView(batch, enabled, size, userId, now);
}
RecommendationPlan plan = rankingEngine.rank(userId, books, signals, links,
enabled, MAX_GENERATED_ITEMS, now);
batch = persistPlan(userId, fingerprint, plan, now);
recommendationMapper.pruneExpiredBatches(userId, now.minusDays(90));
}
return feedView(batch, enabled, size, userId, now);
}
private ApiResponse<RecommendationFeedView> feedView(RecommendationBatch batch,
boolean enabled,
int size,
Integer userId,
LocalDateTime now) {
List<RecommendationItemView> items = recommendationMapper.findItems(batch.getId())
.stream().limit(size).toList();
for (RecommendationItemView item : items) {
insertUniqueEvent(userId, item.getItemId(), "EXPOSE", now);
}
return ApiResponse.success(RecommendationFeedView.builder()
.mode(batch.getMode())
.personalized(!"PUBLIC".equals(batch.getMode()))
.enabled(enabled)
.signalCount(batch.getSignalCount())
.generatedAt(batch.getGeneratedAt())
.privacyNotice(DATA_SCOPE)
.items(items)
.build());
}
@Override
public ApiResponse<RecommendationSettingView> setting() {
Integer userId = CurrentUserContext.userId();
if (!isReaderContext(userId)) return ApiResponse.error("当前身份不能读取推荐设置");
RecommendationSetting setting = recommendationMapper.findSetting(userId);
return ApiResponse.success(settingView(setting == null || Boolean.TRUE.equals(setting.getEnabled())));
}
@Override
@Transactional
public ApiResponse<RecommendationSettingView> updateSetting(Boolean enabled) {
Integer userId = CurrentUserContext.userId();
if (enabled == null || !isReader(userId)) return ApiResponse.error("推荐设置无效");
RecommendationSetting setting = RecommendationSetting.builder()
.userId(userId)
.enabled(enabled)
.updatedAt(now())
.build();
if (recommendationMapper.updateSetting(setting) == 0) {
try {
recommendationMapper.insertSetting(setting);
} catch (DuplicateKeyException ignored) {
recommendationMapper.updateSetting(setting);
}
}
sourceVersionService.invalidateUserAfterCommit(userId);
return ApiResponse.success(enabled ? "个性化推荐已开启" : "已改为公共荐书", settingView(enabled));
}
@Override
@Transactional
public ApiResponse<Void> clearHistory() {
Integer userId = CurrentUserContext.userId();
if (!isReader(userId)) return ApiResponse.error("当前身份不能清除推荐记录");
recommendationMapper.deleteEventsByUser(userId);
recommendationMapper.deleteItemsByUser(userId);
recommendationMapper.deleteBatchesByUser(userId);
sourceVersionService.invalidateUserAfterCommit(userId);
return ApiResponse.success("推荐记录已清除,收藏、借阅和书评保持不变");
}
@Override
@Transactional
public ApiResponse<Void> recordEvent(Long itemId, String eventType) {
Integer userId = CurrentUserContext.userId();
if (itemId == null || !List.of("CLICK", "DISMISS").contains(eventType)
|| !isReaderContext(userId)) {
return ApiResponse.error("推荐事件无效");
}
RecommendationItem item = recommendationMapper.findOwnedItem(userId, itemId);
if (item == null) return ApiResponse.error("推荐条目不存在或不属于当前读者");
int inserted = insertUniqueEvent(userId, itemId, eventType, now());
if (inserted > 0 && "DISMISS".equals(eventType)) {
sourceVersionService.invalidateUserAfterCommit(userId);
}
return "DISMISS".equals(eventType)
? ApiResponse.success("已减少此类推荐") : ApiResponse.success();
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void attributeFavorite(Integer userId, Integer bookId) {
if (userId == null || bookId == null) return;
RecommendationItem item = recommendationMapper.findLatestExposedItem(
userId, bookId, now().minusDays(7));
if (item != null) {
insertUniqueEvent(userId, item.getId(), "FAVORITE", now());
}
}
private RecommendationBatch persistPlan(Integer userId,
String fingerprint,
RecommendationPlan plan,
LocalDateTime now) {
RecommendationBatch batch = RecommendationBatch.builder()
.userId(userId)
.mode(plan.mode())
.algorithmVersion(RecommendationRankingEngine.ALGORITHM_VERSION)
.signalCount(plan.signalCount())
.sourceFingerprint(fingerprint)
.generatedAt(now)
.expiresAt(now.plusHours(6))
.build();
recommendationMapper.insertBatch(batch);
for (RankedRecommendation ranked : plan.items()) {
RecommendationItem item = RecommendationItem.builder()
.batchId(batch.getId())
.userId(userId)
.bookId(ranked.book().getId())
.rankNo(ranked.rank())
.totalScore(score(ranked.totalScore()))
.contentScore(score(ranked.contentScore()))
.collaborativeScore(score(ranked.collaborativeScore()))
.qualityScore(score(ranked.qualityScore()))
.explorationScore(score(ranked.explorationScore()))
.sourceType(ranked.sourceType())
.reason(ranked.reason())
.build();
recommendationMapper.insertItem(item);
}
return batch;
}
private RecommendationSettingView settingView(boolean enabled) {
return RecommendationSettingView.builder()
.enabled(enabled)
.dataScope(DATA_SCOPE)
.clearEffect(CLEAR_EFFECT)
.build();
}
private int insertUniqueEvent(Integer userId, Long itemId, String eventType, LocalDateTime time) {
return recommendationMapper.insertEvent(RecommendationEvent.builder()
.userId(userId)
.itemId(itemId)
.eventType(eventType)
.createdAt(time)
.build());
}
private boolean isReader(Integer userId) {
if (!isReaderContext(userId)) return false;
User user = userMapper.findByIdForUpdate(userId);
return user != null
&& Objects.equals(user.getUserRole(), UserRole.READER.code())
&& Objects.equals(user.getAccountStatus(), AccountStatus.NORMAL.code())
&& !Boolean.TRUE.equals(user.getIsLogin());
}
private boolean isReaderContext(Integer userId) {
return userId != null && Objects.equals(CurrentUserContext.roleCode(), UserRole.READER.code());
}
private BigDecimal score(double value) {
double safe = Double.isFinite(value) ? Math.max(0d, value) : 0d;
return BigDecimal.valueOf(safe).setScale(6, RoundingMode.HALF_UP);
}
private LocalDateTime now() {
return LocalDateTime.now().withNano(0);
}
private String fingerprint(boolean enabled, String sourceSeed) {
return hash((enabled ? "1|" : "0|")
+ RecommendationRankingEngine.ALGORITHM_VERSION + '|' + sourceSeed);
}
private String fingerprint(boolean enabled,
List<RecommendationBookProfile> books,
List<RecommendationUserSignal> signals,
List<RecommendationFavoriteLink> links,
Set<Integer> dismissedBookIds) {
StringBuilder source = new StringBuilder(enabled ? "1|" : "0|");
source.append(RecommendationRankingEngine.ALGORITHM_VERSION).append('|');
books.stream().sorted(Comparator.comparing(RecommendationBookProfile::getId)).forEach(book ->
source.append('b').append(book.getId()).append(':').append(book.getVersion())
.append(':').append(book.getFavoriteCount()).append(':').append(book.getBorrowCount())
.append(':').append(book.getReviewCount()).append(':').append(book.getAverageRating()).append('|'));
signals.stream().sorted(Comparator.comparing(RecommendationUserSignal::getBookId)).forEach(signal ->
source.append('s').append(signal.getBookId()).append(':').append(signal.getFavoriteCount())
.append(':').append(signal.getBorrowCount()).append(':').append(signal.getActiveBorrowCount())
.append(':').append(signal.getReviewCount()).append(':').append(signal.getAverageRating())
.append(':').append(signal.getLatestInteractionTime()).append('|'));
links.stream().sorted(Comparator.comparing(RecommendationFavoriteLink::getUserId)
.thenComparing(RecommendationFavoriteLink::getBookId))
.forEach(link -> source.append('f').append(link.getUserId()).append(':')
.append(link.getBookId()).append('|'));
dismissedBookIds.stream().sorted()
.forEach(bookId -> source.append('d').append(bookId).append('|'));
return hash(source.toString());
}
private String hash(String source) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(source.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
}