Browse Source

feat(company): add own job management list

release/identity-selection-20260929
Zhang Shun 2 weeks ago
parent
commit
fa6555d9e5
  1. 101
      docs/openapi-rc.json
  2. 19
      docs/verify_openapi.py
  3. 24
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  4. 10
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  5. 30
      mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java
  6. 32
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  7. 30
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java
  8. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  9. 8
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java
  10. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  11. 235
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyJobManagementIntegrationTest.java
  12. 189
      mydreamjob-server/src/test/java/com/mydreamjob/integration/CompanyJobManagementMySqlTest.java
  13. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  14. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

101
docs/openapi-rc.json

@ -1746,6 +1746,66 @@
} }
} }
}, },
"/companies/me/jobs": {
"get": {
"tags": [
"企业"
],
"summary": "查询当前企业岗位管理列表",
"description": "仅返回当前登录企业自己的岗位,可按状态、标题关键词和城市筛选;未认证企业可查看自己的岗位记录",
"operationId": "getMyJobs",
"parameters": [
{
"name": "status",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": ["DRAFT", "PUBLISHED", "CLOSED"]
}
},
{
"name": "keyword",
"in": "query",
"required": false,
"schema": {"type": "string"}
},
{
"name": "city",
"in": "query",
"required": false,
"schema": {"type": "string"}
},
{
"name": "page",
"in": "query",
"required": false,
"schema": {"type": "integer", "format": "int32", "default": 0}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {"type": "integer", "format": "int32", "default": 20}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponsePageResponseCompanyManagedJobItem"
}
}
}
}
},
"security": [
{"bearerAuth": []}
]
}
},
"/companies/{companyId}/jobs": { "/companies/{companyId}/jobs": {
"get": { "get": {
"tags": [ "tags": [
@ -18038,6 +18098,47 @@
} }
} }
}, },
"ApiResponsePageResponseCompanyManagedJobItem": {
"type": "object",
"properties": {
"code": {"type": "integer", "format": "int32"},
"message": {"type": "string"},
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"$ref": "#/components/schemas/CompanyManagedJobItem"}
},
"total": {"type": "integer", "format": "int64"},
"page": {"type": "integer", "format": "int32"},
"size": {"type": "integer", "format": "int32"},
"totalPages": {"type": "integer", "format": "int32"}
}
},
"requestId": {"type": "string"},
"timestamp": {"type": "string", "format": "date-time"}
}
},
"CompanyManagedJobItem": {
"type": "object",
"description": "企业自身岗位管理安全摘要",
"properties": {
"id": {"type": "integer", "format": "int64", "description": "岗位 ID"},
"companyId": {"type": "integer", "format": "int64", "description": "所属企业 ID"},
"title": {"type": "string", "description": "岗位名称"},
"description": {"type": "string", "description": "岗位描述"},
"requirements": {"type": "string", "description": "任职要求"},
"city": {"type": "string", "description": "工作城市"},
"employmentType": {"type": "string", "description": "用工类型"},
"salaryMin": {"type": "integer", "format": "int32", "description": "最低月薪,单位元"},
"salaryMax": {"type": "integer", "format": "int32", "description": "最高月薪,单位元"},
"status": {"type": "string", "description": "岗位状态:DRAFT、PUBLISHED、CLOSED"},
"publishedAt": {"type": "string", "format": "date-time", "description": "发布时间"},
"createdAt": {"type": "string", "format": "date-time", "description": "创建时间"},
"updatedAt": {"type": "string", "format": "date-time", "description": "更新时间"}
}
},
"ApiResponsePageResponseDataExportTaskResponse": { "ApiResponsePageResponseDataExportTaskResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

19
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 196, f"Expected 196 paths (183 baseline + 13 module paths), got {len(d['paths'])}" assert len(d['paths']) == 197, f"Expected 197 paths (184 baseline + 13 module paths), got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -64,6 +64,7 @@ required = [
'/auth/company/register', '/auth/company/register',
'/auth/identity/select', '/auth/identity/select',
'/companies/me/onboarding', '/companies/me/onboarding',
'/companies/me/jobs',
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
'/profile/me/privacy', '/profile/me/privacy',
@ -675,6 +676,18 @@ for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath
assert _forbidden not in _onboarding_text assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated') print(' Phase A company registration/onboarding paths and redacted schemas validated')
# 企业岗位管理:私有全状态查询与公开岗位列表保持不同的安全边界。
assert set(paths['/companies/me/jobs']) == {'get'}
_company_jobs = paths['/companies/me/jobs']['get']
assert _company_jobs['operationId'] == 'getMyJobs'
assert {p['name'] for p in _company_jobs['parameters']} == {'status', 'keyword', 'city', 'page', 'size'}
assert _company_jobs['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponsePageResponseCompanyManagedJobItem'
assert 'bearerAuth' in json.dumps(_company_jobs.get('security', []))
_company_job_properties = resolve_schema(d['components']['schemas']['CompanyManagedJobItem'])['properties']
assert {'id', 'companyId', 'title', 'city', 'salaryMin', 'salaryMax', 'status'} <= set(_company_job_properties)
print(' 企业自身全状态岗位列表路径、认证要求和强类型响应已验证')
# ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume' _download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint' assert _download_path in paths, 'Missing company resume download endpoint'
@ -703,10 +716,10 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted') print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 # 统计 operation 总数并校验当前基线 222 个既有 operation 加 18 个模块 operation。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 239, f"Expected 239 operations (221 baseline + 18 module operations), got {operations}" assert operations == 240, f"Expected 240 operations (222 baseline + 18 module operations), got {operations}"
# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。 # 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。
_select = paths['/auth/identity/select']['post'] _select = paths['/auth/identity/select']['post']

24
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -4,8 +4,13 @@ import com.mydreamjob.company.dto.CompanyCreateRequest;
import com.mydreamjob.company.dto.CompanyPublicView; import com.mydreamjob.company.dto.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest; import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -89,6 +94,25 @@ public class CompanyController {
// ── 公开端:企业岗位列表 ──────────────────────────────────── // ── 公开端:企业岗位列表 ────────────────────────────────────
@Operation(summary = "查询当前企业岗位管理列表",
security = @SecurityRequirement(name = "bearerAuth"),
description = "仅返回当前登录企业自己的岗位,可按状态、标题关键词和城市筛选;未认证企业可查看自己的岗位记录")
@GetMapping("/me/jobs")
public ResponseEntity<ApiResponse<PageResponse<CompanyManagedJobItem>>> getMyJobs(
@Parameter(description = "岗位状态过滤", schema = @Schema(allowableValues = {"DRAFT", "PUBLISHED", "CLOSED"}))
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String city,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<CompanyManagedJobItem> result = companyService.getMyJobs(
currentUserAccessor.requireUserId(), status, keyword, city, page, size);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(result.getContent(),
result.getTotalElements(), result.getNumber(), result.getSize()),
requestIdAccessor.get(request)));
}
@Operation(summary = "查询企业公开岗位列表", description = "匿名可访问,仅返回 PUBLISHED 岗位") @Operation(summary = "查询企业公开岗位列表", description = "匿名可访问,仅返回 PUBLISHED 岗位")
@GetMapping("/{companyId}/jobs") @GetMapping("/{companyId}/jobs")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs( public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs(

10
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -8,6 +8,7 @@ import com.mydreamjob.company.dto.CompanyCreateRequest;
import com.mydreamjob.company.dto.CompanyPublicView; import com.mydreamjob.company.dto.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest; import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -167,4 +168,13 @@ public class CompanyService {
size // size size // size
); );
} }
/** 当前企业自身岗位管理列表;身份和企业归属均由服务端解析。 */
public Page<CompanyManagedJobItem> getMyJobs(Long userId, String status,
String keyword, String city,
int page, int size) {
Company company = findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
return jobService.listCompanyJobs(company.getId(), status, keyword, city, page, size);
}
} }

