Browse Source

feat(application): 补齐求职者投递列表岗位摘要并验证批量查询

fix/mysql57-module-migrations-20261009
Zhang Shun 2 weeks ago
parent
commit
5841e09190
  1. 9
      docs/frontend-integration.md
  2. 2
      docs/openapi-rc.json
  3. 6
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  4. 29
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  5. 145
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationSummaryIntegrationTest.java
  6. 40
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

9
docs/frontend-integration.md

@ -11,6 +11,15 @@
聊天实时连接、发送幂等、事件补拉和游标规则见 [聊天实时接口](chat-realtime-api.md)。 聊天实时连接、发送幂等、事件补拉和游标规则见 [聊天实时接口](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) ## 2. 后端启动(Windows)
先启动基础设施容器: 先启动基础设施容器:

2
docs/openapi-rc.json

@ -870,7 +870,7 @@
"投递" "投递"
], ],
"summary": "我的投递列表", "summary": "我的投递列表",
"description": "查看当前学生自己的投递记录,支持 status 筛选和分页", "description": "查看当前学生自己的投递记录,支持 status 筛选和分页;附当前岗位摘要 jobTitle/companyId/companyName/city/salaryMin/salaryMax/jobStatus/employmentType,薪资单位元/月",
"operationId": "listMyApplications", "operationId": "listMyApplications",
"parameters": [ "parameters": [
{ {

6
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") @GetMapping("/applications/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications( public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@ -97,9 +97,9 @@ public class ApplicationController {
HttpServletRequest request) { HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100; if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size); Page<Map<String, Object>> result = applicationService.listStudentApplicationViews(userId, status, page, size);
Map<String, Object> data = new HashMap<>(); Map<String, Object> 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("total", result.getTotalElements());
data.put("page", result.getNumber()); data.put("page", result.getNumber());
data.put("size", result.getSize()); data.put("size", result.getSize());

29
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -171,6 +171,35 @@ public class ApplicationService {
return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr); return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr);
} }
/** 仅补齐本人当前页的岗位摘要;批量投影查询,禁止逐条反查岗位或企业。 */
@Transactional(readOnly = true)
public Page<Map<String, Object>> listStudentApplicationViews(
Long studentUserId, String status, int page, int size) {
Page<Application> applications = listStudentApplications(studentUserId, status, page, size);
Map<Long, Object[]> summaries = new HashMap<>();
if (!applications.isEmpty()) {
List<Long> 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<String, Object> 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) { public Application getApplication(Long applicationId, Long studentUserId) {
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));

145
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<String, Object> 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());
}
}

40
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -45,6 +45,46 @@ class LxqModulesMySql57IntegrationTest {
@Autowired ObjectMapper mapper; @Autowired ObjectMapper mapper;
@Autowired com.mydreamjob.messaging.MessageRepository messages; @Autowired com.mydreamjob.messaging.MessageRepository messages;
@Autowired jakarta.persistence.EntityManagerFactory entityManagerFactory; @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() { @Test void recentWindowUnreadUsesOneBoundedQueryOnMysql57() {
String phone = "q57-" + java.util.UUID.randomUUID().toString().substring(0, 8); String phone = "q57-" + java.util.UUID.randomUUID().toString().substring(0, 8);

Loading…
Cancel
Save