You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

411 lines
16 KiB

package com.mydreamjob.onboarding;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ReviewSummaryView;
import com.mydreamjob.review.JobReviewDimensionScoreRepository;
import com.mydreamjob.review.JobReviewRepository;
import com.mydreamjob.review.ReviewDimension;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CompanyKnowledgeService {
private static final String CONFIGURED = "CONFIGURED";
private static final String BASIC_ONLY = "BASIC_ONLY";
private static final String EMPTY = "EMPTY";
private static final String INSUFFICIENT = "INSUFFICIENT";
private static final String SUFFICIENT = "SUFFICIENT";
private static final int MINIMUM_PUBLIC_REVIEWS = 3;
private static final int MAX_TOP_TAGS = 10;
private static final int MAX_CULTURE_LENGTH = 2000;
private static final int MAX_WORK_POLICIES_LENGTH = 4000;
private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000;
private static final Pattern HTML_TAG_PATTERN =
Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final CompanyKnowledgeRepository companyKnowledgeRepository;
private final JobReviewRepository jobReviewRepository;
private final JobReviewDimensionScoreRepository dimensionScoreRepository;
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public CompanyKnowledgeView getCompanyKnowledge(Long userId) {
Company company = requireOwnedCompany(userId);
return companyKnowledgeRepository.findByCompanyId(company.getId())
.map(this::toConfiguredView)
.orElseGet(() -> CompanyKnowledgeView.builder()
.configured(false)
.version(0L)
.build());
}
@Transactional
public CompanyKnowledgeView upsertCompanyKnowledge(
Long userId,
CompanyKnowledgeUpdateRequest request) {
Company company = requireOwnedCompany(userId);
KnowledgeContent content = validateContent(request);
lockCompany(company);
CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId())
.orElse(null);
if (existing == null) {
CompanyKnowledge created = CompanyKnowledge.builder()
.companyId(company.getId())
.culture(content.culture())
.workPolicies(content.workPolicies())
.onboardingGuide(content.onboardingGuide())
.build();
return toConfiguredView(saveKnowledge(created));
}
if (request.getVersion() == null && matchesContent(existing, content)) {
return toConfiguredView(existing);
}
if (request.getVersion() == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (!Objects.equals(request.getVersion(), existing.getVersion())) {
throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE);
}
existing.setCulture(content.culture());
existing.setWorkPolicies(content.workPolicies());
existing.setOnboardingGuide(content.onboardingGuide());
return toConfiguredView(saveKnowledge(existing));
}
@Transactional(readOnly = true)
public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview(
Long userId,
Long companyId) {
assertStudentRole(userId);
Company company = companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId)
.orElse(null);
return CompanyKnowledgeOverviewResponse.builder()
.company(toCompanyView(company))
.knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED)
.knowledge(knowledge == null ? null : toKnowledgeView(knowledge))
.reviewSummary(buildReviewSummary(companyId))
.build();
}
private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) {
try {
return companyKnowledgeRepository.saveAndFlush(knowledge);
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) {
throw new BusinessException(
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex);
}
}
private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) {
return Objects.equals(knowledge.getCulture(), content.culture())
&& Objects.equals(knowledge.getWorkPolicies(), content.workPolicies())
&& Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide());
}
private void lockCompany(Company company) {
if (entityManager == null || company == null || company.getId() == null) {
return;
}
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE);
}
private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (request.getVersion() != null && request.getVersion() < 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH);
String workPolicies = normalizeText(
request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH);
String onboardingGuide = normalizeText(
request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH);
if (culture == null && workPolicies == null && onboardingGuide == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return new KnowledgeContent(culture, workPolicies, onboardingGuide);
}
private String normalizeText(String raw, int maxLength) {
if (!StringUtils.hasText(raw)) {
return null;
}
String value = raw.trim();
if (value.length() > maxLength) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (HTML_TAG_PATTERN.matcher(value).find()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return value;
}
private ReviewSummaryView buildReviewSummary(Long companyId) {
List<Object[]> countRows =
jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId);
long totalReviews = extractCount(countRows);
if (totalReviews == 0) {
return ReviewSummaryView.builder()
.state(EMPTY)
.totalReviews(0)
.build();
}
if (totalReviews < MINIMUM_PUBLIC_REVIEWS) {
return ReviewSummaryView.builder()
.state(INSUFFICIENT)
.totalReviews(Math.toIntExact(totalReviews))
.build();
}
return ReviewSummaryView.builder()
.state(SUFFICIENT)
.totalReviews(Math.toIntExact(totalReviews))
.averageRating(extractAverageRating(countRows))
.ratingDistribution(buildRatingDistribution(companyId))
.topTags(buildTopTags(companyId))
.dimensionSummary(buildDimensionSummary(companyId))
.build();
}
private long extractCount(List<Object[]> rows) {
if (rows == null || rows.isEmpty() || rows.get(0) == null
|| rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) {
return 0L;
}
return count.longValue();
}
private Double extractAverageRating(List<Object[]> rows) {
if (rows == null || rows.isEmpty() || rows.get(0) == null
|| rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) {
return 0.0;
}
return BigDecimal.valueOf(average.doubleValue())
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
}
private Map<String, Integer> buildRatingDistribution(Long companyId) {
Map<String, Integer> distribution = new LinkedHashMap<>();
for (int rating = 1; rating <= 5; rating++) {
distribution.put("rating" + rating, 0);
}
List<Object[]> rows =
jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId);
if (rows == null) {
return distribution;
}
for (Object[] row : rows) {
if (row == null || row.length < 2
|| !(row[0] instanceof Number rating)
|| !(row[1] instanceof Number count)) {
continue;
}
int ratingValue = rating.intValue();
if (ratingValue >= 1 && ratingValue <= 5) {
distribution.put("rating" + ratingValue, count.intValue());
}
}
return distribution;
}
private List<String> buildTopTags(Long companyId) {
Map<String, Long> counts = new HashMap<>();
List<String> rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId);
if (rawTags != null) {
for (String rawTag : rawTags) {
for (String tag : parseTags(rawTag)) {
counts.merge(tag, 1L, Long::sum);
}
}
}
return counts.entrySet().stream()
.sorted((left, right) -> {
int byCount = Long.compare(right.getValue(), left.getValue());
return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey());
})
.limit(MAX_TOP_TAGS)
.map(Map.Entry::getKey)
.toList();
}
private List<String> parseTags(String raw) {
if (!StringUtils.hasText(raw)) {
return List.of();
}
Object parsed = parseJson(raw);
for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) {
parsed = parseJson(nested);
}
if (!(parsed instanceof Collection<?> values)) {
return List.of();
}
List<String> tags = new ArrayList<>();
for (Object value : values) {
if (value instanceof String tag && StringUtils.hasText(tag)) {
tags.add(tag.trim());
}
}
return tags;
}
private Object parseJson(String raw) {
try {
return OBJECT_MAPPER.readValue(raw, Object.class);
} catch (JsonProcessingException ex) {
return null;
}
}
private Map<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary(
Long companyId) {
Map<String, Object[]> rowsByCode = new HashMap<>();
List<Object[]> rows =
dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId);
if (rows != null) {
for (Object[] row : rows) {
if (row != null && row.length >= 3 && row[0] instanceof String code) {
rowsByCode.put(code, row);
}
}
}
Map<String, ReviewSummaryView.DimensionSummaryView> result = new LinkedHashMap<>();
for (ReviewDimension dimension : ReviewDimension.values()) {
Object[] row = rowsByCode.get(dimension.name());
long sampleCount = 0L;
Double averageScore = null;
if (row != null && row[2] instanceof Number count) {
sampleCount = count.longValue();
}
if (row != null && row[1] instanceof Number average) {
averageScore = BigDecimal.valueOf(average.doubleValue())
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
}
result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder()
.averageScore(averageScore)
.sampleCount(sampleCount)
.dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS
? SUFFICIENT : INSUFFICIENT)
.build());
}
return result;
}
private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) {
return CompanyKnowledgeView.builder()
.configured(true)
.culture(knowledge.getCulture())
.workPolicies(knowledge.getWorkPolicies())
.onboardingGuide(knowledge.getOnboardingGuide())
.updatedAt(knowledge.getUpdatedAt())
.version(knowledge.getVersion() == null ? 0L : knowledge.getVersion())
.build();
}
private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView(
CompanyKnowledge knowledge) {
return CompanyKnowledgeOverviewResponse.KnowledgeView.builder()
.culture(knowledge.getCulture())
.workPolicies(knowledge.getWorkPolicies())
.onboardingGuide(knowledge.getOnboardingGuide())
.updatedAt(knowledge.getUpdatedAt())
.build();
}
private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) {
return CompanyKnowledgeOverviewResponse.CompanyView.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.website(company.getWebsite())
.address(company.getAddress())
.verified(company.getVerified())
.build();
}
private Company requireOwnedCompany(Long userId) {
assertCompanyRole(userId);
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
}
private void assertCompanyRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private record KnowledgeContent(
String culture,
String workPolicies,
String onboardingGuide) {
}
}