30
mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java

@ -0,0 +1,30 @@
package com.mydreamjob.job;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.data.jpa.domain.Specification;
/** 企业私有岗位列表查询;租户条件始终是必选条件。 */
public final class CompanyJobManagementSpecification {
private CompanyJobManagementSpecification() {}
public static Specification<Job> forCompany(long companyId, String status, String keyword, String city) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("companyId"), companyId));
if (status != null) predicates.add(cb.equal(root.get("status"), status));
if (keyword != null) {
// LOCATE treats % and _ literally and is supported by both H2 and MySQL.
predicates.add(cb.greaterThan(cb.locate(
cb.lower(root.get("title")), keyword.toLowerCase(Locale.ROOT)), 0));
}
if (city != null) {
predicates.add(cb.equal(cb.lower(root.get("city")), city.toLowerCase(Locale.ROOT)));
}
return cb.and(predicates.toArray(Predicate[]::new));
};
}
}

32
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -9,6 +9,7 @@ import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest; import com.mydreamjob.job.dto.JobUpdateRequest;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery; import jakarta.persistence.TypedQuery;
@ -140,6 +141,37 @@ public class JobService {
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
} }
/** 查询当前企业自己的全部岗位状态;不调用发布门禁,也不产生写入副作用。 */
@Transactional(readOnly = true)
public Page<CompanyManagedJobItem> listCompanyJobs(Long companyId, String status,
String keyword, String city,
int page, int size) {
if (companyId == null || companyId <= 0 || page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (status != null) {
try {
JobStatus.valueOf(status);
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
String normalizedKeyword = normalizeFilter(keyword, 120);
String normalizedCity = normalizeFilter(city, 80);
Pageable pageable = PageRequest.of(page, size, Sort.by(
Sort.Order.desc("createdAt"), Sort.Order.desc("id")));
return jobRepository.findAll(CompanyJobManagementSpecification.forCompany(
companyId, status, normalizedKeyword, normalizedCity), pageable)
.map(CompanyManagedJobItem::from);
}
private String normalizeFilter(String value, int maxLength) {
if (value == null || value.isBlank()) return null;
String normalized = value.trim();
if (normalized.length() > maxLength) throw new BusinessException(ErrorCode.BAD_REQUEST);
return normalized;
}
// ── 公开接口 ──────────────────────────────────────────────── // ── 公开接口 ────────────────────────────────────────────────
/** /**

30
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java

@ -0,0 +1,30 @@
package com.mydreamjob.job.dto;
import com.mydreamjob.job.Job;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/** 企业自身岗位管理列表项,不包含招聘统计、候选人或内部审核数据。 */
@Schema(name = "CompanyManagedJobItem", description = "企业自身岗位管理安全摘要")
public record CompanyManagedJobItem(
@Schema(description = "岗位 ID") Long id,
@Schema(description = "所属企业 ID") Long companyId,
@Schema(description = "岗位名称") String title,
@Schema(description = "岗位描述") String description,
@Schema(description = "任职要求") String requirements,
@Schema(description = "工作城市") String city,
@Schema(description = "用工类型") String employmentType,
@Schema(description = "最低月薪,单位元") Integer salaryMin,
@Schema(description = "最高月薪,单位元") Integer salaryMax,
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") String status,
@Schema(description = "发布时间") LocalDateTime publishedAt,
@Schema(description = "创建时间") LocalDateTime createdAt,
@Schema(description = "更新时间") LocalDateTime updatedAt) {
public static CompanyManagedJobItem from(Job job) {
return new CompanyManagedJobItem(job.getId(), job.getCompanyId(), job.getTitle(),
job.getDescription(), job.getRequirements(), job.getCity(), job.getEmploymentType(),
job.getSalaryMin(), job.getSalaryMax(), job.getStatus(), job.getPublishedAt(),
job.getCreatedAt(), job.getUpdatedAt());
}
}

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest {
} }
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("既有 183 paths 保持不变").isEqualTo(183); assertThat(pathCount - modulePaths).as("既有 184 paths 保持不变").isEqualTo(184);
assertThat(opCount - moduleOperations).as("既有 221 operations 保持不变").isEqualTo(221); assertThat(opCount - moduleOperations).as("既有 222 operations 保持不变").isEqualTo(222);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

8
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java

@ -52,8 +52,8 @@ public final class UnifiedIdentityJarAcceptanceProbe {
JsonNode runtime=call("GET","/v3/api-docs",null,null,200); JsonNode runtime=call("GET","/v3/api-docs",null,null,200);
JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++;
assertThat(runtime.path("paths").size()).isEqualTo(183); checks++; assertThat(runtime.path("paths").size()).isEqualTo(197); checks++;
assertThat(operations(runtime)).hasSize(221); checks++; assertThat(operations(runtime)).hasSize(240); checks++;
JsonNode pending=login("19900009602"); JsonNode pending=login("19900009602");
assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++;
assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
@ -125,9 +125,9 @@ public final class UnifiedIdentityJarAcceptanceProbe {
} }
Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of(
"checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", "checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3",
"paths",183,"operations",221,"smsDelivery","mock sender; not real provider", "paths",197,"operations",240,"smsDelivery","mock sender; not real provider",
"fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); "fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle")));
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 183 paths/221 operations; fixture residual 0"); System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 197 paths/240 operations; fixture residual 0");
} finally {stop();} } finally {stop();}
} }
} }

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
} }
} }
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁范围不减少").isEqualTo(196); assertThat(denied - moduleDenied).as("既有身份门禁范围不减少").isEqualTo(197);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

