diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java new file mode 100644 index 0000000..1b98711 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java @@ -0,0 +1,63 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "company_knowledge", + uniqueConstraints = @UniqueConstraint( + name = "uk_company_knowledge_company", + columnNames = "company_id")) +@EntityListeners(AuditingEntityListener.class) +public class CompanyKnowledge { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(length = 2000) + private String culture; + + @Column(name = "work_policies", length = 4000) + private String workPolicies; + + @Column(name = "onboarding_guide", length = 4000) + private String onboardingGuide; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java new file mode 100644 index 0000000..17bc85f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CompanyKnowledgeRepository extends JpaRepository { + + Optional findByCompanyId(Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java new file mode 100644 index 0000000..f503449 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java @@ -0,0 +1,95 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "onboarding_checklist_items", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_onboarding_checklist_scope_item", + columnNames = {"scope_type", "scope_id", "item_key"}), + @UniqueConstraint( + name = "uk_onboarding_checklist_company_idempotency", + columnNames = {"company_id", "idempotency_key"}) + }) +@EntityListeners(AuditingEntityListener.class) +public class OnboardingChecklistItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "scope_type", nullable = false, length = 20) + private String scopeType; + + @Column(name = "scope_id", nullable = false) + private Long scopeId; + + @Column(name = "company_id") + private Long companyId; + + @Column(name = "item_key", nullable = false, length = 80) + private String itemKey; + + @Column(nullable = false, length = 120) + private String title; + + @Column(length = 1000) + private String description; + + @Column(nullable = false, length = 30) + private String category; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 1000; + + @Column(nullable = false) + @Builder.Default + private Boolean required = false; + + @Column(nullable = false) + @Builder.Default + private Boolean active = true; + + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + + @Column(name = "request_hash", columnDefinition = "CHAR(64)") + private String requestHash; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java new file mode 100644 index 0000000..5206055 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java @@ -0,0 +1,26 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface OnboardingChecklistItemRepository extends JpaRepository { + + @Query(""" + SELECT item + FROM OnboardingChecklistItem item + WHERE item.active = true + AND ( + item.scopeType = 'STANDARD' + OR (item.scopeType = 'COMPANY' + AND item.scopeId = :companyId + AND item.companyId = :companyId) + ) + ORDER BY item.sortOrder ASC, item.id ASC + """) + List findEffectiveChecklistItems(@Param("companyId") Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java new file mode 100644 index 0000000..563c409 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java @@ -0,0 +1,64 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "student_onboarding_progress", + uniqueConstraints = @UniqueConstraint( + name = "uk_student_onboarding_progress_student_item", + columnNames = {"student_user_id", "item_id"})) +@EntityListeners(AuditingEntityListener.class) +public class StudentOnboardingProgress { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "item_id", nullable = false) + private Long itemId; + + @Column(nullable = false) + @Builder.Default + private Boolean completed = false; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java new file mode 100644 index 0000000..599315d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java @@ -0,0 +1,19 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StudentOnboardingProgressRepository + extends JpaRepository { + + List findByStudentUserIdAndItemIdIn( + Long studentUserId, Collection itemIds); + + Optional findByStudentUserIdAndItemId( + Long studentUserId, Long itemId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java index 9ac6f60..b27968d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java @@ -42,4 +42,23 @@ public interface JobReviewDimensionScoreRepository extends JpaRepository aggregatePublishedByJobId(@Param("jobId") Long jobId); + + /** + * 按公司聚合公开评价八维评分:一次数据库聚合,不加载 JobReview 实体, + * 不先查询 reviewId 列表。返回 [dimensionCode, AVG(score), COUNT(id)]。 + */ + @Query(""" + SELECT d.dimensionCode, AVG(d.score), COUNT(d.id) + FROM JobReviewDimensionScore d + WHERE d.reviewId IN ( + SELECT r.id + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + ) + GROUP BY d.dimensionCode + ORDER BY d.dimensionCode + """) + List aggregatePublishedDimensionsByCompanyId(@Param("companyId") Long companyId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java index 7766da1..75be056 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -31,6 +31,41 @@ public interface JobReviewRepository extends JpaRepository { List findByJobIdAndStatusAndModerationStatusIn( Long jobId, String status, Collection moderationStatuses); + + /** 企业公开评价汇总:一次查询统计全部岗位的 PUBLISHED + 审核通过评价。 */ + @Query(""" + SELECT COUNT(r), COALESCE(AVG(r.rating), 0) + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + """) + List countAndAveragePublishedRatingByCompanyId(@Param("companyId") Long companyId); + + /** 企业公开评价评分分布,按评分升序返回 [rating, count]。 */ + @Query(""" + SELECT r.rating, COUNT(r) + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + GROUP BY r.rating + ORDER BY r.rating + """) + List findPublishedRatingDistributionByCompanyId(@Param("companyId") Long companyId); + + /** 企业公开评价的原始 tags 列,避免逐条读取评价。 */ + @Query(""" + SELECT r.tags + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + AND r.tags IS NOT NULL + ORDER BY r.id ASC + """) + List findPublishedTagsByCompanyId(@Param("companyId") Long companyId); + // ── 防刷规则查询 ────────────────────────────────────────── long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java new file mode 100644 index 0000000..0f10218 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java @@ -0,0 +1,399 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.review.JobReview; +import com.mydreamjob.review.JobReviewDimensionScore; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.junit.jupiter.api.BeforeEach; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.onboarding.OnboardingRepositoryIntegrationTest$OnboardingSchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class OnboardingRepositoryIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(900); + + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + @Autowired private JobReviewRepository reviewRepository; + @Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + dimensionScoreRepository.deleteAll(); + reviewRepository.deleteAll(); + jobRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void effectiveChecklistReturnsActiveStandardAndCompanyItemsInStableOrder() { + Long companyAId = createCompany("清单企业A"); + Long companyBId = createCompany("清单企业B"); + + OnboardingChecklistItem standardActive = saveChecklistItem( + "STANDARD", 0L, null, "standard-visible", "通用可见项", 100, true, null, null); + saveChecklistItem("STANDARD", 0L, null, "standard-inactive", "通用停用项", 1, false, null, null); + OnboardingChecklistItem companyFirst = saveChecklistItem( + "COMPANY", companyAId, companyAId, "company-a-first", "企业A首项", 50, true, + "company-a-first-key", "a".repeat(64)); + OnboardingChecklistItem companySecond = saveChecklistItem( + "COMPANY", companyAId, companyAId, "company-a-second", "企业A次项", 50, true, + "company-a-second-key", "b".repeat(64)); + saveChecklistItem("COMPANY", companyAId, companyAId, "company-a-inactive", "企业A停用项", + 40, false, null, null); + saveChecklistItem("COMPANY", companyBId, companyBId, "company-b-visible", "企业B可见项", + 10, true, "company-b-key", "c".repeat(64)); + + List result = checklistItemRepository.findEffectiveChecklistItems(companyAId); + + assertThat(result) + .extracting(OnboardingChecklistItem::getId) + .containsExactly(companyFirst.getId(), companySecond.getId(), standardActive.getId()); + assertThat(result) + .extracting(OnboardingChecklistItem::getItemKey) + .containsExactly("company-a-first", "company-a-second", "standard-visible"); + } + + @Test + void requestHashIsMappedAsChar64() { + Long companyId = createCompany("哈希企业"); + String hash = "0123456789abcdef".repeat(4); + OnboardingChecklistItem saved = saveChecklistItem( + "COMPANY", companyId, companyId, "company-hash", "哈希映射项", 200, true, + "company-hash-key", hash); + checklistItemRepository.flush(); + + assertThat(checklistItemRepository.findById(saved.getId())) + .get() + .extracting(OnboardingChecklistItem::getRequestHash) + .isEqualTo(hash); + + String typeName = jdbcTemplate.queryForObject( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " + + "AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'", + String.class); + Integer columnSize = jdbcTemplate.queryForObject( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " + + "AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'", + Integer.class); + + assertThat(typeName).isNotNull().startsWith("CHAR").doesNotContain("VARCHAR"); + assertThat(columnSize).isEqualTo(64); + } + + @Test + void progressBatchAndUniqueLookupsAreScopedToStudentAndItem() { + Long studentOne = createUser(User.Role.STUDENT, "进度学生A"); + Long studentTwo = createUser(User.Role.STUDENT, "进度学生B"); + OnboardingChecklistItem firstItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-1", "进度项1", 10, true, null, null); + OnboardingChecklistItem secondItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-2", "进度项2", 20, true, null, null); + OnboardingChecklistItem thirdItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-3", "进度项3", 30, true, null, null); + + LocalDateTime completedAt = LocalDateTime.of(2026, 9, 16, 8, 0); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentOne).itemId(firstItem.getId()).completed(true) + .completedAt(completedAt).build()); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentOne).itemId(secondItem.getId()).completed(false) + .completedAt(null).build()); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentTwo).itemId(firstItem.getId()).completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 9, 0)).build()); + progressRepository.flush(); + + List batch = progressRepository.findByStudentUserIdAndItemIdIn( + studentOne, List.of(firstItem.getId(), secondItem.getId(), thirdItem.getId())); + assertThat(batch) + .extracting(StudentOnboardingProgress::getItemId) + .containsExactlyInAnyOrder(firstItem.getId(), secondItem.getId()); + assertThat(batch) + .filteredOn(progress -> progress.getItemId().equals(firstItem.getId())) + .singleElement() + .extracting(StudentOnboardingProgress::getCompletedAt) + .isEqualTo(completedAt); + + Optional unique = progressRepository.findByStudentUserIdAndItemId( + studentOne, firstItem.getId()); + assertThat(unique).isPresent(); + assertThat(unique.orElseThrow().getCompleted()).isTrue(); + assertThat(progressRepository.findByStudentUserIdAndItemId(studentOne, thirdItem.getId())).isEmpty(); + assertThat(progressRepository.findByStudentUserIdAndItemId(studentTwo, secondItem.getId())).isEmpty(); + } + + @Test + void companyKnowledgeLookupIsScopedToCompany() { + Long companyId = createCompany("知识企业"); + Long otherCompanyId = createCompany("其他知识企业"); + knowledgeRepository.save(CompanyKnowledge.builder() + .companyId(companyId) + .culture("重视透明沟通") + .workPolicies("弹性工作制") + .onboardingGuide("前台报到") + .build()); + knowledgeRepository.flush(); + + Optional found = knowledgeRepository.findByCompanyId(companyId); + assertThat(found).isPresent(); + assertThat(found.orElseThrow().getCulture()).isEqualTo("重视透明沟通"); + assertThat(found.orElseThrow().getWorkPolicies()).isEqualTo("弹性工作制"); + assertThat(found.orElseThrow().getOnboardingGuide()).isEqualTo("前台报到"); + assertThat(knowledgeRepository.findByCompanyId(otherCompanyId)).isEmpty(); + } + + @Test + void companyReviewAggregatesIncludeOnlyPublishedApprovedReviews() { + Long companyId = createCompany("评价企业A"); + Long otherCompanyId = createCompany("评价企业B"); + Long studentOne = createUser(User.Role.STUDENT, "评价学生A"); + Long studentTwo = createUser(User.Role.STUDENT, "评价学生B"); + Long studentThree = createUser(User.Role.STUDENT, "评价学生C"); + Long hiddenStudent = createUser(User.Role.STUDENT, "评价学生D"); + Long otherStudent = createUser(User.Role.STUDENT, "评价学生E"); + + Long firstJobId = createJob(companyId, "评价岗位A1"); + Long secondJobId = createJob(companyId, "评价岗位A2"); + Long otherJobId = createJob(otherCompanyId, "评价岗位B"); + + JobReview five = saveReview(firstJobId, studentOne, 5, "[\"成长空间\",\"团队氛围\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview four = saveReview(secondJobId, studentTwo, 4, "[\"成长空间\"]", + "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED); + JobReview three = saveReview(firstJobId, studentThree, 3, "[\"团队氛围\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview hidden = saveReview(firstJobId, hiddenStudent, 1, "[\"薪资透明\"]", + "PUBLISHED", JobReview.MODERATION_PENDING); + saveReview(otherJobId, otherStudent, 2, "[\"其他企业标签\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + + dimensionScoreRepository.saveAll(List.of( + dimensionScore(five.getId(), "TEAM_ATMOSPHERE", 5), + dimensionScore(five.getId(), "GROWTH", 5), + dimensionScore(four.getId(), "TEAM_ATMOSPHERE", 4), + dimensionScore(four.getId(), "GROWTH", 4), + dimensionScore(three.getId(), "TEAM_ATMOSPHERE", 3), + dimensionScore(hidden.getId(), "TEAM_ATMOSPHERE", 1) + )); + dimensionScoreRepository.flush(); + + List summary = reviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); + assertThat(summary).hasSize(1); + assertThat(((Number) summary.get(0)[0]).longValue()).isEqualTo(3); + assertThat(((Number) summary.get(0)[1]).doubleValue()).isEqualTo(4.0); + + List distribution = reviewRepository.findPublishedRatingDistributionByCompanyId(companyId); + assertThat(distribution).hasSize(3); + assertThat(((Number) distribution.get(0)[0]).intValue()).isEqualTo(3); + assertThat(((Number) distribution.get(0)[1]).longValue()).isEqualTo(1); + assertThat(((Number) distribution.get(1)[0]).intValue()).isEqualTo(4); + assertThat(((Number) distribution.get(1)[1]).longValue()).isEqualTo(1); + assertThat(((Number) distribution.get(2)[0]).intValue()).isEqualTo(5); + assertThat(((Number) distribution.get(2)[1]).longValue()).isEqualTo(1); + + List tags = reviewRepository.findPublishedTagsByCompanyId(companyId); + assertThat(tags).hasSize(3); + assertThat(tags).allSatisfy(tag -> + assertThat(tag).doesNotContain("薪资透明", "其他企业标签")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("成长空间").contains("团队氛围")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("成长空间").doesNotContain("团队氛围")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("团队氛围").doesNotContain("成长空间")); + + List dimensions = dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); + assertThat(dimensions).hasSize(2); + assertThat(dimensions.get(0)[0]).isEqualTo("GROWTH"); + assertThat(((Number) dimensions.get(0)[1]).doubleValue()).isEqualTo(4.5); + assertThat(((Number) dimensions.get(0)[2]).longValue()).isEqualTo(2); + assertThat(dimensions.get(1)[0]).isEqualTo("TEAM_ATMOSPHERE"); + assertThat(((Number) dimensions.get(1)[1]).doubleValue()).isEqualTo(4.0); + assertThat(((Number) dimensions.get(1)[2]).longValue()).isEqualTo(3); + } + + private Long createCompany(String name) { + Long ownerId = createUser(User.Role.COMPANY, name + "负责人"); + return companyRepository.save(Company.builder() + .ownerUserId(ownerId) + .name(name) + .verified(true) + .build()).getId(); + } + + private Long createUser(User.Role role, String nickname) { + User user = userRepository.save(User.builder() + .phone("175" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + return user.getId(); + } + + private Long createJob(Long companyId, String title) { + return jobRepository.save(Job.builder() + .companyId(companyId) + .title(title) + .description("岗位描述") + .city("上海") + .employmentType("FULL_TIME") + .status("PUBLISHED") + .build()).getId(); + } + + private OnboardingChecklistItem saveChecklistItem( + String scopeType, + Long scopeId, + Long companyId, + String itemKey, + String title, + int sortOrder, + boolean active, + String idempotencyKey, + String requestHash) { + return checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType(scopeType) + .scopeId(scopeId) + .companyId(companyId) + .itemKey(itemKey) + .title(title) + .description(title + "说明") + .category("OTHER") + .sortOrder(sortOrder) + .required(false) + .active(active) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .build()); + } + + private JobReview saveReview( + Long jobId, + Long studentUserId, + int rating, + String tags, + String status, + String moderationStatus) { + return reviewRepository.save(JobReview.builder() + .jobId(jobId) + .studentUserId(studentUserId) + .rating(rating) + .content("评价内容") + .tags(tags) + .status(status) + .moderationStatus(moderationStatus) + .build()); + } + + private JobReviewDimensionScore dimensionScore(Long reviewId, String dimensionCode, int score) { + return JobReviewDimensionScore.builder() + .reviewId(reviewId) + .dimensionCode(dimensionCode) + .score(score) + .build(); + } + + public static class OnboardingSchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "onboarding_checklist_items", + "student_onboarding_progress", + "company_knowledge" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} \ No newline at end of file