diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 394e298..afb6fe9 100644 --- a/docs/openapi-rc.json +++ b/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": { "get": { "tags": [ @@ -18038,7 +18098,48 @@ } } }, - "ApiResponsePageResponseDataExportTaskResponse": { + "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": { "type": "object", "properties": { "code": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 23b251b..82f9f4e 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) 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'] @@ -64,6 +64,7 @@ required = [ '/auth/company/register', '/auth/identity/select', '/companies/me/onboarding', + '/companies/me/jobs', '/review-weight-templates', '/profile/me/review-weights', '/profile/me/privacy', @@ -675,6 +676,18 @@ for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath assert _forbidden not in _onboarding_text 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:唯一新增的企业简历下载接口 ──────────────────────────────── _download_path = '/company/candidates/{applicationId}/resume' 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}' 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()) 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'] diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java index 5bd872a..1113158 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java +++ b/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.CompanyUpdateRequest; 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 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 jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -89,6 +94,25 @@ public class CompanyController { // ── 公开端:企业岗位列表 ──────────────────────────────────── + @Operation(summary = "查询当前企业岗位管理列表", + security = @SecurityRequirement(name = "bearerAuth"), + description = "仅返回当前登录企业自己的岗位,可按状态、标题关键词和城市筛选;未认证企业可查看自己的岗位记录") + @GetMapping("/me/jobs") + public ResponseEntity>> 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 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 岗位") @GetMapping("/{companyId}/jobs") public ResponseEntity>> getCompanyJobs( diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index dcfd781..df87091 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/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.CompanyUpdateRequest; import com.mydreamjob.job.JobService; +import com.mydreamjob.job.dto.CompanyManagedJobItem; import com.mydreamjob.job.dto.JobSearchItem; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -167,4 +168,13 @@ public class CompanyService { size // size ); } + + /** 当前企业自身岗位管理列表;身份和企业归属均由服务端解析。 */ + public Page 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); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java new file mode 100644 index 0000000..7d201ea --- /dev/null +++ b/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 forCompany(long companyId, String status, String keyword, String city) { + return (root, query, cb) -> { + List 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)); + }; + } + +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 6abe1a9..494de89 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/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.JobStatusRequest; import com.mydreamjob.job.dto.JobUpdateRequest; +import com.mydreamjob.job.dto.CompanyManagedJobItem; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -140,6 +141,37 @@ public class JobService { .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); } + /** 查询当前企业自己的全部岗位状态;不调用发布门禁,也不产生写入副作用。 */ + @Transactional(readOnly = true) + public Page 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; + } + // ── 公开接口 ──────────────────────────────────────────────── /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java new file mode 100644 index 0000000..cce9e46 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 48e3008..4257541 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("既有 183 paths 保持不变").isEqualTo(183); - assertThat(opCount - moduleOperations).as("既有 221 operations 保持不变").isEqualTo(221); + assertThat(pathCount - modulePaths).as("既有 184 paths 保持不变").isEqualTo(184); + assertThat(opCount - moduleOperations).as("既有 222 operations 保持不变").isEqualTo(222); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java index 9184c7e..64294a0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java +++ b/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 contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; - assertThat(runtime.path("paths").size()).isEqualTo(183); checks++; - assertThat(operations(runtime)).hasSize(221); checks++; + assertThat(runtime.path("paths").size()).isEqualTo(197); checks++; + assertThat(operations(runtime)).hasSize(240); checks++; JsonNode pending=login("19900009602"); assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); 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( "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"))); - 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();} } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index 2d61e73..ac3fb32 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest { } } 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"), "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyJobManagementIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyJobManagementIntegrationTest.java new file mode 100644 index 0000000..97730a1 --- /dev/null +++ b/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 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) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/CompanyJobManagementMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/CompanyJobManagementMySqlTest.java new file mode 100644 index 0000000..6540ab6 --- /dev/null +++ b/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 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 userIds = new ArrayList<>(); + private final List 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) {} +} 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 cb6bec3..9beeb92 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/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")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(196); + assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(197); } private Set operations(JsonNode document) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java index 1253314..1ca8793 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java +++ b/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")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(196); + assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(197); } private Set operations(JsonNode document) {