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
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) {
|
|
}
|
|
}
|
|
|