diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index f56566e..e37ccc3 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -11,6 +11,15 @@ 聊天实时连接、发送幂等、事件补拉和游标规则见 [聊天实时接口](chat-realtime-api.md)。 +### 我的投递列表岗位摘要 + +`GET /applications/me` 保留原有分页包、筛选和投递字段,items 新增以下平铺字段: +`jobTitle`、`companyId`、`companyName`、`city`、`salaryMin`、`salaryMax`、`jobStatus`、`employmentType`。 +薪资为元/月,未填写时内部值为 null,响应可能按全局 non_null 配置省略该字段,不要当作 0 元;`status` 仍是投递状态,`jobStatus` 才是岗位状态。 +摘要是查询时的当前信息,不是投递时快照;关闭岗位仍保留在本人投递列表中。 +历史关联数据缺失时保留投递记录,摘要内部值为 null(响应可能省略字段),前端按缺失/null 显示占位。 +后台只对当前页的去重岗位 ID 做一次批量关联查询,空页不查摘要,不需要前端逐条请求岗位详情。 + ## 2. 后端启动(Windows) 先启动基础设施容器: diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 2979388..d8cb87f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -870,7 +870,7 @@ "投递" ], "summary": "我的投递列表", - "description": "查看当前学生自己的投递记录,支持 status 筛选和分页", + "description": "查看当前学生自己的投递记录,支持 status 筛选和分页;附当前岗位摘要 jobTitle/companyId/companyName/city/salaryMin/salaryMax/jobStatus/employmentType,薪资单位元/月", "operationId": "listMyApplications", "parameters": [ { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index 7c07fa3..deea673 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -88,7 +88,7 @@ public class ApplicationController { // ── 学生查看自己的投递 ──────────────────────────────────── - @Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页") + @Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页;附当前岗位摘要 jobTitle/companyId/companyName/city/salaryMin/salaryMax/jobStatus/employmentType,薪资单位元/月") @GetMapping("/applications/me") public ResponseEntity>> listMyApplications( @RequestParam(required = false) String status, @@ -97,9 +97,9 @@ public class ApplicationController { HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); if (size > 100) size = 100; - Page result = applicationService.listStudentApplications(userId, status, page, size); + Page> result = applicationService.listStudentApplicationViews(userId, status, page, size); Map data = new HashMap<>(); - data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList())); + data.put("items", result.getContent()); data.put("total", result.getTotalElements()); data.put("page", result.getNumber()); data.put("size", result.getSize()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 9ee862b..40d6ede 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -171,6 +171,35 @@ public class ApplicationService { return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr); } + /** 仅补齐本人当前页的岗位摘要;批量投影查询,禁止逐条反查岗位或企业。 */ + @Transactional(readOnly = true) + public Page> listStudentApplicationViews( + Long studentUserId, String status, int page, int size) { + Page applications = listStudentApplications(studentUserId, status, page, size); + Map summaries = new HashMap<>(); + if (!applications.isEmpty()) { + List jobIds = applications.stream().map(Application::getJobId).distinct().toList(); + em.createQuery(""" + select j.id, j.title, j.companyId, c.name, j.city, + j.salaryMin, j.salaryMax, j.status, j.employmentType + from Job j left join Company c on c.id = j.companyId + where j.id in :jobIds + """, Object[].class) + .setParameter("jobIds", jobIds).getResultList() + .forEach(row -> summaries.put((Long) row[0], row)); + } + return applications.map(application -> { + Map view = toView(application); + Object[] row = summaries.get(application.getJobId()); + String[] fields = {"jobTitle", "companyId", "companyName", "city", + "salaryMin", "salaryMax", "jobStatus", "employmentType"}; + for (int i = 0; i < fields.length; i++) { + view.put(fields[i], row == null ? null : row[i + 1]); + } + return view; + }); + } + public Application getApplication(Long applicationId, Long studentUserId) { Application app = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationSummaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationSummaryIntegrationTest.java new file mode 100644 index 0000000..ed084e2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationSummaryIntegrationTest.java @@ -0,0 +1,145 @@ +package com.mydreamjob.application; + +import com.mydreamjob.auth.User; +import com.mydreamjob.company.Company; +import com.mydreamjob.job.Job; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true") +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@Transactional +class ApplicationSummaryIntegrationTest { + @Autowired EntityManager em; + @Autowired EntityManagerFactory emf; + @Autowired ApplicationService service; + @Autowired MockMvc mvc; + @Autowired com.mydreamjob.auth.AuthSessionService sessions; + @Autowired java.time.Clock clock; + @MockBean RedisTemplate redis; + long studentId; + long otherStudentId; + + @BeforeEach + void fixture() { + User student = user("summary-student", User.Role.STUDENT); + User other = user("summary-other", User.Role.STUDENT); + User owner = user("summary-company", User.Role.COMPANY); + studentId = student.getId(); + otherStudentId = other.getId(); + Company company = Company.builder().ownerUserId(owner.getId()).name("摘要测试企业").verified(true).build(); + em.persist(company); + for (int i = 0; i < 55; i++) { + Job job = Job.builder().companyId(company.getId()).title("岗位" + i).description("测试") + .city("上海").employmentType("FULL_TIME").status(i == 0 ? "CLOSED" : "PUBLISHED") + .salaryMin(i == 0 ? null : 10000).salaryMax(i == 0 ? null : 20000).build(); + em.persist(job); + em.persist(Application.builder().jobId(job.getId()).studentUserId(studentId) + .status(i % 2 == 0 ? "SUBMITTED" : "WITHDRAWN").build()); + if (i == 0) em.persist(Application.builder().jobId(job.getId()).studentUserId(otherStudentId) + .status("SUBMITTED").build()); + } + em.flush(); + em.clear(); + } + + private User user(String name, User.Role role) { + User user = User.builder().phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) + .nickname(name).role(role).roles(Set.of(role)).active(true).verified(true).build(); + em.persist(user); + return user; + } + + @Test + void fixedQueryBudgetAndAllSummaryFieldsForOneTwentyAndFiftyRows() { + var stats = emf.unwrap(SessionFactory.class).getStatistics(); + for (int size : new int[]{1, 20, 50}) { + em.clear(); + stats.clear(); + var page = service.listStudentApplicationViews(studentId, null, 0, size); + long statements = stats.getPrepareStatementCount(); + assertEquals(3, statements, "当前页、总数、批量岗位摘要共三条 SQL,size=" + size); + assertEquals(55, page.getTotalElements()); + assertEquals(size, page.getNumberOfElements()); + for (var item : page) { + assertEquals(studentId, item.get("studentUserId")); + assertEquals("摘要测试企业", item.get("companyName")); + assertEquals("上海", item.get("city")); + assertEquals("FULL_TIME", item.get("employmentType")); + assertTrue(item.get("jobTitle").toString().startsWith("岗位")); + assertNotNull(item.get("companyId")); + assertTrue(item.containsKey("salaryMin")); + assertTrue(item.containsKey("salaryMax")); + assertNotNull(item.get("jobStatus")); + assertNotNull(item.get("status")); + assertNotNull(item.get("createdAt")); + } + } + } + + @Test + void statusFilterPaginationAndIsolationArePreserved() { + var page = service.listStudentApplicationViews(studentId, "SUBMITTED", 1, 20); + assertEquals(28, page.getTotalElements()); + assertEquals(2, page.getTotalPages()); + assertEquals(1, page.getNumber()); + assertEquals(8, page.getNumberOfElements()); + page.forEach(item -> assertEquals("SUBMITTED", item.get("status"))); + var other = service.listStudentApplicationViews(otherStudentId, null, 0, 20); + assertEquals(1, other.getTotalElements()); + var closed = other.getContent().get(0); + assertEquals("CLOSED", closed.get("jobStatus")); + assertEquals("SUBMITTED", closed.get("status")); + assertNull(closed.get("salaryMin")); + assertNull(closed.get("salaryMax")); + } + + @Test + void emptyPageSkipsSummarySql() { + var stats = emf.unwrap(SessionFactory.class).getStatistics(); + stats.clear(); + var page = service.listStudentApplicationViews(studentId, "HIRED", 0, 20); + assertTrue(page.isEmpty()); + assertEquals(0, page.getTotalElements()); + assertEquals(1, stats.getPrepareStatementCount()); + } + + @Test + void rejectsInvalidPagination() { + assertThrows(com.mydreamjob.common.exception.BusinessException.class, + () -> service.listStudentApplicationViews(studentId, null, -1, 20)); + assertThrows(com.mydreamjob.common.exception.BusinessException.class, + () -> service.listStudentApplicationViews(studentId, null, 0, 101)); + } + + @Test + void httpListReturnsSummaryAndUnchangedPageEnvelope() throws Exception { + String bearer = "Bearer " + sessions.createSession(em.find(User.class, studentId), clock.instant(), + com.mydreamjob.audit.AuditContext.builder().requestId("application-summary-test").build()).accessToken(); + mvc.perform(get("/applications/me").header("Authorization", bearer).param("size", "20")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.total").value(55)) + .andExpect(jsonPath("$.data.totalPages").value(3)) + .andExpect(jsonPath("$.data.items.length()").value(20)) + .andExpect(jsonPath("$.data.items[0].companyName").value("摘要测试企业")) + .andExpect(jsonPath("$.data.items[0].jobTitle").isString()); + mvc.perform(get("/applications/me")).andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index 0adb306..3168196 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java @@ -45,6 +45,46 @@ class LxqModulesMySql57IntegrationTest { @Autowired ObjectMapper mapper; @Autowired com.mydreamjob.messaging.MessageRepository messages; @Autowired jakarta.persistence.EntityManagerFactory entityManagerFactory; + @Autowired com.mydreamjob.application.ApplicationService applications; + + @Test void studentApplicationSummaryUsesOneBatchQueryOnMysql57() { + String key = "summary57-" + java.util.UUID.randomUUID().toString().substring(0, 8); + jdbc.update("INSERT INTO users (phone,role,is_active,is_verified) VALUES (?,'STUDENT',1,1)", key); + Long user = jdbc.queryForObject("SELECT id FROM users WHERE phone=?", Long.class, key); + Long company = null; + Long job = null; + try { + jdbc.update("INSERT INTO companies (owner_user_id,name,verified,created_at,updated_at) " + + "VALUES (?,?,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", user, key); + company = jdbc.queryForObject("SELECT id FROM companies WHERE owner_user_id=?", Long.class, user); + jdbc.update("INSERT INTO jobs (company_id,title,description,city,employment_type,status,salary_min,salary_max,created_at,updated_at) " + + "VALUES (?,?,'fixture','上海','FULL_TIME','CLOSED',12000,24000,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", company,key); + job = jdbc.queryForObject("SELECT id FROM jobs WHERE company_id=?", Long.class, company); + jdbc.update("INSERT INTO applications (job_id,student_user_id,status,created_at,updated_at) " + + "VALUES (?,?,'SUBMITTED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", job,user); + var stats = entityManagerFactory.unwrap(org.hibernate.SessionFactory.class).getStatistics(); + boolean wasEnabled = stats.isStatisticsEnabled(); + stats.setStatisticsEnabled(true); + try { + long before = stats.getPrepareStatementCount(); + var page = applications.listStudentApplicationViews(user, null, 0, 1); + assertThat(stats.getPrepareStatementCount() - before).isEqualTo(3); + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().get(0)).containsEntry("jobTitle",key) + .containsEntry("companyName",key).containsEntry("city","上海") + .containsEntry("salaryMin",12000).containsEntry("salaryMax",24000) + .containsEntry("jobStatus","CLOSED").containsEntry("status","SUBMITTED") + .containsEntry("employmentType","FULL_TIME"); + } finally { stats.setStatisticsEnabled(wasEnabled); } + } finally { + if (job != null) { + jdbc.update("DELETE FROM applications WHERE job_id=?",job); + jdbc.update("DELETE FROM jobs WHERE id=?",job); + } + if (company != null) jdbc.update("DELETE FROM companies WHERE id=?",company); + jdbc.update("DELETE FROM users WHERE id=?",user); + } + } @Test void recentWindowUnreadUsesOneBoundedQueryOnMysql57() { String phone = "q57-" + java.util.UUID.randomUUID().toString().substring(0, 8);