diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index cbbdbef..2e2703f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -33,7 +33,7 @@ }, { "name": "双向匹配沟通", - "description": "匹配成功后创建或获取直聊会话(幂等)" + "description": "企业接受投递后创建或获取直聊会话(幂等),兼容历史匹配" }, { "name": "企业认证", @@ -211,7 +211,7 @@ "消息" ], "summary": "发送固定版本简历卡片", - "description": "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值", + "description": "仅投递本人,双方为会话成员且企业已接受投递;兼容历史 MATCHED/HIRED。拒绝/撤回、屏蔽、企业认证和简历隐私规则仍生效。clientMessageId 必填,重试复用原值", "operationId": "send", "parameters": [ { @@ -1347,8 +1347,8 @@ "tags": [ "双向匹配沟通" ], - "summary": "创建/获取匹配会话", - "description": "仅 MATCHED 双方可调用;重复调用返回同一会话", + "summary": "创建/获取投递沟通会话", + "description": "仅投递双方;企业接受后即可沟通,无需双向评分达标;历史 MATCHED 或 HIRED 保持兼容。已拒绝/撤回投递返回409/8304,未接受同样返回409/8304;重复调用返回同一会话。企业认证、归属、屏蔽校验保留。GET /applications/{id}/match 的 communicationAllowed 为沟通资格,不应从匹配 status=CLOSED 推断禁止沟通。", "operationId": "createOrGetConversation", "parameters": [ { @@ -1424,7 +1424,7 @@ "双向匹配" ], "summary": "查询投递匹配状态", - "description": "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID", + "description": "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID。附 companyAcceptedAt(明确接受时间,可null)和 communicationAllowed(沟通资格);接受后不要求双向评分达标。匹配 status=CLOSED 可能由 HIRED 引起,不代表 communicationAllowed=false;最终仍校验会话归属、企业认证与屏蔽。", "operationId": "getMatch", "parameters": [ { @@ -12251,6 +12251,79 @@ } } } + }, + "/applications/{id}/accept": { + "post": { + "tags": [ + "投递" + ], + "summary": "企业接受投递并解锁沟通", + "description": "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。", + "operationId": "accept", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAcceptanceView" + } + } + } + }, + "401": { + "description": "未登录或会话不可用", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAcceptanceView" + } + } + } + }, + "403": { + "description": "角色、企业认证或投递归属不符", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAcceptanceView" + } + } + } + }, + "404": { + "description": "投递/企业不存在", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAcceptanceView" + } + } + } + }, + "409": { + "description": "投递已拒绝或撤回,不能接受", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAcceptanceView" + } + } + } + } + } + } } }, "components": { @@ -21844,7 +21917,7 @@ "description": "经度,JSON number,最多 6 位小数;非 UNSET 时必填", "maximum": 180, "minimum": -180, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "latitude": { "type": [ @@ -21854,7 +21927,7 @@ "description": "纬度,JSON number,最多 6 位小数;非 UNSET 时必填", "maximum": 90, "minimum": -90, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "coordinateSystem": { "type": [ @@ -21915,7 +21988,7 @@ "description": "经度,最多 6 位小数", "maximum": 180, "minimum": -180, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "latitude": { "type": [ @@ -21925,7 +21998,7 @@ "description": "纬度,最多 6 位小数", "maximum": 90, "minimum": -90, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "coordinateSystem": { "type": [ @@ -22021,13 +22094,13 @@ "type": "number", "maximum": 180, "minimum": -180, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "latitude": { "type": "number", "maximum": 90, "minimum": -90, - "multipleOf": 0.000001 + "multipleOf": 1e-06 }, "coordinateSystem": { "type": "string", @@ -22053,6 +22126,2641 @@ "poiName", "provider" ] + }, + "CompanyKnowledgeUpdateRequest": { + "type": "object", + "properties": { + "culture": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + }, + "workPolicies": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "onboardingGuide": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ApiResponseCompanyKnowledgeView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyKnowledgeView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanyKnowledgeView": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "culture": { + "type": "string" + }, + "workPolicies": { + "type": "string" + }, + "onboardingGuide": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "CompanyChecklistItemUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "description": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "category": { + "type": "string", + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" + }, + "sortOrder": { + "type": "integer", + "format": "int32", + "maximum": 9999, + "minimum": 0 + }, + "required": { + "type": "boolean" + }, + "active": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "required": [ + "active", + "category", + "required", + "sortOrder", + "title", + "version" + ] + }, + "ApiResponseCompanyChecklistItemView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyChecklistItemView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanyChecklistItemView": { + "type": "object", + "properties": { + "itemId": { + "type": "integer", + "format": "int64" + }, + "source": { + "type": "string" + }, + "itemKey": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "active": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "ProgressUpdateRequest": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "required": [ + "completed" + ] + }, + "ApiResponseProgressUpdateResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ProgressUpdateResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ProgressUpdateResponse": { + "type": "object", + "properties": { + "itemId": { + "type": "integer", + "format": "int64" + }, + "completed": { + "type": "boolean" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "progressVersion": { + "type": "integer", + "format": "int64" + }, + "totalItems": { + "type": "integer", + "format": "int32" + }, + "completedItems": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number", + "format": "double" + } + } + }, + "SalaryCalculationOverrideRequest": { + "type": "object", + "properties": { + "socialSecurityBase": { + "type": "number", + "maximum": 10000000.0, + "minimum": 0.0 + }, + "housingFundBase": { + "type": "number", + "maximum": 10000000.0, + "minimum": 0.0 + }, + "pensionRate": { + "type": "number", + "maximum": 0.2, + "minimum": 0.0 + }, + "medicalRate": { + "type": "number", + "maximum": 0.2, + "minimum": 0.0 + }, + "unemploymentRate": { + "type": "number", + "maximum": 0.1, + "minimum": 0.0 + }, + "housingFundRate": { + "type": "number", + "maximum": 0.12, + "minimum": 0.0 + }, + "seriousIllnessMonthlyFee": { + "type": "number", + "maximum": 10000.0, + "minimum": 0.0 + } + } + }, + "SalaryCalculationRequest": { + "type": "object", + "properties": { + "cityCode": { + "type": "string", + "minLength": 1 + }, + "grossMonthlySalary": { + "type": "number", + "maximum": 10000000.0, + "minimum": 0.01 + }, + "monthIndex": { + "type": "integer", + "format": "int32", + "maximum": 12, + "minimum": 1 + }, + "specialAdditionalDeduction": { + "type": "number", + "maximum": 10000000.0, + "minimum": 0.0 + }, + "expectedPolicyVersion": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + }, + "overrides": { + "$ref": "#/components/schemas/SalaryCalculationOverrideRequest" + } + }, + "required": [ + "cityCode", + "expectedPolicyVersion", + "grossMonthlySalary" + ] + }, + "ApiResponseSalaryCalculationResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SalaryCalculationResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "SalaryCalculationResponse": { + "type": "object", + "properties": { + "historyId": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "result": { + "$ref": "#/components/schemas/SalaryCalculationResult" + } + } + }, + "SalaryCalculationResult": { + "type": "object", + "properties": { + "cityCode": { + "type": "string" + }, + "cityName": { + "type": "string" + }, + "dataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + }, + "policyVersion": { + "type": "string" + }, + "policyEffectiveDate": { + "type": "string", + "format": "date" + }, + "taxRulesVersion": { + "type": "string" + }, + "estimate": { + "type": "boolean" + }, + "monthIndex": { + "type": "integer", + "format": "int32" + }, + "grossMonthlySalary": { + "type": "number" + }, + "specialAdditionalDeduction": { + "type": "number" + }, + "socialSecurityBase": { + "type": "number" + }, + "housingFundBase": { + "type": "number" + }, + "pensionRate": { + "type": "number" + }, + "medicalRate": { + "type": "number" + }, + "unemploymentRate": { + "type": "number" + }, + "housingFundRate": { + "type": "number" + }, + "seriousIllnessMonthlyFee": { + "type": "number" + }, + "pension": { + "type": "number" + }, + "medical": { + "type": "number" + }, + "unemployment": { + "type": "number" + }, + "housingFund": { + "type": "number" + }, + "cumulativeGrossIncome": { + "type": "number" + }, + "cumulativeSocialInsurance": { + "type": "number" + }, + "cumulativeHousingFund": { + "type": "number" + }, + "cumulativeSeriousIllness": { + "type": "number" + }, + "cumulativeBasicDeduction": { + "type": "number" + }, + "cumulativeSpecialAdditionalDeduction": { + "type": "number" + }, + "cumulativeTaxableIncome": { + "type": "number" + }, + "cumulativeTaxDue": { + "type": "number" + }, + "previouslyWithheldTax": { + "type": "number" + }, + "currentMonthTax": { + "type": "number" + }, + "netSalary": { + "type": "number" + } + } + }, + "DecisionHistoryItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "fromStatus": { + "type": "string" + }, + "toStatus": { + "type": "string" + }, + "actorUserId": { + "type": "integer", + "format": "int64" + }, + "actorRole": { + "type": "string" + }, + "note": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReportedUser": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64" + }, + "nickname": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "CompanyChecklistItemCreateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "description": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "category": { + "type": "string", + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" + }, + "sortOrder": { + "type": "integer", + "format": "int32", + "maximum": 9999, + "minimum": 0 + }, + "required": { + "type": "boolean" + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + } + }, + "required": [ + "category", + "title" + ] + }, + "CreateAssessmentAttemptRequest": { + "type": "object", + "properties": { + "typeCode": { + "type": "string", + "maxLength": 40, + "minLength": 0 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + } + }, + "required": [ + "typeCode" + ] + }, + "ApiResponseAssessmentAttemptResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AssessmentAttemptResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AssessmentAttemptResponse": { + "type": "object", + "properties": { + "attemptId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "totalQuestions": { + "type": "integer", + "format": "int32" + }, + "answeredCount": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number" + }, + "questionSetVersion": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssessmentQuestionResponse" + } + } + } + }, + "AssessmentQuestionResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "stem": { + "type": "string" + }, + "scaleType": { + "type": "string" + }, + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResponseAssessmentResultResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AssessmentResultResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AssessmentResultResponse": { + "type": "object", + "properties": { + "attemptId": { + "type": "integer", + "format": "int64" + }, + "typeCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "primaryCode": { + "type": "string" + }, + "primaryType": { + "type": "string" + }, + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionScore" + } + }, + "typeRanking": { + "type": "array", + "items": { + "type": "string" + } + }, + "answeredCount": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number" + }, + "questionSetVersion": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "disclaimer": { + "type": "string" + }, + "explorationDirections": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DimensionScore": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "score": { + "type": "number" + }, + "answeredCount": { + "type": "integer", + "format": "int32" + } + } + }, + "ImprovementArea": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "currentScore": { + "type": "number" + }, + "summary": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "QuestionFeedback": { + "type": "object", + "properties": { + "attemptQuestionId": { + "type": "integer", + "format": "int64" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "stem": { + "type": "string" + }, + "selectedOptionId": { + "type": "string" + }, + "awardedScore": { + "type": "integer", + "format": "int32" + }, + "maxScore": { + "type": "integer", + "format": "int32" + }, + "explanation": { + "type": "string" + } + } + }, + "RadarEntry": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "Weakness": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "score": { + "type": "number" + } + } + }, + "AnswerItem": { + "type": "object", + "properties": { + "questionId": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 + } + }, + "required": [ + "questionId", + "value" + ] + }, + "SaveAssessmentAnswersRequest": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnswerItem" + }, + "minItems": 1 + } + }, + "required": [ + "answers", + "version" + ] + }, + "ApiResponseListSalaryCitySummaryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SalaryCitySummaryResponse" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "SalaryCitySummaryResponse": { + "type": "object", + "properties": { + "cityCode": { + "type": "string" + }, + "provinceCode": { + "type": "string" + }, + "provinceName": { + "type": "string" + }, + "cityName": { + "type": "string" + }, + "policyVersion": { + "type": "string" + }, + "policyEffectiveDate": { + "type": "string", + "format": "date" + }, + "dataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + } + } + }, + "ApiResponseSalaryCityDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SalaryCityDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "SalaryCityDetailResponse": { + "type": "object", + "properties": { + "cityCode": { + "type": "string" + }, + "provinceCode": { + "type": "string" + }, + "provinceName": { + "type": "string" + }, + "cityName": { + "type": "string" + }, + "policyVersion": { + "type": "string" + }, + "policyEffectiveDate": { + "type": "string", + "format": "date" + }, + "dataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + }, + "dataSource": { + "type": "string" + }, + "socialSecurityBaseMin": { + "type": "number" + }, + "socialSecurityBaseMax": { + "type": "number" + }, + "housingFundBaseMin": { + "type": "number" + }, + "housingFundBaseMax": { + "type": "number" + }, + "pensionRate": { + "type": "number" + }, + "medicalRate": { + "type": "number" + }, + "unemploymentRate": { + "type": "number" + }, + "housingFundRate": { + "type": "number" + }, + "seriousIllnessMonthlyFee": { + "type": "number" + } + } + }, + "ApiResponsePageResponseSalaryHistorySummaryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PageResponseSalaryHistorySummaryResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PageResponseSalaryHistorySummaryResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SalaryHistorySummaryResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "SalaryHistorySummaryResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "cityCode": { + "type": "string" + }, + "cityName": { + "type": "string" + }, + "dataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + }, + "grossMonthlySalary": { + "type": "number" + }, + "netSalary": { + "type": "number" + }, + "monthIndex": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseSalaryHistoryDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SalaryHistoryDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "JsonNode": {}, + "SalaryHistoryDetailResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "cityCode": { + "type": "string" + }, + "cityName": { + "type": "string" + }, + "dataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + }, + "policyVersion": { + "type": "string" + }, + "taxRulesVersion": { + "type": "string" + }, + "monthIndex": { + "type": "integer", + "format": "int32" + }, + "grossMonthlySalary": { + "type": "number" + }, + "netSalary": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "inputSnapshot": { + "$ref": "#/components/schemas/JsonNode" + }, + "policySnapshot": { + "$ref": "#/components/schemas/JsonNode" + }, + "resultSnapshot": { + "$ref": "#/components/schemas/JsonNode" + } + } + }, + "PageResponseReportResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResponsePageResponseJobRecommendationItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PageResponseJobRecommendationItem" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PageResponseJobRecommendationItem": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRecommendationItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "PageResponseDataExportTaskResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataExportTaskResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "Coverage": { + "type": "object", + "properties": { + "computableDimensions": { + "type": "integer", + "format": "int32" + }, + "totalDimensions": { + "type": "integer", + "format": "int32" + }, + "includedDimensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludedDimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExcludedDimension" + } + } + } + }, + "ExcludedDimension": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "status": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "RuleContribution": { + "type": "object", + "properties": { + "ruleCode": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "contribution": { + "type": "number" + }, + "maxContribution": { + "type": "number" + }, + "label": { + "type": "string" + }, + "observation": { + "type": "string" + }, + "explanation": { + "type": "string" + } + } + }, + "Validity": { + "type": "object", + "properties": { + "completedAt": { + "type": "string", + "format": "date-time" + }, + "validUntil": { + "type": "string", + "format": "date-time" + }, + "expired": { + "type": "boolean" + } + } + }, + "ApiResponseListCompanyChecklistItemView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyChecklistItemView" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyKnowledgeOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyKnowledgeOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanyKnowledgeOverviewResponse": { + "type": "object", + "properties": { + "company": { + "$ref": "#/components/schemas/CompanyView" + }, + "knowledgeState": { + "type": "string" + }, + "knowledge": { + "$ref": "#/components/schemas/KnowledgeView" + }, + "reviewSummary": { + "$ref": "#/components/schemas/ReviewSummaryView" + } + } + }, + "CompanyView": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "industry": { + "type": "string" + }, + "website": { + "type": "string" + }, + "address": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, + "DimensionSummaryView": { + "type": "object", + "properties": { + "averageScore": { + "type": "number", + "format": "double" + }, + "sampleCount": { + "type": "integer", + "format": "int64" + }, + "dataState": { + "type": "string" + } + } + }, + "KnowledgeView": { + "type": "object", + "properties": { + "culture": { + "type": "string" + }, + "workPolicies": { + "type": "string" + }, + "onboardingGuide": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "ReviewSummaryView": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number", + "format": "double" + }, + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "topTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "dimensionSummary": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/DimensionSummaryView" + } + } + } + }, + "ApiResponseEffectiveChecklistResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/EffectiveChecklistResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ChecklistItemView": { + "type": "object", + "properties": { + "itemId": { + "type": "integer", + "format": "int64" + }, + "source": { + "type": "string" + }, + "itemKey": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "active": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "progressVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "EffectiveChecklistResponse": { + "type": "object", + "properties": { + "companyId": { + "type": "integer", + "format": "int64" + }, + "companyName": { + "type": "string" + }, + "customized": { + "type": "boolean" + }, + "totalItems": { + "type": "integer", + "format": "int32" + }, + "completedItems": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number", + "format": "double" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemView" + } + } + } + }, + "ApiResponseCompetitionRankingResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompetitionRankingResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CompetitionRankingResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "status": { + "type": "string" + }, + "asOfPeriod": { + "type": "string" + }, + "mySnapshotPeriod": { + "type": "string" + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionItem" + } + }, + "gaps": { + "type": "array", + "items": { + "type": "string" + } + }, + "topCandidates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RankingSnapshot" + } + }, + "rulesVersion": { + "type": "string" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "RankingSnapshot": { + "type": "object", + "properties": { + "rank": { + "type": "integer", + "format": "int32" + }, + "overallScore": { + "type": "number" + } + } + }, + "PageResponseCompanyManagedJobItem": { + "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" + } + } + }, + "PageResponseBlockRelationResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockRelationResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResponseListAssessmentQuestionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssessmentQuestionResponse" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListAssessmentTypeResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssessmentTypeResponse" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AssessmentTypeResponse": { + "type": "object", + "properties": { + "typeCode": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "status": { + "type": "string" + }, + "scaleType": { + "type": "string" + }, + "questionCount": { + "type": "integer", + "format": "int32" + }, + "attemptQuestionCount": { + "type": "integer", + "format": "int32" + }, + "minPerDimension": { + "type": "integer", + "format": "int32" + }, + "questionSetVersion": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "ApiResponsePageResponseAssessmentHistoryItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PageResponseAssessmentHistoryItem" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AssessmentHistoryItem": { + "type": "object", + "properties": { + "attemptId": { + "type": "integer", + "format": "int64" + }, + "typeCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "answeredCount": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number" + }, + "questionSetVersion": { + "type": "string" + }, + "primaryCode": { + "type": "string" + }, + "primaryType": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "submittedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PageResponseAssessmentHistoryItem": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssessmentHistoryItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "TrendPoint": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "RuleCount": { + "type": "object", + "properties": { + "ruleCode": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "SeverityCount": { + "type": "object", + "properties": { + "severity": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "PageResponseRiskEventResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskEventResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "DistributionRow": { + "type": "object", + "properties": { + "ruleCode": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResponseUnavailable": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Unavailable" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Unavailable": { + "type": "object", + "properties": { + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseOverview": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Overview" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Distribution": { + "type": "object", + "properties": { + "sampleSize": { + "type": "integer", + "format": "int32" + }, + "mean": { + "type": "number" + }, + "median": { + "type": "number" + }, + "p10": { + "type": "number" + }, + "p25": { + "type": "number" + }, + "p75": { + "type": "number" + }, + "p90": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "standardDeviation": { + "type": "number" + }, + "buckets": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "concentrationRate": { + "type": "number" + }, + "riskLevel": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "Overview": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "period": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "populationSize": { + "type": "integer", + "format": "int32" + }, + "computableSnapshotRatio": { + "type": "number" + }, + "confidence": { + "type": "string" + }, + "riskLevel": { + "type": "string" + }, + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "mixedVersionCount": { + "type": "integer", + "format": "int32" + }, + "unknownVersionCount": { + "type": "integer", + "format": "int32" + }, + "distribution": { + "$ref": "#/components/schemas/Distribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Distribution" + } + }, + "coherentVersionSubset": { + "type": "array", + "items": { + "type": "string" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "ApiResponseDimensions": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Dimensions" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "DimensionMetric": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "populationSize": { + "type": "integer", + "format": "int32" + }, + "rowCount": { + "type": "integer", + "format": "int32" + }, + "missingRowCount": { + "type": "integer", + "format": "int32" + }, + "validSampleSize": { + "type": "integer", + "format": "int32" + }, + "confidence": { + "type": "string" + }, + "missingRowRate": { + "type": "number" + }, + "nullScoreRate": { + "type": "number" + }, + "validCoverage": { + "type": "number" + }, + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "inconsistentRecordCount": { + "type": "integer", + "format": "int32" + }, + "unknownStatusCount": { + "type": "integer", + "format": "int32" + }, + "outOfRangeScoreCount": { + "type": "integer", + "format": "int32" + }, + "unknownRulesVersionCount": { + "type": "integer", + "format": "int32" + }, + "distribution": { + "$ref": "#/components/schemas/Distribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Distribution" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "Dimensions": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "period": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionMetric" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "ApiResponseAppeals": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Appeals" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Appeals": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "appealCount": { + "type": "integer", + "format": "int32" + }, + "sampleSize": { + "type": "integer", + "format": "int32" + }, + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "statusEventCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "statusCandidateCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "confidence": { + "type": "string" + }, + "approvedEverCount": { + "type": "integer", + "format": "int32" + }, + "approvalRate": { + "type": "number" + }, + "rollbackExecutionRate": { + "type": "number" + }, + "duplicateAppealCount": { + "type": "integer", + "format": "int32" + }, + "duplicateAppealRate": { + "type": "number" + }, + "comparableRollbackCount": { + "type": "integer", + "format": "int32" + }, + "comparableRollbackCandidateCount": { + "type": "integer", + "format": "int32" + }, + "changedCount": { + "type": "integer", + "format": "int32" + }, + "changedCandidateCount": { + "type": "integer", + "format": "int32" + }, + "actualScoreChangeRate": { + "type": "number" + }, + "meanDelta": { + "type": "number" + }, + "meanAbsoluteDelta": { + "type": "number" + }, + "duplicateRollbackHistoryCount": { + "type": "integer", + "format": "int32" + }, + "invalidScoreRollbackCount": { + "type": "integer", + "format": "int32" + }, + "ruleVersionChangedRollbackCount": { + "type": "integer", + "format": "int32" + }, + "reasonCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "exposureAppealRateAvailability": { + "type": "string" + }, + "exposureAppealRateReason": { + "type": "string" + } + } + }, + "ApiResponsePageResponseRecommendationProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PageResponseRecommendationProfileResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PageResponseRecommendationProfileResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationProfileResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResponseListAbilityTypeResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityTypeResponse" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PageResponseAbilityHistoryItem": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityHistoryItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResponseVoid": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": {}, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AcceptanceView": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "companyAcceptedAt": { + "type": "string", + "format": "date-time" + }, + "communicationAllowed": { + "type": "boolean" + } + } + }, + "ApiResponseAcceptanceView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AcceptanceView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "securitySchemes": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 265aca9..9125e84 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']) == 206, f"Expected 206 paths, got {len(d['paths'])}" +assert len(d['paths']) == 207, f"Expected 207 paths, got {len(d['paths'])}" paths = d['paths'] @@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r # 统计 operation 总数并校验当前接口契约基线。 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 254, f"Expected 254 operations, got {operations}" +assert operations == 255, f"Expected 255 operations, got {operations}" assert 'post' in paths['/conversations/{id}/resume-messages'] assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys() assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys() @@ -978,5 +978,18 @@ _items = resolve_local_ref(d, _search_response['properties']['data']['$ref'])['p assert _items['type'] == 'array' and _items.get('maxItems') == 10 assert _items['items']['$ref'] == '#/components/schemas/JobLocationSuggestion' print(' Company job-location suggestions: typed candidates, bounded query and error contract verified') +_accept = paths['/applications/{id}/accept']['post'] +assert {'200', '401', '403', '404', '409'} <= set(_accept['responses']) +assert not _accept.get('requestBody'), 'Acceptance must not accept caller-provided userId/companyId/scores' +assert '不修改status' in _accept['description'] and '幂等' in _accept['description'] +_accept_success = next(iter(_accept['responses']['200']['content'].values()))['schema'] +_accept_envelope = resolve_local_ref(d, _accept_success['$ref']) +_accept_view = resolve_local_ref(d, _accept_envelope['properties']['data']['$ref']) +assert set(_accept_view['properties']) == {'applicationId', 'status', 'companyAcceptedAt', 'communicationAllowed'} +assert _accept_view['properties']['communicationAllowed']['type'] == 'boolean' +assert _accept_view['properties']['companyAcceptedAt']['format'] == 'date-time' +assert 'communicationAllowed' in paths['/applications/{id}/match']['get']['description'] +assert '无需双向评分达标' in paths['/applications/{id}/conversation']['post']['description'] +print(' Company acceptance unlocks communication; typed response and capability contract verified') print("Valid JSON: True") print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java index f3a0b68..a5096ff 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java @@ -56,6 +56,10 @@ public class Application { @Builder.Default private String status = "SUBMITTED"; + /** 企业明确接受本轮投递;查看简历、评分、旧轮投递均不自动写入。 */ + @Column(name = "company_accepted_at") + private LocalDateTime companyAcceptedAt; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java new file mode 100644 index 0000000..75e94f9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java @@ -0,0 +1,41 @@ +package com.mydreamjob.application; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.security.CurrentUserAccessor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; + +@RestController +@RequiredArgsConstructor +@Tag(name = "投递") +public class ApplicationAcceptanceController { + private final ApplicationAcceptanceService acceptance; + private final CurrentUserAccessor users; + private final RequestIdAccessor requestIds; + + public record AcceptanceView(Long applicationId, String status, LocalDateTime companyAcceptedAt, + boolean communicationAllowed) {} + + @Operation(summary = "企业接受投递并解锁沟通", description = "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="200",description="data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="401",description="未登录或会话不可用"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="403",description="角色、企业认证或投递归属不符"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="404",description="投递/企业不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="409",description="投递已拒绝或撤回,不能接受") + }) + @PostMapping("/applications/{id}/accept") + public ResponseEntity> accept(@PathVariable Long id, HttpServletRequest request) { + Application app = acceptance.accept(users.requireUserId(),id,AuditContext.from(request)); + AcceptanceView view = new AcceptanceView(app.getId(),app.getStatus(),app.getCompanyAcceptedAt(),true); + return ResponseEntity.ok(ApiResponse.ok(view,requestIds.get(request))); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java new file mode 100644 index 0000000..40f7fb3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java @@ -0,0 +1,58 @@ +package com.mydreamjob.application; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.company.VerifiedCompanyGuard; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.security.UserRoleGuard; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class ApplicationAcceptanceService { + private final UserRoleGuard roles; + private final CompanyRepository companies; + private final ApplicationRepository applications; + private final JobService jobs; + private final ApplicationStatusHistoryRepository history; + private final AuditService audit; + private final NotificationService notifications; + private final Clock clock; + + /** 与撤回/状态推进使用相同投递行锁;重试只产生一次接受记录和通知。 */ + @Transactional + public Application accept(Long actorUserId, Long applicationId, AuditContext context) { + roles.requireCompany(actorUserId); + Company company = VerifiedCompanyGuard.requireVerified(companies.findByOwnerUserId(actorUserId).orElse(null)); + Application app = applications.findLockedById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + Job job = jobs.getJob(app.getJobId()); + if (!company.getId().equals(job.getCompanyId())) + throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); + if (!Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) + throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); + // 已录用仍可补记明确接受;已经接受时不覆盖原始时间/审计。 + if (app.getCompanyAcceptedAt() != null) return app; + app.setCompanyAcceptedAt(LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MICROS)); + app = applications.save(app); + history.save(ApplicationStatusHistory.builder().applicationId(app.getId()) + .operatorUserId(actorUserId).fromStatus(app.getStatus()).toStatus(app.getStatus()) + .reason("COMPANY_ACCEPTED: company accepted application; communication unlocked").build()); + audit.log(actorUserId,"ACCEPT_APPLICATION","APPLICATION",app.getId(), + "Company accepted application; communication unlocked",context); + notifications.publishEvent(app.getStudentUserId(),"APPLICATION_ACCEPTED","企业已接受投递", + "企业已接受你的投递,可以开始沟通","APPLICATION",app.getId(),"accept-application-"+app.getId()); + return app; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java new file mode 100644 index 0000000..f70ede4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java @@ -0,0 +1,16 @@ +package com.mydreamjob.application; + +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; + +/** 沟通资格与双向评分解耦;终止投递优先拒绝,历史匹配/已录用保持兼容。 */ +public final class ApplicationCommunicationPolicy { + private ApplicationCommunicationPolicy() {} + + public static boolean allows(Application app, ApplicationMatchRecord legacyMatch) { + if (app == null || app.getStatus() == null + || !java.util.Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus())) + return false; + return app.getCompanyAcceptedAt() != null || "HIRED".equals(app.getStatus()) + || (legacyMatch != null && ApplicationMatchRecord.STATUS_MATCHED.equals(legacyMatch.getStatus())); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 2f9691a..0fa065c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -281,6 +281,7 @@ public class ApplicationService { view.put("jobId", app.getJobId()); view.put("studentUserId", app.getStudentUserId()); view.put("status", app.getStatus()); + view.put("companyAcceptedAt", app.getCompanyAcceptedAt()); view.put("attemptNumber", app.getAttemptNumber()); view.put("previousApplicationId", app.getPreviousApplicationId()); view.put("createdAt", app.getCreatedAt()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java index e336702..30da5dd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java @@ -51,7 +51,7 @@ public class ApplicationMatchController { return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request))); } - @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID") + @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID。附 companyAcceptedAt(明确接受时间,可null)和 communicationAllowed(沟通资格);接受后不要求双向评分达标。匹配 status=CLOSED 可能由 HIRED 引起,不代表 communicationAllowed=false;最终仍校验会话归属、企业认证与屏蔽。") @GetMapping("/applications/{id}/match") public ResponseEntity>> getMatch( @PathVariable Long id, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java index 5d7307e..24c6df3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java @@ -19,11 +19,12 @@ public interface ApplicationMatchRecordRepository extends JpaRepository 0 FROM ApplicationMatchRecord r " - + "JOIN Application a ON a.id = r.applicationId " - + "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED' " - + "AND a.status NOT IN ('REJECTED', 'WITHDRAWN')") + /** 沟通资格;方法名保留给旧调用方,语义已扩展为企业接受/已录用/历史匹配。 */ + @Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId " + + "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId " + + "AND a.status IN ('SUBMITTED', 'VIEWED', 'INTERVIEW', 'HIRED') " + + "AND (a.companyAcceptedAt IS NOT NULL OR a.status = 'HIRED' " + + "OR EXISTS (SELECT r.id FROM ApplicationMatchRecord r WHERE r.applicationId = a.id AND r.status = 'MATCHED'))") boolean existsMatchedApplication(@Param("companyId") Long companyId, @Param("candidateId") Long candidateId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java index ffe3321..3cce3e3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java @@ -171,6 +171,8 @@ public class BidirectionalMatchService { v.put("jobId", job.getId()); v.put("companyId", job.getCompanyId()); v.put("candidateId", app.getStudentUserId()); + v.put("companyAcceptedAt", app.getCompanyAcceptedAt()); + v.put("communicationAllowed", com.mydreamjob.application.ApplicationCommunicationPolicy.allows(app, record)); if (record != null) { v.put("candidateInterestScore", record.getCandidateInterestScore()); v.put("hrSuitabilityScore", record.getHrSuitabilityScore()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java index 760fc7b..a63893b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java @@ -13,7 +13,7 @@ import java.util.Map; /** * 匹配后沟通解锁接口:基于 application 创建/获取直聊会话 */ -@Tag(name = "双向匹配沟通", description = "匹配成功后创建或获取直聊会话(幂等)") +@Tag(name = "双向匹配沟通", description = "企业接受投递后创建或获取直聊会话(幂等),兼容历史匹配") @RestController @RequiredArgsConstructor public class MatchConversationController { @@ -25,7 +25,7 @@ public class MatchConversationController { private final MatchConversationService matchConversationService; - @Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话") + @Operation(summary = "创建/获取投递沟通会话", description = "仅投递双方;企业接受后即可沟通,无需双向评分达标;历史 MATCHED 或 HIRED 保持兼容。已拒绝/撤回投递返回409/8304,未接受同样返回409/8304;重复调用返回同一会话。企业认证、归属、屏蔽校验保留。GET /applications/{id}/match 的 communicationAllowed 为沟通资格,不应从匹配 status=CLOSED 推断禁止沟通。") @PostMapping("/applications/{id}/conversation") public ResponseEntity>> createOrGetConversation( @PathVariable Long id, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java index e354e22..dcfe066 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.Map; /** - * 匹配后沟通解锁:仅 MATCHED 的双方可基于 application 创建/获取直聊会话。 + * 企业接受后沟通解锁;兼容历史 MATCHED 和已录用,拒绝/撤回优先禁止。 * *

幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话, * 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。 @@ -41,12 +41,13 @@ public class MatchConversationService { private final ApplicationMatchConversationRepository conversationLinkRepository; private final ConversationRepository conversationRepository; private final ConversationMemberRepository memberRepository; + private final com.mydreamjob.safety.BlockRelationRepository blockRelationRepository; @Transactional public Map createOrGetMatchConversation(Long userId, Long applicationId) { Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null); if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany); - Application app = applicationRepository.findById(applicationId) + Application app = applicationRepository.findLockedById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); Job job = jobService.getJob(app.getJobId()); Long companyId = job.getCompanyId(); @@ -56,6 +57,7 @@ public class MatchConversationService { Long otherUserId; if (candidateId.equals(userId)) { otherUserId = companyRepository.findById(companyId) + .map(VerifiedCompanyGuard::requireVerified) .map(Company::getOwnerUserId) .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); } else { @@ -66,12 +68,9 @@ public class MatchConversationService { otherUserId = candidateId; } - // 必须已 MATCHED - if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) { - throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); - } + // 与撤回/状态推进共用当前读;不能仅根据匹配查询的 CLOSED 展示值判断沟通权限。 ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null); - if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) { + if (!com.mydreamjob.application.ApplicationCommunicationPolicy.allows(app, record)) { throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); } @@ -83,6 +82,10 @@ public class MatchConversationService { } // 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致) + if (blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(userId, otherUserId) + || blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(otherUserId, userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be sent"); + } Conversation conversation = findOrCreateDirectConversation(userId, otherUserId); try { conversationLinkRepository.save(ApplicationMatchConversation.builder() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index f8f9774..e181d37 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -84,8 +84,8 @@ public enum ErrorCode { // 双向匹配 83xx MATCH_CLOSED(8301, "match is closed"), MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"), - MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"), - APPLICATION_NOT_MATCHED(8304, "application is not matched"), + MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation requires a company-accepted application"), + APPLICATION_NOT_MATCHED(8304, "application is not accepted for communication"), // 站内信 83xx CONVERSATION_NOT_FOUND(8305, "conversation not found"), MESSAGE_NOT_FOUND(8306, "message not found"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java index 1a68072..601f6c0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java @@ -17,7 +17,7 @@ public class ChatResumeController { private final com.mydreamjob.security.CurrentUserAccessor current; private final com.mydreamjob.common.logging.RequestIdAccessor requestIds; - @Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值") + @Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且企业已接受投递;兼容历史 MATCHED/HIRED。拒绝/撤回、屏蔽、企业认证和简历隐私规则仍生效。clientMessageId 必填,重试复用原值") @PostMapping("/{id}/resume-messages") public ApiResponse send(@PathVariable Long id, @Valid @RequestBody ChatResumeService.SendRequest body, HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java index 1050255..1cc2c80 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java @@ -143,7 +143,7 @@ public class ChatResumeService { || blocks.existsByBlockerUserIdAndBlockedUserId(employer,student)) throw new BusinessException(ErrorCode.FORBIDDEN); var match = matches.findByApplicationId(applicationId).orElse(null); - if (match == null || !"MATCHED".equals(match.getStatus())) + if (!com.mydreamjob.application.ApplicationCommunicationPolicy.allows(application,match)) throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); // 主动发送不会偷偷覆盖学生的 PRIVATE 设置;双方均必须满足当前下载策略。 resumeAccess.authorizeForDownload(employer,applicationId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index a143013..2115123 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -118,7 +118,7 @@ public class MessagingService { } // 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话 assertNotBlockedBetween(userId, otherId); - // 新建会话:企业-求职者组合必须先存在双向匹配成功的投递,防止绕过匹配解锁 + // 新建会话:企业已接受投递(兼容历史匹配/已录用),不能靠通用接口绕过。 assertCompanyCandidatePairHasMatchedApplication(userId, otherId); Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build()); memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(userId).build()); @@ -128,7 +128,7 @@ public class MessagingService { /** * 防绕过守卫:仅当双方为"企业账号 + 求职者账号"且确实要新建会话时,要求存在一条 - * 同时归属双方且状态为 MATCHED 的投递;否则 403。 + * 同时归属双方且允许沟通的投递(企业接受/历史匹配/已录用);否则 403。 * 学生-学生、企业-企业及其他组合保持原语义;无法判定身份时(上游已校验存在性)防御性放行。 */ private void assertCompanyCandidatePairHasMatchedApplication(Long userId, Long otherId) { @@ -145,6 +145,7 @@ public class MessagingService { Long companyUserId = meCompany ? userId : otherId; Long studentUserId = meCompany ? otherId : userId; Long companyId = companyRepository.findByOwnerUserId(companyUserId) + .map(VerifiedCompanyGuard::requireVerified) .map(Company::getId).orElse(null); if (companyId == null || !applicationMatchRecordRepository.existsMatchedApplication(companyId, studentUserId)) { diff --git a/mydreamjob-server/src/main/resources/db/migration/V61__application_company_acceptance.sql b/mydreamjob-server/src/main/resources/db/migration/V61__application_company_acceptance.sql new file mode 100644 index 0000000..e3d723c --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V61__application_company_acceptance.sql @@ -0,0 +1,2 @@ +-- 接受与招聘阶段分离;不猜测/回填历史接受时间,不改既有状态、评分、会话。 +ALTER TABLE applications ADD COLUMN company_accepted_at DATETIME(6) NULL; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceIntegrationTest.java new file mode 100644 index 0000000..6907d7c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceIntegrationTest.java @@ -0,0 +1,189 @@ +package com.mydreamjob.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.*; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.bidirectionalmatch.*; +import com.mydreamjob.company.*; +import com.mydreamjob.job.*; +import com.mydreamjob.messaging.*; +import com.mydreamjob.common.exception.BusinessException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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 java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.http.MediaType; + +@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:integrationdb_accept_chat;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@AutoConfigureMockMvc @ActiveProfiles("integration-test") +class ApplicationAcceptanceIntegrationTest { + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired UserRepository users; + @Autowired CompanyRepository companies; + @Autowired JobRepository jobs; + @Autowired ApplicationRepository applications; + @Autowired ApplicationAcceptanceService acceptance; + @Autowired ApplicationService applicationService; + @Autowired ApplicationStatusHistoryRepository history; + @Autowired ApplicationMatchRecordRepository matches; + @Autowired MatchConversationService conversations; + @Autowired AuthSessionService sessions; + @Autowired Clock clock; + @Autowired com.mydreamjob.safety.BlockRelationRepository blocks; + @MockBean RedisTemplate redis; + static final AtomicLong PHONE=new AtomicLong(71000000); + User student,employer,other; + Company company; + Job job; + Application app; + String studentToken,employerToken; + + User user(User.Role role) { + return users.save(User.builder().phone("166"+PHONE.incrementAndGet()).nickname("隔离接受沟通测试") + .role(role).roles(Set.of(role)).active(true).verified(true).build()); + } + String token(User user) { return sessions.createSession(user,clock.instant(),AuditContext.builder().build()).accessToken(); } + @BeforeEach void setup() { + student=user(User.Role.STUDENT);employer=user(User.Role.COMPANY);other=user(User.Role.STUDENT); + company=companies.save(Company.builder().ownerUserId(employer.getId()).name("隔离企业").verified(true).build()); + job=jobs.save(Job.builder().companyId(company.getId()).title("仅测试").description("fixture") + .city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()); + app=applications.save(Application.builder().jobId(job.getId()).studentUserId(student.getId()).status("SUBMITTED").build()); + studentToken=token(student);employerToken=token(employer); + } + String url(String suffix) {return "/applications/"+app.getId()+suffix;} + long conversation(String token) throws Exception { + String json=mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+token)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + return mapper.readTree(json).path("data").path("id").asLong(); + } + void accept() { acceptance.accept(employer.getId(),app.getId(),null); } + + @Test void httpAcceptWithoutScoresThenBothSidesCanSendAndRead() throws Exception { + mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(8304)); + mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) + .content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(8303)); + String response=mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(true)) + .andExpect(jsonPath("$.data.status").value("SUBMITTED")).andReturn().getResponse().getContentAsString(); + String acceptedAt=mapper.readTree(response).path("data").path("companyAcceptedAt").asText(); + assertFalse(acceptedAt.isBlank()); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companyAcceptedAt").value(acceptedAt)); + assertTrue(matches.findByApplicationId(app.getId()).isEmpty()); + mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PENDING_BOTH")) + .andExpect(jsonPath("$.data.communicationAllowed").value(true)); + long id=conversation(employerToken);assertEquals(id,conversation(studentToken)); + for(String auth:List.of(studentToken,employerToken)) { + mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+auth).contentType(MediaType.APPLICATION_JSON) + .content("{\"conversationId\":"+id+",\"content\":\"隔离双向沟通\",\"clientMessageId\":\"msg-"+auth.hashCode()+"\"}")) + .andExpect(status().isOk()); + } + for(String auth:List.of(studentToken,employerToken)) { + mvc.perform(get("/conversations/"+id+"/messages").header("Authorization","Bearer "+auth)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(2)); + mvc.perform(get("/conversations/"+id+"/events").header("Authorization","Bearer "+auth)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isNotEmpty()); + } + long count=history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream() + .filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count(); + assertEquals(1,count); + } + @Test void historicalHiredHasClosedMatchButAllowsCommunication() throws Exception { + app.setStatus("HIRED");applications.save(app); + mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CLOSED")) + .andExpect(jsonPath("$.data.communicationAllowed").value(true)); + assertTrue(matches.existsMatchedApplication(company.getId(),student.getId())); + assertTrue(conversation(employerToken)>0); + } + @Test void viewingAloneDoesNotUnlock() { + app.setStatus("VIEWED");applications.save(app); + assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); + assertThrows(BusinessException.class,()->conversations.createOrGetMatchConversation(employer.getId(),app.getId())); + } + @ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"}) + void terminalWinsOverAcceptance(String status) throws Exception { + accept();app=applications.findById(app.getId()).orElseThrow();app.setStatus(status);applications.save(app); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict()); + mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict()); + assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); + mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(false)); + } + @Test void studentOtherCompanyAndAnonymousCannotAccept() throws Exception { + mvc.perform(post(url("/accept"))).andExpect(status().isUnauthorized()); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden()); + User foreign=user(User.Role.COMPANY); + companies.save(Company.builder().ownerUserId(foreign.getId()).name("别家企业").verified(true).build()); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+token(foreign))).andExpect(status().isForbidden()); + assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt()); + } + @Test void unverifiedAndDisabledCompaniesCannotAccept() throws Exception { + company.setVerified(false);companies.save(company); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden()); + company.setVerified(true);companies.save(company); + employer.setActive(false);users.save(employer); + mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isUnauthorized()); + assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt()); + } + @Test void concurrentAcceptanceIsIdempotent() throws Exception { + ExecutorService pool=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1); + try { + Callable call=()->{start.await();return acceptance.accept(employer.getId(),app.getId(),null).getCompanyAcceptedAt();}; + var a=pool.submit(call);var b=pool.submit(call);start.countDown(); + assertEquals(a.get(15,TimeUnit.SECONDS),b.get(15,TimeUnit.SECONDS)); + assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream() + .filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count()); + } finally {pool.shutdownNow();} + } + @Test void revokedCompanyCertificationCannotBeBypassedByStudent() throws Exception { + accept();company.setVerified(false);companies.save(company); + mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden()); + mvc.perform(post("/conversations").header("Authorization","Bearer "+studentToken).contentType(MediaType.APPLICATION_JSON) + .content("{\"participantUserId\":"+employer.getId()+"}")).andExpect(status().isForbidden()); + } + @Test void withdrawalAndReapplicationDoNotInheritAcceptance() throws Exception { + accept();long oldId=app.getId();long oldConversation=conversation(employerToken); + applicationService.withdraw(student.getId(),oldId); + var request=new com.mydreamjob.application.dto.ApplicationCreateRequest();request.setJobId(job.getId()); + Application next=applicationService.apply(student.getId(),request); + assertNull(next.getCompanyAcceptedAt());assertEquals(oldId,next.getPreviousApplicationId()); + assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); + mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) + .content("{\"conversationId\":"+oldConversation+",\"content\":\"撤回后禁止发送\"}")).andExpect(status().isForbidden()); + app=next;accept();assertEquals(oldConversation,conversation(employerToken)); + } + @Test void runtimeContractIncludesAcceptanceAndCapabilities() throws Exception { + String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + assertTrue(mapper.readTree(json).path("paths").has("/applications/{id}/accept")); + java.nio.file.Files.writeString(java.nio.file.Path.of("target/acceptance-chat-openapi.json"),json); + } + @ParameterizedTest @ValueSource(booleans={true,false}) + void acceptedPairCannotBypassEitherDirectionOfBlocking(boolean forward) throws Exception { + accept(); + blocks.save(com.mydreamjob.safety.BlockRelation.builder() + .blockerUserId(forward?student.getId():employer.getId()) + .blockedUserId(forward?employer.getId():student.getId()).build()); + mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden()); + mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON) + .content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlIntegrationTest.java new file mode 100644 index 0000000..5c7cdbd --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlIntegrationTest.java @@ -0,0 +1,26 @@ +package com.mydreamjob.application; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** 同一组真实控制器、权限、接受并发、聊天双向读写与撤回探针,在真实 MySQL 重用。 */ +@Testcontainers +@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) +class ApplicationAcceptanceMySqlIntegrationTest extends ApplicationAcceptanceIntegrationTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>( + System.getProperty("acceptance.mysql.image","mysql:8.4")) + .withDatabaseName("acceptance_chat").withUsername("accepttest").withPassword("synthetic-only") + .withCommand("--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci") + .withUrlParam("characterEncoding","UTF-8") + .withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024)); + @DynamicPropertySource static void mysql(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url",MYSQL::getJdbcUrl); + registry.add("spring.datasource.username",MYSQL::getUsername); + registry.add("spring.datasource.password",MYSQL::getPassword); + registry.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlMigrationTest.java new file mode 100644 index 0000000..84897c3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlMigrationTest.java @@ -0,0 +1,56 @@ +package com.mydreamjob.application; + +import com.mydreamjob.config.MySql57MigrationConfiguration; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import java.sql.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ApplicationAcceptanceMySqlMigrationTest { + @Test void mysql57V60ToV61() throws Exception {verify("mysql:5.7.36");} + @Test void mysql84V60ToV61() throws Exception {verify("mysql:8.4");} + Flyway flyway(MySQLContainer db,String version) { + var config=Flyway.configure().dataSource(db.getJdbcUrl(),db.getUsername(),db.getPassword()) + .locations("classpath:db/migration").target(version); + MySql57MigrationConfiguration.configure(config);return config.load(); + } + void verify(String image) throws Exception { + try(var db=new MySQLContainer<>(image).withDatabaseName("acceptance_migration").withUsername("accepttest") + .withUrlParam("characterEncoding","UTF-8") + .withPassword("synthetic-only").withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024))) { + db.start(); + try(var connection=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword()); + var sql=connection.createStatement()) { + sql.execute("ALTER DATABASE acceptance_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + flyway(db,"60").migrate();var before=history(sql); + sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(940001,'19900881111','COMPANY',1,1),(940002,'19900882222','STUDENT',1,1)"); + sql.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(940001,940001,'test',1)"); + sql.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(940001,940001,'test','fixture','test','FULL_TIME','PUBLISHED')"); + sql.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(940001,940001,940002,'HIRED')"); + assertEquals(1,flyway(db,"61").migrate().migrationsExecuted); + assertEquals(before,history(sql)); + try(var rows=sql.executeQuery("SELECT status,company_accepted_at FROM applications WHERE id=940001")) { + assertTrue(rows.next());assertEquals("HIRED",rows.getString(1));assertNull(rows.getTimestamp(2)); + } + try(var rows=sql.executeQuery("SELECT data_type,datetime_precision,is_nullable FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='applications' AND column_name='company_accepted_at'")) { + assertTrue(rows.next());assertEquals("datetime",rows.getString(1));assertEquals(6,rows.getInt(2));assertEquals("YES",rows.getString(3)); + } + sql.execute("UPDATE applications SET company_accepted_at='2026-10-22 12:00:00.123456' WHERE id=940001"); + assertEquals(0,flyway(db,"61").migrate().migrationsExecuted);flyway(db,"61").validate(); + try(var rows=sql.executeQuery("SELECT CAST(company_accepted_at AS CHAR) FROM applications WHERE id=940001")) { + assertTrue(rows.next());assertTrue(rows.getString(1).endsWith(".123456")); + } + assertEquals(before,history(sql)); + } + } + } + List history(Statement sql) throws Exception { + var result=new ArrayList(); + try(var rows=sql.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'61' ORDER BY installed_rank")) { + while(rows.next())result.add(rows.getString(1)+"|"+rows.getString(2)+"|"+rows.getBoolean(3)); + } + return result; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceServiceTest.java new file mode 100644 index 0000000..5f586db --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceServiceTest.java @@ -0,0 +1,80 @@ +package com.mydreamjob.application; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.company.*; +import com.mydreamjob.job.*; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.security.UserRoleGuard; +import com.mydreamjob.common.exception.BusinessException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import java.time.*; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ApplicationAcceptanceServiceTest { + @Mock UserRoleGuard roles; + @Mock CompanyRepository companies; + @Mock ApplicationRepository applications; + @Mock JobService jobs; + @Mock ApplicationStatusHistoryRepository history; + @Mock AuditService audit; + @Mock NotificationService notifications; + final Clock clock=Clock.fixed(Instant.parse("2026-10-22T12:00:00Z"),ZoneOffset.UTC); + ApplicationAcceptanceService service; + Application app; + @BeforeEach void setup() { + service=new ApplicationAcceptanceService(roles,companies,applications,jobs,history,audit,notifications,clock); + app=Application.builder().id(15L).jobId(3L).studentUserId(62L).status("SUBMITTED").build(); + } + void fixture() { + when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(true).build())); + when(applications.findLockedById(15L)).thenReturn(Optional.of(app)); + when(jobs.getJob(3L)).thenReturn(Job.builder().id(3L).companyId(54L).build()); + } + @ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW","HIRED"}) + void acceptIsExplicitAndDoesNotAdvanceStatus(String status) { + fixture();app.setStatus(status);when(applications.save(app)).thenReturn(app); + var result=service.accept(64L,15L,null); + assertEquals(status,result.getStatus());assertEquals(LocalDateTime.now(clock),result.getCompanyAcceptedAt()); + verify(roles).requireCompany(64L); + verify(history).save(argThat(h->h.getOperatorUserId().equals(64L) + &&h.getFromStatus().equals(status)&&h.getToStatus().equals(status))); + verify(audit).log(eq(64L),eq("ACCEPT_APPLICATION"),eq("APPLICATION"),eq(15L),anyString(),isNull()); + verify(notifications).publishEvent(eq(62L),eq("APPLICATION_ACCEPTED"),anyString(),anyString(),eq("APPLICATION"),eq(15L),eq("accept-application-15")); + } + @Test void repeatedAcceptanceDoesNotOverwriteOrNotifyAgain() { + fixture();var original=LocalDateTime.now(clock).minusDays(1);app.setCompanyAcceptedAt(original); + assertSame(app,service.accept(64L,15L,null)); + assertEquals(original,app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications); + verify(applications,never()).save(any()); + } + @ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"}) + void terminalCannotBeAcceptedEvenIfPreviouslyAccepted(String status) { + fixture();app.setStatus(status);app.setCompanyAcceptedAt(LocalDateTime.now(clock)); + assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); + verifyNoInteractions(history,audit,notifications); + } + @Test void anotherCompanyCannotAccept() { + fixture();when(jobs.getJob(3L)).thenReturn(Job.builder().companyId(99L).build()); + assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); + assertNull(app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications); + } + @Test void unverifiedCompanyCannotAccept() { + when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(false).build())); + assertThrows(BusinessException.class,()->service.accept(64L,15L,null)); + verifyNoInteractions(applications,history,audit,notifications); + } + @Test void roleFailureHasNoSideEffects() { + doThrow(new BusinessException(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)).when(roles).requireCompany(62L); + assertThrows(BusinessException.class,()->service.accept(62L,15L,null)); + verifyNoInteractions(companies,applications,history,audit,notifications); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationCommunicationPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationCommunicationPolicyTest.java new file mode 100644 index 0000000..2a67af9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationCommunicationPolicyTest.java @@ -0,0 +1,31 @@ +package com.mydreamjob.application; + +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; + +class ApplicationCommunicationPolicyTest { + @ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"}) + void viewingOrEnteringInterviewDoesNotMeanAcceptance(String status) { + assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status).build(),null)); + } + @ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"}) + void acceptedDoesNotRequireAnyScore(String status) { + assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status(status) + .companyAcceptedAt(LocalDateTime.now()).build(),null)); + } + @ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN","UNKNOWN"}) + void terminalAndUnknownWinOverAcceptanceAndMatch(String status) { + assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status) + .companyAcceptedAt(LocalDateTime.now()).build(),ApplicationMatchRecord.builder().status("MATCHED").build())); + } + @Test void historicalHiredAndMatchedRemainUsable() { + assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("HIRED").build(),null)); + assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("VIEWED").build(), + ApplicationMatchRecord.builder().status("MATCHED").build())); + assertFalse(ApplicationCommunicationPolicy.allows(null,null)); + } +} 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 17e35f6..eb1da33 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest { } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("193 paths 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(193); - assertThat(opCount - moduleOperations).as("236 operations 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(236); + assertThat(pathCount - modulePaths).as("194 paths 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(194); + assertThat(opCount - moduleOperations).as("237 operations 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(237); 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/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index 9a44059..b5b0cbc 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("既有身份门禁、事件补拉、简历消息、8项在线简历及企业地点搜索操作均受保护").isEqualTo(210); + assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息、8项在线简历、企业地点搜索及接受投递操作均受保护").isEqualTo(211); 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/bidirectionalmatch/MatchConversationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java index 1c76050..07899ff 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java @@ -36,6 +36,7 @@ class MatchConversationServiceTest { @Mock private ApplicationMatchConversationRepository conversationLinkRepository; @Mock private ConversationRepository conversationRepository; @Mock private ConversationMemberRepository memberRepository; + @Mock private com.mydreamjob.safety.BlockRelationRepository blockRelationRepository; @InjectMocks private MatchConversationService conversationService; @@ -64,7 +65,7 @@ class MatchConversationServiceTest { @Test void createOrGet_notMatched_shouldReturn409() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(companyRepository.findById(COMPANY_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build())); @@ -80,7 +81,7 @@ class MatchConversationServiceTest { @Test void createOrGet_matched_byStudent_shouldCreateAndLink() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); @@ -105,7 +106,7 @@ class MatchConversationServiceTest { @Test void createOrGet_matched_byCompany_shouldCreate() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); @@ -127,7 +128,7 @@ class MatchConversationServiceTest { @Test void createOrGet_repeatCall_shouldReturnExistingLink() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(companyRepository.findById(COMPANY_ID)) .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build())); @@ -147,7 +148,7 @@ class MatchConversationServiceTest { @Test void createOrGet_concurrentLinkConflict_shouldReturnExisting() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)) @@ -174,7 +175,7 @@ class MatchConversationServiceTest { @Test void createOrGet_otherStudent_shouldReturn403() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); + when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(jobService.getJob(JOB_ID)).thenReturn(job()); when(companyRepository.findByOwnerUserId(OTHER_STUDENT_ID)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, @@ -184,7 +185,7 @@ class MatchConversationServiceTest { @Test void createOrGet_applicationNotFound_shouldReturn404() { - when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); + when(applicationRepository.findLockedById(999L)).thenReturn(Optional.empty()); BusinessException ex = assertThrows(BusinessException.class, () -> conversationService.createOrGetMatchConversation(STUDENT_ID, 999L)); assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); 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 f618597..23f4962 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java @@ -148,8 +148,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(254).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(206); + assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(207); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { 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 7a12ebc..3c83bf5 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(254).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(206); + assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(207); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java index 75a671d..3926170 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java @@ -147,7 +147,7 @@ class JobMapLocationIntegrationTest { @Test void runtimeContractContainsLocationAndSearchRoute() throws Exception { String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); - var spec=mapper.readTree(raw);assertEquals(206,spec.path("paths").size()); + var spec=mapper.readTree(raw);assertEquals(207,spec.path("paths").size()); assertTrue(spec.path("paths").path("/companies/me/job-location-suggestions").has("get")); var search=spec.path("paths").path("/companies/me/job-location-suggestions").path("get"); assertEquals("searchJobLocationSuggestions",search.path("operationId").asText()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java index 7440cd0..6f75f31 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java @@ -209,4 +209,10 @@ class ChatWorkflowIntegrationTest { String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); java.nio.file.Files.writeString(java.nio.file.Path.of("target/workflow-openapi.json"),json); } + @Test void acceptedApplicationCanSendResumeWithoutMatchScores() { + matches.delete(matches.findByApplicationId(application.getId()).orElseThrow()); + application.setCompanyAcceptedAt(java.time.LocalDateTime.now(clock));em.flush(); + assertTrue(matches.findByApplicationId(application.getId()).isEmpty()); + assertEquals("RESUME",send("accepted-no-match").getMessageType()); + } }