235
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyJobManagementIntegrationTest.java

@ -0,0 +1,235 @@
package com.mydreamjob.company;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 企业岗位管理列表:租户隔离、状态/关键词/城市过滤、分页及只读行为。 */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:company_job_management;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class CompanyJobManagementIntegrationTest {
private static final AtomicInteger SEQUENCE = new AtomicInteger();
@MockBean private RedisTemplate<String, Object> redis;
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@Autowired private UserRepository users;
@Autowired private CompanyRepository companies;
@Autowired private JobRepository jobs;
@Autowired private AuthSessionService sessions;
@Autowired private ObjectMapper mapper;
private long companyId;
private long otherCompanyId;
private String companyToken;
private String otherCompanyToken;
private String studentToken;
private String adminToken;
private long draftId;
private long publishedId;
private long closedId;
@BeforeEach
void setUp() {
Identity owner = identity(User.Role.COMPANY);
Identity otherOwner = identity(User.Role.COMPANY);
Identity student = identity(User.Role.STUDENT);
Identity admin = identity(User.Role.ADMIN);
companyToken = owner.token();
otherCompanyToken = otherOwner.token();
studentToken = student.token();
adminToken = admin.token();
companyId = companies.saveAndFlush(Company.builder().ownerUserId(owner.id())
.name("岗位列表测试企业A").verified(false).build()).getId();
otherCompanyId = companies.saveAndFlush(Company.builder().ownerUserId(otherOwner.id())
.name("岗位列表测试企业B").verified(true).build()).getId();
draftId = job(companyId, "Java%工程师", "南京", "DRAFT");
publishedId = job(companyId, "Python工程师", "北京", "PUBLISHED");
closedId = job(companyId, "Java工程师", "南京", "CLOSED");
job(otherCompanyId, "Java外部岗位", "南京", "PUBLISHED");
jdbc.update("update jobs set created_at=? where company_id in (?, ?)",
LocalDateTime.of(2026, 10, 10, 12, 0), companyId, otherCompanyId);
}
@AfterEach
void tearDown() {
jdbc.update("delete from jobs where company_id in (?, ?)", companyId, otherCompanyId);
jdbc.update("delete from companies where id in (?, ?)", companyId, otherCompanyId);
jdbc.update("delete from auth_tokens where user_id in (select id from users where phone like '1990099%')");
jdbc.update("delete from user_roles where user_id in (select id from users where phone like '1990099%')");
jdbc.update("delete from users where phone like '1990099%'");
}
@Test
void companyCanListAllOwnStatusesWithTypedPageAndStablePagination() throws Exception {
String before = jobSnapshot();
long auditsBefore = count("audit_logs");
long notificationsBefore = count("notifications");
long messagesBefore = count("messages");
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(companyToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(3))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.totalPages").value(1))
.andExpect(jsonPath("$.data.items.length()").value(3));
JsonNode first = mapper.readTree(mvc.perform(get("/companies/me/jobs?page=0&size=1")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data");
JsonNode second = mapper.readTree(mvc.perform(get("/companies/me/jobs?page=1&size=1")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data");
assertThat(first.path("total").asLong()).isEqualTo(3);
assertThat(second.path("total").asLong()).isEqualTo(3);
assertThat(first.path("items").get(0).path("id").asLong())
.isNotEqualTo(second.path("items").get(0).path("id").asLong());
assertThat(first.path("items").get(0).path("id").asLong())
.isEqualTo(Math.max(draftId, Math.max(publishedId, closedId)));
assertThat(jobSnapshot()).isEqualTo(before);
assertThat(count("audit_logs")).isEqualTo(auditsBefore);
assertThat(count("notifications")).isEqualTo(notificationsBefore);
assertThat(count("messages")).isEqualTo(messagesBefore);
}
@Test
void statusKeywordCityFiltersAreAppliedTogetherAndWildcardIsLiteral() throws Exception {
mvc.perform(get("/companies/me/jobs").param("status", "DRAFT")
.param("keyword", "%").param("city", "南京")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(draftId))
.andExpect(jsonPath("$.data.items[0].status").value("DRAFT"))
.andExpect(jsonPath("$.data.items[0].salaryMin").value(12000));
mvc.perform(get("/companies/me/jobs?status=REJECTED")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isBadRequest());
}
@Test
void tenantScopeAppliesToContentAndCountAndAnonymousOrNonCompanyRemainDenied() throws Exception {
mvc.perform(get("/companies/me/jobs?status=PUBLISHED&companyId=" + otherCompanyId)
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(publishedId))
.andExpect(jsonPath("$.data.items[0].companyId").value(companyId));
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(otherCompanyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(1));
mvc.perform(get("/companies/me/jobs")).andExpect(status().isUnauthorized());
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(studentToken)))
.andExpect(status().isForbidden());
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(adminToken)))
.andExpect(status().isForbidden());
}
@Test
void rejectsInvalidPagingAndDoesNotExposeAnotherTenant() throws Exception {
mvc.perform(get("/companies/me/jobs?page=-1").header("Authorization", bearer(companyToken)))
.andExpect(status().isBadRequest());
mvc.perform(get("/companies/me/jobs?size=101").header("Authorization", bearer(companyToken)))
.andExpect(status().isBadRequest());
mvc.perform(get("/companies/me/jobs?size=0").header("Authorization", bearer(companyToken)))
.andExpect(status().isBadRequest());
String body = mvc.perform(get("/companies/me/jobs?size=1&page=99")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items.length()").value(0))
.andExpect(jsonPath("$.data.total").value(3)).andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("Java外部岗位").doesNotContain("1990099");
}
@Test
void closedJobsRemainClosedAndPublicListDoesNotExposeDraftOrClosed() throws Exception {
mvc.perform(get("/companies/me/jobs?status=CLOSED").header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(closedId));
mvc.perform(get("/jobs?companyId=" + companyId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[?(@.status == 'DRAFT')]").doesNotExist())
.andExpect(jsonPath("$.data.items[?(@.status == 'CLOSED')]").doesNotExist());
}
@Test
void openApiDocumentsThePrivateManagementRouteAndTypedResponse() throws Exception {
JsonNode openApi = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode operation = openApi.path("paths").path("/companies/me/jobs").path("get");
assertThat(operation.path("operationId").asText()).isEqualTo("getMyJobs");
assertThat(operation.path("security").toString()).contains("bearerAuth");
assertThat(operation.path("parameters").toString()).contains("status", "keyword", "city", "page", "size");
assertThat(operation.path("responses").path("200").path("content").path("*/*")
.path("schema").path("$ref").asText()).contains("ApiResponsePageResponseCompanyManagedJobItem");
JsonNode itemProperties = openApi.path("components").path("schemas")
.path("CompanyManagedJobItem").path("properties");
assertThat(itemProperties.has("status")).isTrue();
assertThat(itemProperties.has("salaryMin")).isTrue();
assertThat(itemProperties.has("salaryMax")).isTrue();
assertThat(itemProperties.has("companyId")).isTrue();
}
private Identity identity(User.Role role) {
int sequence = SEQUENCE.incrementAndGet();
User user = users.saveAndFlush(User.builder()
.phone("1990099" + String.format("%04d", sequence))
.role(role).roles(new HashSet<>(Set.of(role))).active(true).verified(true).build());
var token = sessions.createSession(user, Instant.now(), new AuditContext()).accessToken();
return new Identity(user.getId(), token);
}
private long job(long ownerCompanyId, String title, String city, String status) {
return jobs.saveAndFlush(Job.builder().companyId(ownerCompanyId).title(title)
.description("岗位描述").requirements("岗位要求").city(city).employmentType("FULL_TIME")
.salaryMin(12000).salaryMax(18000).status(status).build()).getId();
}
private String jobSnapshot() {
return jdbc.query("select id,company_id,title,description,requirements,city,employment_type,salary_min,salary_max,status,published_at,created_at,updated_at from jobs order by id",
rs -> {
StringBuilder snapshot = new StringBuilder();
while (rs.next()) snapshot.append(rs.getString(1)).append('|').append(rs.getString(2))
.append('|').append(rs.getString(3)).append('|').append(rs.getString(4))
.append('|').append(rs.getString(5)).append('|').append(rs.getString(6))
.append('|').append(rs.getString(7)).append('|').append(rs.getString(8))
.append('|').append(rs.getString(9)).append('|').append(rs.getString(10))
.append('|').append(rs.getString(11)).append('|').append(rs.getString(12))
.append('|').append(rs.getString(13)).append('\n');
return snapshot.toString();
});
}
private long count(String table) {
return jdbc.queryForObject("select count(*) from " + table, Long.class);
}
private String bearer(String token) { return "Bearer " + token; }
private record Identity(long id, String token) {}
}

189
mydreamjob-server/src/test/java/com/mydreamjob/integration/CompanyJobManagementMySqlTest.java

@ -0,0 +1,189 @@
package com.mydreamjob.integration;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.AuthSessionService;
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 java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 企业私有岗位管理接口在真实 MySQL 上的租户、分页、筛选和只读验收。 */
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("mysql-it")
class CompanyJobManagementMySqlTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("company_job_management_test")
.withUsername("company_job_test")
.withPassword("company-job-test-only");
@DynamicPropertySource
static void database(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
private static final AtomicInteger SEQUENCE = new AtomicInteger();
@MockBean private RedisTemplate<String, Object> redis;
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@Autowired private UserRepository users;
@Autowired private CompanyRepository companies;
@Autowired private JobRepository jobs;
@Autowired private AuthSessionService sessions;
private final List<Long> userIds = new ArrayList<>();
private final List<Long> companyIds = new ArrayList<>();
private long companyId;
private long otherCompanyId;
private long draftId;
private long publishedId;
private long closedId;
private String companyToken;
private String studentToken;
@BeforeEach
void fixtures() {
assertThat(jdbc.queryForObject("select version()", String.class)).startsWith("8.");
Identity company = identity(User.Role.COMPANY);
Identity otherCompany = identity(User.Role.COMPANY);
Identity student = identity(User.Role.STUDENT);
companyToken = company.token();
studentToken = student.token();
companyId = company(company.userId(), false);
otherCompanyId = company(otherCompany.userId(), true);
draftId = job(companyId, "Java%工程师", "南京", "DRAFT");
publishedId = job(companyId, "Python工程师", "北京", "PUBLISHED");
closedId = job(companyId, "Java工程师", "南京", "CLOSED");
job(otherCompanyId, "Java其他企业岗位", "南京", "PUBLISHED");
jdbc.update("update jobs set created_at=? where company_id in (?, ?)",
Timestamp.valueOf(LocalDateTime.of(2026, 10, 10, 12, 0)), companyId, otherCompanyId);
}
@AfterEach
void cleanup() {
for (long id : companyIds) jdbc.update("delete from jobs where company_id=?", id);
for (long id : companyIds) {
jdbc.update("delete from company_verifications where company_id=?", id);
jdbc.update("delete from companies where id=?", id);
}
for (long id : userIds) jdbc.update("delete from auth_tokens where user_id=?", id);
for (long id : userIds) jdbc.update("delete from user_roles where user_id=?", id);
for (long id : userIds) jdbc.update("delete from users where id=?", id);
}
@Test
void realMysqlListsAllOwnStatusesAndFiltersWithoutWritesOrCrossTenantLeakage() throws Exception {
String before = snapshot(companyId);
long auditsBefore = count("audit_logs");
long notificationsBefore = count("notifications");
long messagesBefore = count("messages");
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(3))
.andExpect(jsonPath("$.data.items.length()").value(3));
mvc.perform(get("/companies/me/jobs").param("status", "DRAFT")
.param("keyword", "%").param("city", "南京")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(draftId));
mvc.perform(get("/companies/me/jobs").param("status", "CLOSED")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items[0].id").value(closedId));
mvc.perform(get("/companies/me/jobs").param("status", "PUBLISHED")
.param("companyId", Long.toString(otherCompanyId))
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(publishedId))
.andExpect(jsonPath("$.data.items[0].companyId").value(companyId));
mvc.perform(get("/companies/me/jobs").param("page", "0").param("size", "1")
.header("Authorization", bearer(companyToken)))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items.length()").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(Math.max(draftId, Math.max(publishedId, closedId))));
mvc.perform(get("/companies/me/jobs").header("Authorization", bearer(studentToken)))
.andExpect(status().isForbidden());
mvc.perform(get("/companies/me/jobs")).andExpect(status().isUnauthorized());
mvc.perform(get("/companies/" + companyId + "/jobs"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[?(@.id == " + draftId + ")]").doesNotExist())
.andExpect(jsonPath("$.data.items[?(@.id == " + closedId + ")]").doesNotExist());
assertThat(snapshot(companyId)).isEqualTo(before);
assertThat(count("audit_logs")).isEqualTo(auditsBefore);
assertThat(count("notifications")).isEqualTo(notificationsBefore);
assertThat(count("messages")).isEqualTo(messagesBefore);
}
private Identity identity(User.Role role) {
int sequence = SEQUENCE.incrementAndGet();
User user = users.saveAndFlush(User.builder()
.phone("1990098" + String.format("%04d", sequence))
.role(role).roles(new HashSet<>(Set.of(role))).active(true).verified(true).build());
userIds.add(user.getId());
String token = sessions.createSession(user, Instant.now(), new AuditContext()).accessToken();
return new Identity(user.getId(), token);
}
private long company(long ownerUserId, boolean verified) {
Company value = companies.saveAndFlush(Company.builder().ownerUserId(ownerUserId)
.name("临时岗位管理企业").verified(verified).build());
companyIds.add(value.getId());
return value.getId();
}
private long job(long ownerCompanyId, String title, String city, String status) {
return jobs.saveAndFlush(Job.builder().companyId(ownerCompanyId).title(title)
.description("临时岗位描述").requirements("临时岗位要求").city(city)
.employmentType("FULL_TIME").salaryMin(12000).salaryMax(18000).status(status).build()).getId();
}
private long count(String table) {
return jdbc.queryForObject("select count(*) from " + table, Long.class);
}
private String snapshot(long ownerCompanyId) {
return jdbc.query("select id,company_id,title,description,requirements,city,employment_type,salary_min,salary_max,status,published_at,created_at,updated_at from jobs where company_id=? order by id",
rs -> {
StringBuilder result = new StringBuilder();
while (rs.next()) for (int column = 1; column <= 13; column++)
result.append(rs.getString(column)).append(column == 13 ? '\n' : '|');
return result.toString();
}, ownerCompanyId);
}
private static String bearer(String token) { return "Bearer " + token; }
private record Identity(long userId, String token) {}
}

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

@ -58,8 +58,8 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(196); assertThat(runtime.path("paths").size()).isEqualTo(197);
} }
private Set<String> operations(JsonNode document) { private Set<String> operations(JsonNode document) {

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(196); assertThat(runtime.path("paths").size()).isEqualTo(197);
} }
private Set<String> operations(JsonNode document) { private Set<String> operations(JsonNode document) {

Loading…
Cancel
Save