From e46efe55ac0ce7438e3b8bc4a9a92d2dd2d96ef4 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Thu, 22 Oct 2026 17:13:20 +0800 Subject: [PATCH] feat(job): add map locations and bounded place search; serialize session logout --- docs/job-map-runtime-configuration.md | 39 ++ docs/openapi-rc.json | 386 +++++++++++++++++- docs/verify_openapi.py | 62 ++- .../mydreamjob/auth/AuthSessionService.java | 4 + .../src/main/java/com/mydreamjob/job/Job.java | 20 + .../com/mydreamjob/job/JobController.java | 4 +- .../mydreamjob/job/JobMapLocationPolicy.java | 63 +++ .../java/com/mydreamjob/job/JobService.java | 16 + .../mydreamjob/job/dto/JobCreateRequest.java | 5 + .../mydreamjob/job/dto/JobDetailViewDoc.java | 3 + .../job/dto/JobMapLocationRequest.java | 42 ++ .../job/dto/JobMapLocationView.java | 18 + .../mydreamjob/job/dto/JobPublicViewDoc.java | 3 + .../mydreamjob/job/dto/JobUpdateRequest.java | 5 + .../job/dto/StrictCoordinateDeserializer.java | 16 + .../job/location/AmapPlaceClient.java | 160 ++++++++ .../mydreamjob/job/location/AmapTimeouts.java | 24 ++ .../location/JobLocationSearchController.java | 74 ++++ .../location/JobLocationSearchException.java | 11 + .../location/JobLocationSearchLimiter.java | 57 +++ .../job/location/JobLocationSuggestion.java | 16 + .../db/migration/V60__job_map_location.sql | 8 + .../CompanyResumeDownloadIntegrationTest.java | 4 +- ...utRefreshSerializationIntegrationTest.java | 129 ++++++ .../auth/AuthSessionServiceTest.java | 10 + .../UnifiedRegistrationIntegrationTest.java | 2 +- .../LxqModulesMySql57IntegrationTest.java | 4 +- .../LxqModulesMySqlIntegrationTest.java | 4 +- .../job/JobMapLocationIntegrationTest.java | 203 +++++++++ .../job/JobMapLocationMySqlMigrationTest.java | 64 +++ .../job/JobMapLocationPolicyTest.java | 127 ++++++ .../job/location/AmapPlaceClientTest.java | 98 +++++ .../location/AmapPlaceHttpTransportTest.java | 47 +++ .../job/location/AmapTimeoutsTest.java | 48 +++ .../JobLocationSearchControllerTest.java | 93 +++++ .../JobLocationSearchLimiterTest.java | 50 +++ .../OnlineResumeMigrationTest.java | 5 +- 37 files changed, 1897 insertions(+), 27 deletions(-) create mode 100644 docs/job-map-runtime-configuration.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/JobMapLocationPolicy.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationView.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictCoordinateDeserializer.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapPlaceClient.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapTimeouts.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchException.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchLimiter.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSuggestion.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V60__job_map_location.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthLogoutRefreshSerializationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationMySqlMigrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationPolicyTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceClientTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceHttpTransportTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapTimeoutsTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchLimiterTest.java diff --git a/docs/job-map-runtime-configuration.md b/docs/job-map-runtime-configuration.md new file mode 100644 index 0000000..4128720 --- /dev/null +++ b/docs/job-map-runtime-configuration.md @@ -0,0 +1,39 @@ +# 岗位地图服务端配置与认证锁修复 + +本说明属于“认证锁修复+地图超时配置化”新批次,不代表已部署。前一31文件/JAR的8秒/12秒仅是隔离候选对照,不能与本批实际默认值混用。 + +## 地图超时 + +| 属性 | 默认值 | 合法范围 | +|---|---:|---| +| app.amap.connect-timeout-ms | 8000 | 500—10000毫秒 | +| app.amap.total-timeout-ms | 12000 | 1000—20000毫秒,并且严格大于连接预算 | + +使用服务端配置绑定,不接受业务请求参数控制预算。非法值、非整数或非法预算关系导致启动失败,不静默退回默认值;错误不包含Key。 + +配置示例(不含凭据,可通过部署系统的外部配置注入): + +```yaml +app: + amap: + connect-timeout-ms: 8000 + total-timeout-ms: 12000 +``` + +总预算覆盖一次HTTP请求的响应头与响应体,超时取消异步请求。保持固定高德HTTPS目的地、禁止重定向、响应上限262144字节、失败关闭和企业/全局限流,不新增自动重试。 + +真实网络的1.5秒连接预算在本机多次失败,8秒连接/12秒总预算曾获得真实成功。这不保证所有环境必然成功,仍需同次新制品的真实调用、延迟和容量验证。前端请求预算需要容纳后端总预算和业务处理开销,避免短于后端预算就提前报失败;不建议无限等待或自动连续重试。 + +Key仍只通过安全配置 `AMAP_WEB_SERVICE_KEY` 或 `app.amap.web-service-key` 注入,不在源码或前端分发。没有修改原 application.yml,也不覆盖现有注销冷静期等运行配置。 + +## 认证锁顺序 + +登出在撤销auth_tokens前,先锁users当前用户行,与登录、刷新、密码变更统一 `users -> auth_tokens` 顺序。刷新先拿到锁时,登出等待刷新提交后才启动批量撤销;登出先拿到锁时,刷新随后看到已撤销状态,不能签发活动后继。 + +保留当前会话范围、幂等登出语义和既有角色/状态判断,不扩大到其它会话,不更改Token格式或API路径,不增加数据库迁移。 + +回归使用真实事务/SQL、提交前屏障和数据库阻塞观察,验证两个执行顺序、旧/新Access拒绝及其他会话不受影响。诊断时H2复现过残留,而MySQL同一交错未复现;不能用MySQL一次通过声称所有并发交错已穷尽。 + +## 验证与发布 + +新批次必须更新源码manifest并重新专项、全量及同次JAR验证,旧A2通过不自动继承。这里只提供配置与行为说明,不授予提交、推送、发布或服务器修改权限。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 57dd431..cbbdbef 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -3210,7 +3210,7 @@ "岗位" ], "summary": "创建岗位", - "description": "仅企业账号可调用,新岗位默认 DRAFT 状态", + "description": "仅企业账号可调用,新岗位默认 DRAFT 状态。可选 mapLocation 保存岗位实际工作位置;缺省/null 为 UNSET,不推断企业注册地址。非 UNSET 必须具备实际工作地址、成对数字坐标及 GCJ02;位置参数非法返回 HTTP 400,业务校验为 1001。无高德密钥、图片或外部服务调用。", "operationId": "createJob", "requestBody": { "content": { @@ -3534,7 +3534,7 @@ "岗位" ], "summary": "更新岗位", - "description": "仅岗位所属企业可修改", + "description": "仅岗位所属企业可修改,跨企业岗位保持 404。mapLocation 是可选整体替换;缺省/null 且城市与工作地址未变时保持原值;城市或地址改变但未重传点位时自动失效为 UNSET。显式 status=UNSET 清空位置;非 UNSET 必须提供成对数字坐标、GCJ02 和更新后非空地址,非法位置参数 HTTP 400,业务校验为 1001;失败不产生部分写入。", "operationId": "updateJob", "parameters": [ { @@ -9387,14 +9387,14 @@ } } }, + "400": { + "description": "非法 period" + }, "401": { "description": "未登录" }, "403": { "description": "非 ADMIN" - }, - "400": { - "description": "非法 period" } } } @@ -9442,14 +9442,14 @@ } } }, + "400": { + "description": "非法参数" + }, "401": { "description": "未登录" }, "403": { "description": "非 ADMIN" - }, - "400": { - "description": "非法参数" } } } @@ -9534,14 +9534,14 @@ } } }, + "400": { + "description": "非法日期范围" + }, "401": { "description": "未登录" }, "403": { "description": "非 ADMIN" - }, - "400": { - "description": "非法日期范围" } } } @@ -9947,7 +9947,7 @@ "dataState": "SUFFICIENT" }, "SALARY_TRANSPARENCY": { - "averageScore": 4.0, + "averageScore": 4, "sampleCount": 4, "dataState": "SUFFICIENT" }, @@ -9972,7 +9972,7 @@ "dataState": "SUFFICIENT" }, "WORK_LIFE_BALANCE": { - "averageScore": 4.0, + "averageScore": 4, "sampleCount": 4, "dataState": "SUFFICIENT" } @@ -12147,6 +12147,110 @@ } } } + }, + "/companies/me/job-location-suggestions": { + "get": { + "tags": [ + "岗位" + ], + "summary": "搜索岗位工作地点候选", + "description": "仅已建立企业资料的 COMPANY 账号。city 与 keyword 必填、去除首尾空白后分别 1—40、2—80 字符,不接受控制字符或竖线;非法参数 HTTP 400/1001(缺参数为 400/400)。返回最多 10 个 GCJ02 地点,不代表真实办公地址已经平台核验,也不写岗位。无结果为 items=[]。企业选择并确认后调用既有岗位创建/编辑接口保存 mapLocation 和 workLocation。HTTP 429/429 为企业每分钟或全局每小时额度耗尽,Retry-After=60;HTTP 503/9001 为未配置密钥、Redis 或上游不可用,不得当作空结果。缺企业资料沿用 404/5001,非企业 403/2002。", + "operationId": "searchJobLocationSuggestions", + "parameters": [ + { + "name": "city", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 40, + "minLength": 1 + } + }, + { + "name": "keyword", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 80, + "minLength": 2 + } + } + ], + "responses": { + "200": { + "description": "候选地点数组,最多 10 项;无匹配时 items=[]", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "400": { + "description": "参数非法;业务校验 code=1001,缺少参数 code=400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "401": { + "description": "未登录或会话不可用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "403": { + "description": "非企业或不可用账号;code=2002", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "404": { + "description": "缺少企业资料;code=5001", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "429": { + "description": "额度耗尽;code=429,Retry-After=60", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + }, + "503": { + "description": "密钥未配置、Redis 或高德不可用;code=9001,不返回上游细节", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseSuggestions" + } + } + } + } + } + } } }, "components": { @@ -15216,6 +15320,10 @@ "description": "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", "maxLength": 255, "minLength": 0 + }, + "mapLocation": { + "$ref": "#/components/schemas/JobMapLocationRequest", + "description": "可选地图位置。缺省/null 创建为 UNSET;对象按整体校验保存。仅实际工作地点,不从企业注册地址推断。" } }, "required": [ @@ -15465,6 +15573,10 @@ "description": "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", "maxLength": 255, "minLength": 0 + }, + "mapLocation": { + "$ref": "#/components/schemas/JobMapLocationRequest", + "description": "可选地图位置整体替换。缺省/null 且城市与地址未变时保持;城市或地址变更但未重传位置时失效为 UNSET。显式 {status:UNSET} 清空。非 UNSET 必须成对坐标和 GCJ02,且更新后地址非空。" } } }, @@ -20975,6 +21087,10 @@ "type": "string", "format": "date-time", "description": "更新时间" + }, + "mapLocation": { + "$ref": "#/components/schemas/JobMapLocationView", + "description": "岗位实际工作位置;无位置时 status=UNSET,其他位置字段为 null;与地图展示技术解耦" } } }, @@ -21070,6 +21186,10 @@ "company": { "$ref": "#/components/schemas/JobDocCompanySummary", "description": "公开企业摘要(7 字段白名单)" + }, + "mapLocation": { + "$ref": "#/components/schemas/JobMapLocationView", + "description": "岗位实际工作位置;仅 CONFIRMED 用于确认点位展示;无位置时 status=UNSET,不返回企业注册地址" } } }, @@ -21570,7 +21690,10 @@ "additionalProperties": false, "properties": { "experienceType": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "pattern": "^(FULL_TIME|INTERNSHIP)$", "description": "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。" }, @@ -21695,6 +21818,241 @@ "content", "revision" ] + }, + "JobMapLocationRequest": { + "type": [ + "object", + "null" + ], + "description": "岗位地图位置整体替换对象。UNSET 清空全部地图字段;PENDING_CONFIRMATION/CONFIRMED 必须提供成对坐标及 GCJ02。无密钥、图片或地图 SDK 配置。", + "properties": { + "status": { + "type": "string", + "description": "UNSET 清空;PENDING_CONFIRMATION 待企业确认;CONFIRMED 企业已确认", + "enum": [ + "UNSET", + "PENDING_CONFIRMATION", + "CONFIRMED" + ], + "pattern": "^(UNSET|PENDING_CONFIRMATION|CONFIRMED)$" + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "经度,JSON number,最多 6 位小数;非 UNSET 时必填", + "maximum": 180, + "minimum": -180, + "multipleOf": 0.000001 + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "纬度,JSON number,最多 6 位小数;非 UNSET 时必填", + "maximum": 90, + "minimum": -90, + "multipleOf": 0.000001 + }, + "coordinateSystem": { + "type": [ + "string", + "null" + ], + "description": "坐标系;非 UNSET 时必须明确传 GCJ02,不接收 BD09/WGS84", + "pattern": "^GCJ02$" + }, + "poiId": { + "type": [ + "string", + "null" + ], + "description": "高德地点 ID,可省略;不作为服务端已核实地点的证明", + "maxLength": 80, + "minLength": 0 + }, + "poiName": { + "type": [ + "string", + "null" + ], + "description": "地点名称,可省略;trim 后空串清空,拒绝控制字符", + "maxLength": 120, + "minLength": 0 + } + }, + "required": [ + "status" + ] + }, + "JobMapLocationView": { + "type": "object", + "description": "岗位实际工作地点;仅 CONFIRMED 位置用于展示确认点位。UNSET 时坐标、坐标系、POI 均为 null。", + "properties": { + "provider": { + "type": "string", + "description": "地图提供方,固定 AMAP", + "enum": [ + "AMAP" + ] + }, + "status": { + "type": "string", + "description": "企业位置确认状态", + "enum": [ + "UNSET", + "PENDING_CONFIRMATION", + "CONFIRMED" + ] + }, + "longitude": { + "type": [ + "number", + "null" + ], + "description": "经度,最多 6 位小数", + "maximum": 180, + "minimum": -180, + "multipleOf": 0.000001 + }, + "latitude": { + "type": [ + "number", + "null" + ], + "description": "纬度,最多 6 位小数", + "maximum": 90, + "minimum": -90, + "multipleOf": 0.000001 + }, + "coordinateSystem": { + "type": [ + "string", + "null" + ], + "description": "坐标系,已设置时为 GCJ02", + "pattern": "^GCJ02$" + }, + "poiId": { + "type": [ + "string", + "null" + ], + "description": "高德地点 ID", + "maxLength": 80 + }, + "poiName": { + "type": [ + "string", + "null" + ], + "description": "地点名称", + "maxLength": 120 + } + }, + "required": [ + "provider", + "status" + ] + }, + "ApiResponseSuggestions": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Suggestions" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Suggestions": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobLocationSuggestion", + "maxItems": 10 + }, + "maxItems": 10 + } + } + }, + "JobLocationSuggestion": { + "type": "object", + "description": "高德候选地点;不是已确认的工作地址。企业选择后仍需通过岗位写接口确认。", + "properties": { + "poiId": { + "type": "string", + "maxLength": 80 + }, + "poiName": { + "type": "string", + "maxLength": 120 + }, + "address": { + "type": "string", + "description": "省市区及地址,可能为空;企业须补充实际办公地址", + "maxLength": 255 + }, + "city": { + "type": "string", + "maxLength": 40 + }, + "district": { + "type": "string", + "maxLength": 40 + }, + "longitude": { + "type": "number", + "maximum": 180, + "minimum": -180, + "multipleOf": 0.000001 + }, + "latitude": { + "type": "number", + "maximum": 90, + "minimum": -90, + "multipleOf": 0.000001 + }, + "coordinateSystem": { + "type": "string", + "enum": [ + "GCJ02" + ] + }, + "provider": { + "type": "string", + "enum": [ + "AMAP" + ] + } + }, + "required": [ + "address", + "city", + "coordinateSystem", + "district", + "latitude", + "longitude", + "poiId", + "poiName", + "provider" + ] } }, "securitySchemes": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 376d9e9..265aca9 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']) == 205, f"Expected 205 paths, got {len(d['paths'])}" +assert len(d['paths']) == 206, f"Expected 206 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 == 253, f"Expected 253 operations, got {operations}" +assert operations == 254, f"Expected 254 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() @@ -920,5 +920,63 @@ for _legacy_path in ('/profile/me', '/profile/me/preferences'): assert '409/6002' in paths[_legacy_path]['put']['description'] assert 'revision' in paths[_legacy_path]['put']['description'] print(' Legacy profile/preference writes share online resume revision; conditional requirement documented') +_map_schemas = d['components']['schemas'] +_map_request = _map_schemas['JobMapLocationRequest'] +_map_view = _map_schemas['JobMapLocationView'] +assert set(_map_request['type']) == {'object', 'null'} +assert set(_map_request['required']) == {'status'} +assert set(_map_view['required']) == {'provider', 'status'} +for _schema in (_map_request, _map_view): + _props = _schema['properties'] + assert set(_props['status']['enum']) == {'UNSET', 'PENDING_CONFIRMATION', 'CONFIRMED'} + for _axis, _limit in (('longitude', 180), ('latitude', 90)): + assert set(_props[_axis]['type']) == {'number', 'null'} + assert _props[_axis]['minimum'] == -_limit and _props[_axis]['maximum'] == _limit + assert _props[_axis]['multipleOf'] == 0.000001 + assert set(_props['coordinateSystem']['type']) == {'string', 'null'} + assert _props['coordinateSystem']['pattern'] == '^GCJ02$' + assert 'enum' not in _props['coordinateSystem'], 'nullable 坐标系不能被非空 enum 排除 null' + assert _props['poiId']['maxLength'] == 80 + assert _props['poiName']['maxLength'] == 120 +assert _map_view['properties']['provider']['enum'] == ['AMAP'] +for _model, _ref in (('JobCreateRequest', 'JobMapLocationRequest'), + ('JobUpdateRequest', 'JobMapLocationRequest'), + ('JobDetailViewDoc', 'JobMapLocationView'), + ('JobPublicViewDoc', 'JobMapLocationView')): + assert 'mapLocation' in _map_schemas[_model]['properties'], f'{_model} 缺少 mapLocation' + assert _map_schemas[_model]['properties']['mapLocation']['$ref'] == '#/components/schemas/' + _ref + assert 'mapLocation' not in _map_schemas[_model].get('required', []) +assert 'UNSET' in _map_schemas['JobUpdateRequest']['properties']['mapLocation']['description'] +assert not any('/map-preview' in _path for _path in paths), '本批不提供静态图接口' +print(' Job map location: optional grouped coordinates, GCJ02, status and detail contracts verified') +_search_path = '/companies/me/job-location-suggestions' +assert _search_path in paths and set(paths[_search_path]) == {'get'} +_search = paths[_search_path]['get'] +assert {p['name'] for p in _search['parameters']} == {'city', 'keyword'} +for _name, _min, _max in (('city', 1, 40), ('keyword', 2, 80)): + _param = next(p for p in _search['parameters'] if p['name'] == _name) + assert _param['required'] is True and _param['in'] == 'query' + assert _param['schema']['minLength'] == _min and _param['schema']['maxLength'] == _max +assert {'200', '400', '401', '403', '404', '429', '503'} <= set(_search['responses']) +assert '9001' in _search['responses']['503']['description'] +assert 'Retry-After=60' in _search['responses']['429']['description'] +assert '不写岗位' in _search['description'] +assert '不代表真实办公地址已经平台核验' in _search['description'] +_suggestion = _map_schemas['JobLocationSuggestion'] +assert set(_suggestion['required']) == {'poiId', 'poiName', 'address', 'city', 'district', + 'longitude', 'latitude', 'coordinateSystem', 'provider'} +assert _suggestion['properties']['coordinateSystem']['enum'] == ['GCJ02'] +assert _suggestion['properties']['provider']['enum'] == ['AMAP'] +for _axis, _limit in (('longitude', 180), ('latitude', 90)): + _field = _suggestion['properties'][_axis] + assert _field['type'] == 'number' and _field['minimum'] == -_limit and _field['maximum'] == _limit + assert _field['multipleOf'] == 0.000001 +for _name, _max in (('poiId', 80), ('poiName', 120), ('address', 255)): + assert _suggestion['properties'][_name]['maxLength'] == _max +_search_response = resolve_local_ref(d, _search['responses']['200']['content']['application/json']['schema']['$ref']) +_items = resolve_local_ref(d, _search_response['properties']['data']['$ref'])['properties']['items'] +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') print("Valid JSON: True") print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java index 01fb9a5..0177d15 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java @@ -287,6 +287,10 @@ public class AuthSessionService { if (userId == null || sessionId == null || sessionId.isBlank()) { throw new InvalidTokenException(); } + // 与登录、刷新和密码变更统一 users -> auth_tokens 锁顺序。 + // 必须先等刷新事务提交,再开始撤销语句,避免遗漏尚未可见的后继 Token。 + // 不新增 active 校验,保留已失效/不存在会话的幂等登出语义。 + userRepository.findByIdForUpdate(userId); authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java index 4989ca1..96ee013 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java @@ -10,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.math.BigDecimal; /** * 岗位实体 @@ -66,6 +67,25 @@ public class Job { @Column(name = "work_location", length = 255) private String workLocation; + @Column(name = "map_longitude", precision = 10, scale = 6) + private BigDecimal longitude; + + @Column(name = "map_latitude", precision = 10, scale = 6) + private BigDecimal latitude; + + @Column(name = "map_coordinate_system", length = 10) + private String coordinateSystem; + + @Builder.Default + @Column(name = "map_location_status", nullable = false, length = 24) + private String locationStatus = "UNSET"; + + @Column(name = "map_poi_id", length = 80) + private String poiId; + + @Column(name = "map_poi_name", length = 120) + private String poiName; + @Column(name = "status", nullable = false, length = 20) @Builder.Default private String status = "DRAFT"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index d78b059..12b45b2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -54,7 +54,7 @@ public class JobController { // ── 企业端:创建岗位 ──────────────────────────────────────── - @Operation(summary = "创建岗位", description = "仅企业账号可调用,新岗位默认 DRAFT 状态") + @Operation(summary = "创建岗位", description = "仅企业账号可调用,新岗位默认 DRAFT 状态。可选 mapLocation 保存岗位实际工作位置;缺省/null 为 UNSET,不推断企业注册地址。非 UNSET 必须具备实际工作地址、成对数字坐标及 GCJ02;位置参数非法返回 HTTP 400,业务校验为 1001。无高德密钥、图片或外部服务调用。") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC, content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class))) @PostMapping @@ -68,7 +68,7 @@ public class JobController { // ── 企业端:编辑岗位 ──────────────────────────────────────── - @Operation(summary = "更新岗位", description = "仅岗位所属企业可修改") + @Operation(summary = "更新岗位", description = "仅岗位所属企业可修改,跨企业岗位保持 404。mapLocation 是可选整体替换;缺省/null 且城市与工作地址未变时保持原值;城市或地址改变但未重传点位时自动失效为 UNSET。显式 status=UNSET 清空位置;非 UNSET 必须提供成对数字坐标、GCJ02 和更新后非空地址,非法位置参数 HTTP 400,业务校验为 1001;失败不产生部分写入。") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC, content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class))) @PutMapping("/{jobId}") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobMapLocationPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobMapLocationPolicy.java new file mode 100644 index 0000000..f2b928f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobMapLocationPolicy.java @@ -0,0 +1,63 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.job.dto.JobMapLocationRequest; +import com.mydreamjob.job.dto.JobMapLocationView; +import java.math.BigDecimal; +import java.util.Set; + +/** 不联网的位置成组校验;不得从公司注册地址、城市中心或旧地址猜测点位。 */ +final class JobMapLocationPolicy { + private JobMapLocationPolicy() {} + + static JobMapLocationView empty() { + return new JobMapLocationView("AMAP", "UNSET", null, null, null, null, null); + } + + static JobMapLocationView normalize(JobMapLocationRequest request, String address) { + if (request == null) return empty(); + String status = request.getStatus(); + if (status == null || !Set.of("UNSET", "PENDING_CONFIRMATION", "CONFIRMED").contains(status)) bad(); + String poiId = text(request.getPoiId(), 80); + String poiName = text(request.getPoiName(), 120); + if ("UNSET".equals(status)) { + if (request.getLongitude() != null || request.getLatitude() != null + || request.getCoordinateSystem() != null || poiId != null || poiName != null) bad(); + return empty(); + } + if (address == null || address.isBlank() || !"GCJ02".equals(request.getCoordinateSystem())) bad(); + coordinate(request.getLongitude(), 180); + coordinate(request.getLatitude(), 90); + return new JobMapLocationView("AMAP", status, request.getLongitude(), request.getLatitude(), + "GCJ02", poiId, poiName); + } + + static JobMapLocationView view(Job job) { + if (job.getLocationStatus() == null || "UNSET".equals(job.getLocationStatus())) return empty(); + return new JobMapLocationView("AMAP", job.getLocationStatus(), job.getLongitude(), job.getLatitude(), + job.getCoordinateSystem(), job.getPoiId(), job.getPoiName()); + } + + static void apply(Job job, JobMapLocationView location) { + job.setLocationStatus(location.status()); + job.setLongitude(location.longitude()); + job.setLatitude(location.latitude()); + job.setCoordinateSystem(location.coordinateSystem()); + job.setPoiId(location.poiId()); + job.setPoiName(location.poiName()); + } + + private static void coordinate(BigDecimal value, int limit) { + if (value == null || value.abs().compareTo(BigDecimal.valueOf(limit)) > 0 + || value.scale() > 6) bad(); + } + + private static String text(String value, int max) { + if (value == null) return null; + if (value.length() > max || value.codePoints().anyMatch(Character::isISOControl)) bad(); + return value.isBlank() ? null : value.trim(); + } + + private static void bad() { throw new BusinessException(ErrorCode.BAD_REQUEST); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 8ae72f9..3d2e437 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -54,6 +54,7 @@ public class JobService { */ @Transactional public Job createJob(Long companyId, JobCreateRequest req) { + var mapLocation = JobMapLocationPolicy.normalize(req.getMapLocation(), normalizeText(req.getWorkLocation())); Job job = Job.builder() .companyId(companyId) .title(req.getTitle()) @@ -70,6 +71,7 @@ public class JobService { .workLocation(normalizeText(req.getWorkLocation())) .status(JobStatus.DRAFT.name()) .build(); + JobMapLocationPolicy.apply(job, mapLocation); return jobRepository.save(job); } @@ -95,6 +97,16 @@ public class JobService { requireVerifiedCompany(companyId); } + String effectiveAddress = req.getWorkLocation() == null + ? job.getWorkLocation() : normalizeText(req.getWorkLocation()); + boolean addressChanged = !Objects.equals(normalizeText(job.getWorkLocation()), normalizeText(effectiveAddress)); + boolean cityChanged = req.getCity() != null + && !Objects.equals(normalizeText(job.getCity()), normalizeText(req.getCity())); + // 在修改托管实体前校验;地址/城市改变时不能沿用旧点位。 + var mapLocation = req.getMapLocation() != null + ? JobMapLocationPolicy.normalize(req.getMapLocation(), effectiveAddress) + : (addressChanged || cityChanged ? JobMapLocationPolicy.empty() : null); + if (req.getTitle() != null) job.setTitle(req.getTitle()); if (req.getDescription() != null) job.setDescription(req.getDescription()); if (req.getRequirements() != null) job.setRequirements(req.getRequirements()); @@ -111,6 +123,8 @@ public class JobService { // salaryMonths:null 保持原值(本批不提供显式清空);非 null 必须为 1..24。 if (req.getSalaryMonths() != null) job.setSalaryMonths(validateSalaryMonths(req.getSalaryMonths())); + if (mapLocation != null) JobMapLocationPolicy.apply(job, mapLocation); + return jobRepository.save(job); } @@ -272,6 +286,7 @@ public class JobService { view.put("benefits", benefitsFromJson(job.getBenefitsJson())); view.put("salaryMonths", job.getSalaryMonths()); view.put("workLocation", job.getWorkLocation()); + view.put("mapLocation", JobMapLocationPolicy.view(job)); view.put("status", job.getStatus()); view.put("publishedAt", job.getPublishedAt()); view.put("createdAt", job.getCreatedAt()); @@ -298,6 +313,7 @@ public class JobService { view.put("benefits", benefitsFromJson(job.getBenefitsJson())); view.put("salaryMonths", job.getSalaryMonths()); view.put("workLocation", job.getWorkLocation()); + view.put("mapLocation", JobMapLocationPolicy.view(job)); view.put("status", job.getStatus()); view.put("publishedAt", job.getPublishedAt()); view.put("createdAt", job.getCreatedAt()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java index 5fad2c2..b5f4fc7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.Valid; import jakarta.validation.constraints.Size; import lombok.Data; @@ -75,4 +76,8 @@ public class JobCreateRequest { description = "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;" + "非空去两端空白后保存;超过 255 字符拒绝且不截断。") private String workLocation; + + @Valid + @Schema(description = "可选地图位置。缺省/null 创建为 UNSET;对象按整体校验保存。仅实际工作地点,不从企业注册地址推断。") + private JobMapLocationRequest mapLocation; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java index c85e49d..508c288 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java @@ -59,6 +59,9 @@ public class JobDetailViewDoc { @Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) private String workLocation; + @Schema(description = "岗位实际工作位置;无位置时 status=UNSET,其他位置字段为 null;与地图展示技术解耦") + private JobMapLocationView mapLocation; + @Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") private String status; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationRequest.java new file mode 100644 index 0000000..0047225 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationRequest.java @@ -0,0 +1,42 @@ +package com.mydreamjob.job.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; + +/** 岗位实际工作地点;确认仅表示企业确认,不代表平台认证地址真实性。 */ +@Data +@Schema(types = {"object", "null"}, description = "岗位地图位置整体替换对象。UNSET 清空全部地图字段;PENDING_CONFIRMATION/CONFIRMED 必须提供成对坐标及 GCJ02。无密钥、图片或地图 SDK 配置。") +public class JobMapLocationRequest { + @NotNull + @Pattern(regexp = "^(UNSET|PENDING_CONFIRMATION|CONFIRMED)$") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"UNSET", "PENDING_CONFIRMATION", "CONFIRMED"}, + description = "UNSET 清空;PENDING_CONFIRMATION 待企业确认;CONFIRMED 企业已确认") + private String status; + + @DecimalMin("-180") @DecimalMax("180") @Digits(integer = 3, fraction = 6) + @JsonDeserialize(using = StrictCoordinateDeserializer.class) + @Schema(types = {"number", "null"}, description = "经度,JSON number,最多 6 位小数;非 UNSET 时必填", minimum = "-180", maximum = "180", multipleOf = 0.000001) + private BigDecimal longitude; + + @DecimalMin("-90") @DecimalMax("90") @Digits(integer = 3, fraction = 6) + @JsonDeserialize(using = StrictCoordinateDeserializer.class) + @Schema(types = {"number", "null"}, description = "纬度,JSON number,最多 6 位小数;非 UNSET 时必填", minimum = "-90", maximum = "90", multipleOf = 0.000001) + private BigDecimal latitude; + + @Pattern(regexp = "^GCJ02$") + @Schema(types = {"string", "null"}, pattern = "^GCJ02$", description = "坐标系;非 UNSET 时必须明确传 GCJ02,不接收 BD09/WGS84") + private String coordinateSystem; + + @Size(max = 80) + @Schema(types = {"string", "null"}, maxLength = 80, description = "高德地点 ID,可省略;不作为服务端已核实地点的证明") + private String poiId; + + @Size(max = 120) + @Schema(types = {"string", "null"}, maxLength = 120, description = "地点名称,可省略;trim 后空串清空,拒绝控制字符") + private String poiName; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationView.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationView.java new file mode 100644 index 0000000..25e17ab --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationView.java @@ -0,0 +1,18 @@ +package com.mydreamjob.job.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.math.BigDecimal; + +/** 与静态图片、JS API 或原生 SDK 解耦的位置响应。 */ +@Schema(description = "岗位实际工作地点;仅 CONFIRMED 位置用于展示确认点位。UNSET 时坐标、坐标系、POI 均为 null。") +@JsonInclude(JsonInclude.Include.ALWAYS) +public record JobMapLocationView( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = "AMAP", description = "地图提供方,固定 AMAP") String provider, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"UNSET", "PENDING_CONFIRMATION", "CONFIRMED"}, description = "企业位置确认状态") String status, + @Schema(types = {"number", "null"}, minimum = "-180", maximum = "180", multipleOf = 0.000001, description = "经度,最多 6 位小数") BigDecimal longitude, + @Schema(types = {"number", "null"}, minimum = "-90", maximum = "90", multipleOf = 0.000001, description = "纬度,最多 6 位小数") BigDecimal latitude, + @Schema(types = {"string", "null"}, pattern = "^GCJ02$", description = "坐标系,已设置时为 GCJ02") String coordinateSystem, + @Schema(types = {"string", "null"}, maxLength = 80, description = "高德地点 ID") String poiId, + @Schema(types = {"string", "null"}, maxLength = 120, description = "地点名称") String poiName) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java index 796f1f6..49671ef 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java @@ -59,6 +59,9 @@ public class JobPublicViewDoc { @Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) private String workLocation; + @Schema(description = "岗位实际工作位置;仅 CONFIRMED 用于确认点位展示;无位置时 status=UNSET,不返回企业注册地址") + private JobMapLocationView mapLocation; + @Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") private String status; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java index 44d2b53..4963c22 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; import lombok.Data; import java.util.List; @@ -70,4 +71,8 @@ public class JobUpdateRequest { description = "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;" + "非空去两端空白后保存;超过 255 字符拒绝且不截断。") private String workLocation; + + @Valid + @Schema(description = "可选地图位置整体替换。缺省/null 且城市与地址未变时保持;城市或地址变更但未重传位置时失效为 UNSET。显式 {status:UNSET} 清空。非 UNSET 必须成对坐标和 GCJ02,且更新后地址非空。") + private JobMapLocationRequest mapLocation; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictCoordinateDeserializer.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictCoordinateDeserializer.java new file mode 100644 index 0000000..98c7eb7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictCoordinateDeserializer.java @@ -0,0 +1,16 @@ +package com.mydreamjob.job.dto; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import java.io.IOException; +import java.math.BigDecimal; + +/** 坐标只接受 JSON 数字;禁止 Jackson 将字符串或其他类型静默转换成坐标。 */ +public final class StrictCoordinateDeserializer extends JsonDeserializer { + @Override + public BigDecimal deserialize(JsonParser parser, DeserializationContext context) throws IOException { + if (parser.currentToken().isNumeric()) return parser.getDecimalValue(); + return (BigDecimal) context.handleUnexpectedToken(BigDecimal.class, parser); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapPlaceClient.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapPlaceClient.java new file mode 100644 index 0000000..87587c1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapPlaceClient.java @@ -0,0 +1,160 @@ +package com.mydreamjob.job.location; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** 固定 HTTPS 目的地,不接受用户 URL,不跟随重定向,不重试、不记录原始响应。 */ +@Component +@EnableConfigurationProperties(AmapTimeouts.class) +public class AmapPlaceClient { + private static final int MAX_BYTES = 262144; + private final Transport transport; + private final ObjectMapper mapper; + private final String key; + + @Autowired + public AmapPlaceClient(ObjectMapper mapper, + @Value("${app.amap.web-service-key:${AMAP_WEB_SERVICE_KEY:}}") String key, + AmapTimeouts timeouts) { + this(defaultTransport(timeouts), mapper, key); + } + + /** 非Spring调用同样使用正式默认预算;不绕过配置边界。 */ + public AmapPlaceClient(ObjectMapper mapper, String key) { + this(mapper, key, new AmapTimeouts(AmapTimeouts.DEFAULT_CONNECT_MS, AmapTimeouts.DEFAULT_TOTAL_MS)); + } + + AmapPlaceClient(Transport transport, ObjectMapper mapper, String key) { + this.transport = transport; + this.mapper = mapper; + this.key = key == null ? "" : key.trim(); + } + + interface Transport { byte[] get(URI uri) throws Exception; } + + private static Transport defaultTransport(AmapTimeouts timeouts) { + HttpClient http = HttpClient.newBuilder().connectTimeout(timeouts.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER).build(); + return httpTransport(http, timeouts.totalTimeout()); + } + + static Transport httpTransport(HttpClient http, Duration timeout) { + return uri -> { + HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build(); + var pending = http.sendAsync(request, info -> new LimitedBody()); + try { + // 独立总超时覆盖响应头和响应体,避免只收到响应头后无限等待。 + var response = pending.get(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + if (response.statusCode() != 200) throw new IOException("upstream status"); + return response.body(); + } catch (Exception ex) { + pending.cancel(true); + throw ex; + } + }; + } + + /** 在接收阶段限制内存;超限立即取消,不先完整下载再检查。 */ + static final class LimitedBody implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private Flow.Subscription subscription; + private int received; + private boolean failed; + public CompletionStage getBody() { return delegate.getBody(); } + public void onSubscribe(Flow.Subscription value) { subscription = value; delegate.onSubscribe(value); } + public void onNext(List buffers) { + if (failed) return; + long size = buffers.stream().mapToLong(ByteBuffer::remaining).sum(); + if (size > MAX_BYTES - received) { + failed = true; + subscription.cancel(); + delegate.onError(new IOException("upstream size")); + } else { + received += (int) size; + delegate.onNext(buffers); + } + } + public void onError(Throwable error) { if (!failed) { failed = true; delegate.onError(error); } } + public void onComplete() { if (!failed) delegate.onComplete(); } + } + + public boolean configured() { return !key.isBlank(); } + + public List search(String city, String keyword) { + if (!configured()) throw new JobLocationSearchException(false); + try { + URI uri = URI.create("https://restapi.amap.com/v3/place/text?key=" + encode(key) + + "&city=" + encode(city) + "&keywords=" + encode(keyword) + + "&citylimit=true&offset=10&page=1&extensions=base&output=JSON"); + byte[] body = transport.get(uri); + if (body == null || body.length > MAX_BYTES) throw new IOException("upstream size"); + JsonNode root = mapper.readTree(body); + if (!"1".equals(root.path("status").asText()) || !root.path("pois").isArray()) + throw new IOException("upstream payload"); + var items = new ArrayList(); + var ids = new HashSet(); + // 最多解析服务端约定的前 10 个候选;异常候选丢弃,不猜测坐标。 + for (int i = 0; i < Math.min(10, root.path("pois").size()); i++) { + JsonNode poi = root.path("pois").get(i); + try { + String id = text(poi, "id", 80), name = text(poi, "name", 120); + String[] coords = text(poi, "location", 64).split(",", -1); + if (id.isBlank() || name.isBlank() || coords.length != 2 || ids.contains(id)) continue; + BigDecimal lng = coordinate(coords[0], 180), lat = coordinate(coords[1], 90); + String province = text(poi, "pname", 40), cityName = text(poi, "cityname", 40); + String district = text(poi, "adname", 40), street = text(poi, "address", 255); + String address = province + (province.equals(cityName) ? "" : cityName) + district + street; + if (address.length() > 255) continue; + items.add(new JobLocationSuggestion(id, name, address, cityName, district, + lng, lat, "GCJ02", "AMAP")); + ids.add(id); + } catch (IllegalArgumentException ignored) { /* 无效候选不交给前端保存 */ } + } + return List.copyOf(items); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + throw new JobLocationSearchException(false); + } catch (Exception ignored) { + // 不传播 cause;其中可能包含密钥、查询或原始高德响应。 + throw new JobLocationSearchException(false); + } + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private static String text(JsonNode node, String field, int max) { + JsonNode value = node.path(field); + if (!value.isTextual()) return ""; // 高德空地址可能是 [] + String text = value.asText().trim(); + if (text.length() > max || text.codePoints().anyMatch(Character::isISOControl)) + throw new IllegalArgumentException(); + return text; + } + + private static BigDecimal coordinate(String text, int bound) { + if (!text.matches("-?\\d{1,3}(\\.\\d{1,12})?")) throw new IllegalArgumentException(); + BigDecimal value = new BigDecimal(text); + if (value.abs().compareTo(BigDecimal.valueOf(bound)) > 0) throw new IllegalArgumentException(); + return value.setScale(6, RoundingMode.HALF_UP); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapTimeouts.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapTimeouts.java new file mode 100644 index 0000000..0121e6d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapTimeouts.java @@ -0,0 +1,24 @@ +package com.mydreamjob.job.location; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import java.time.Duration; + +/** 有界的服务端HTTP预算;不承载Key,不接受请求参数控制。非法配置启动失败。 */ +@ConfigurationProperties(prefix = "app.amap") +public record AmapTimeouts(@DefaultValue("8000") int connectTimeoutMs, + @DefaultValue("12000") int totalTimeoutMs) { + public static final int DEFAULT_CONNECT_MS = 8000; + public static final int DEFAULT_TOTAL_MS = 12000; + + public AmapTimeouts { + if (connectTimeoutMs < 500 || connectTimeoutMs > 10000 + || totalTimeoutMs < 1000 || totalTimeoutMs > 20000 + || totalTimeoutMs <= connectTimeoutMs) { + throw new IllegalArgumentException("invalid AMAP timeout configuration: connect=500..10000ms, total=1000..20000ms and total>connect required"); + } + } + + public Duration connectTimeout() { return Duration.ofMillis(connectTimeoutMs); } + public Duration totalTimeout() { return Duration.ofMillis(totalTimeoutMs); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchController.java new file mode 100644 index 0000000..d663aa3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchController.java @@ -0,0 +1,74 @@ +package com.mydreamjob.job.location; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyService; +import com.mydreamjob.security.CurrentUserAccessor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/companies/me/job-location-suggestions") +@Tag(name = "岗位") +@RequiredArgsConstructor +public class JobLocationSearchController { + private final CurrentUserAccessor currentUser; + private final CompanyService companies; + private final JobLocationSearchLimiter limiter; + private final AmapPlaceClient client; + private final RequestIdAccessor requestIds; + + public record Suggestions(@ArraySchema(maxItems = 10, schema = @Schema(implementation = JobLocationSuggestion.class)) + List items) {} + + @GetMapping(produces = "application/json") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "候选地点数组,最多 10 项;无匹配时 items=[]"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数非法;业务校验 code=1001,缺少参数 code=400"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录或会话不可用"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非企业或不可用账号;code=2002"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "缺少企业资料;code=5001"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "额度耗尽;code=429,Retry-After=60"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "密钥未配置、Redis 或高德不可用;code=9001,不返回上游细节") + }) + @Operation(operationId = "searchJobLocationSuggestions", summary = "搜索岗位工作地点候选", description = "仅已建立企业资料的 COMPANY 账号。city 与 keyword 必填、去除首尾空白后分别 1—40、2—80 字符,不接受控制字符或竖线;非法参数 HTTP 400/1001(缺参数为 400/400)。返回最多 10 个 GCJ02 地点,不代表真实办公地址已经平台核验,也不写岗位。无结果为 items=[]。企业选择并确认后调用既有岗位创建/编辑接口保存 mapLocation 和 workLocation。HTTP 429/429 为企业每分钟或全局每小时额度耗尽,Retry-After=60;HTTP 503/9001 为未配置密钥、Redis 或上游不可用,不得当作空结果。缺企业资料沿用 404/5001,非企业 403/2002。") + public ApiResponse search( + @Parameter(required = true, schema = @Schema(minLength = 1, maxLength = 40)) @RequestParam String city, + @Parameter(required = true, schema = @Schema(minLength = 2, maxLength = 80)) @RequestParam String keyword, + HttpServletRequest request) { + Company company = companies.findCompanyByOwnerUserId(currentUser.requireUserId()); + if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); + city = validate(city, 1, 40); + keyword = validate(keyword, 2, 80); + if (!client.configured()) throw new JobLocationSearchException(false); + limiter.acquire(company.getId()); + return ApiResponse.ok(new Suggestions(client.search(city, keyword)), requestIds.get(request)); + } + + private String validate(String value, int min, int max) { + if (value == null || value.codePoints().anyMatch(Character::isISOControl)) + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid location search parameter"); + value = value.trim(); + if (value.length() < min || value.length() > max || value.indexOf('|') >= 0) + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid location search parameter"); + return value; + } + + @ExceptionHandler(JobLocationSearchException.class) + public ResponseEntity> unavailable(JobLocationSearchException ex, HttpServletRequest request) { + if (ex.rateLimited) return ResponseEntity.status(429).header("Retry-After", "60") + .body(ApiResponse.tooManyRequests(ex.getMessage(), requestIds.get(request))); + return ResponseEntity.status(503).body(ApiResponse.error(9001, ex.getMessage(), requestIds.get(request))); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchException.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchException.java new file mode 100644 index 0000000..7c5654c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchException.java @@ -0,0 +1,11 @@ +package com.mydreamjob.job.location; + +/** 只携带固定安全消息,不保留含 Key 的上游 URL、响应或异常。 */ +final class JobLocationSearchException extends RuntimeException { + final boolean rateLimited; + + JobLocationSearchException(boolean rateLimited) { + super(rateLimited ? "job location search rate limited" : "job location search temporarily unavailable"); + this.rateLimited = rateLimited; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchLimiter.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchLimiter.java new file mode 100644 index 0000000..55cb78d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchLimiter.java @@ -0,0 +1,57 @@ +package com.mydreamjob.job.location; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import java.time.Instant; +import java.util.List; + +/** 原子计数、固定窗口、失败关闭;{amap} 保持 Redis Cluster 同一槽。 */ +@Component +public class JobLocationSearchLimiter { + private static final DefaultRedisScript SCRIPT = new DefaultRedisScript<>(""" + local c = tonumber(redis.call('GET', KEYS[1]) or '0') + local g = tonumber(redis.call('GET', KEYS[2]) or '0') + if c >= tonumber(ARGV[1]) or g >= tonumber(ARGV[2]) then return 0 end + redis.call('INCR', KEYS[1]); redis.call('EXPIRE', KEYS[1], 120) + redis.call('INCR', KEYS[2]); redis.call('EXPIRE', KEYS[2], 7200) + return 1 + """, Long.class); + private final java.util.function.Supplier redis; + private final int companyPerMinute; + private final int globalPerHour; + + @Autowired + public JobLocationSearchLimiter(ObjectProvider redis, + @Value("${app.amap.company-searches-per-minute:10}") int companyPerMinute, + @Value("${app.amap.global-searches-per-hour:1000}") int globalPerHour) { + this.redis = redis::getIfAvailable; + this.companyPerMinute = Math.max(0, companyPerMinute); + this.globalPerHour = Math.max(0, globalPerHour); + } + + JobLocationSearchLimiter(StringRedisTemplate redis, int companyPerMinute, int globalPerHour) { + this.redis = () -> redis; + this.companyPerMinute = Math.max(0, companyPerMinute); + this.globalPerHour = Math.max(0, globalPerHour); + } + + public void acquire(long companyId) { + long now = Instant.now().getEpochSecond(); + Long allowed; + try { + StringRedisTemplate template = redis.get(); + if (template == null) throw new JobLocationSearchException(false); + allowed = template.execute(SCRIPT, List.of("job-location:{amap}:company:" + companyId + ":" + now / 60, + "job-location:{amap}:global:" + now / 3600), + Integer.toString(companyPerMinute), Integer.toString(globalPerHour)); + } catch (Exception ignored) { + throw new JobLocationSearchException(false); + } + if (allowed == null) throw new JobLocationSearchException(false); + if (allowed != 1L) throw new JobLocationSearchException(true); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSuggestion.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSuggestion.java new file mode 100644 index 0000000..1a784b5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSuggestion.java @@ -0,0 +1,16 @@ +package com.mydreamjob.job.location; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; + +@Schema(description = "高德候选地点;不是已确认的工作地址。企业选择后仍需通过岗位写接口确认。") +public record JobLocationSuggestion( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, maxLength = 80) String poiId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, maxLength = 120) String poiName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, maxLength = 255, description = "省市区及地址,可能为空;企业须补充实际办公地址") String address, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, maxLength = 40) String city, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, maxLength = 40) String district, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minimum = "-180", maximum = "180", multipleOf = 0.000001) BigDecimal longitude, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minimum = "-90", maximum = "90", multipleOf = 0.000001) BigDecimal latitude, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = "GCJ02") String coordinateSystem, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = "AMAP") String provider) {} diff --git a/mydreamjob-server/src/main/resources/db/migration/V60__job_map_location.sql b/mydreamjob-server/src/main/resources/db/migration/V60__job_map_location.sql new file mode 100644 index 0000000..22346c8 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V60__job_map_location.sql @@ -0,0 +1,8 @@ +-- 岗位位置;保留旧地址,不自动解析、猜测坐标或复制企业注册地址。 +-- DECIMAL/VARCHAR 保持 MySQL 5.7、8 与 H2 兼容,不依赖 JSON/空间类型。 +ALTER TABLE jobs ADD COLUMN map_longitude DECIMAL(10,6) NULL; +ALTER TABLE jobs ADD COLUMN map_latitude DECIMAL(10,6) NULL; +ALTER TABLE jobs ADD COLUMN map_coordinate_system VARCHAR(10) NULL; +ALTER TABLE jobs ADD COLUMN map_location_status VARCHAR(24) NOT NULL DEFAULT 'UNSET'; +ALTER TABLE jobs ADD COLUMN map_poi_id VARCHAR(80) NULL; +ALTER TABLE jobs ADD COLUMN map_poi_name VARCHAR(120) 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 b0ca81e..17e35f6 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("192 paths 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(192); - assertThat(opCount - moduleOperations).as("235 operations 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(235); + assertThat(pathCount - modulePaths).as("193 paths 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(193); + assertThat(opCount - moduleOperations).as("236 operations 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(236); 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/AuthLogoutRefreshSerializationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthLogoutRefreshSerializationIntegrationTest.java new file mode 100644 index 0000000..7966cb5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthLogoutRefreshSerializationIntegrationTest.java @@ -0,0 +1,129 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import java.sql.Connection; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 真实事务和SQL,两个顺序均观察数据库阻塞;不靠同时起跑或额外补登出来掩盖竞态。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties="spring.datasource.url=jdbc:h2:mem:auth_logout_serialization;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000") +class AuthLogoutRefreshSerializationIntegrationTest { + private static final AtomicLong SEQ=new AtomicLong(); + @Autowired UserRepository users; + @Autowired AuthSessionService sessions; + @Autowired AuthTokenRepository tokens; + @Autowired JdbcTemplate jdbc; + @Autowired MockMvc mvc; + @MockBean RedisTemplate redis; + @SpyBean AuditService audit; + private long userId; + private String blockingQuery; + private AuthSessionService.SessionTokens victim,other; + + /** 仅供隔离真实MySQL专项使用;全量默认使用本类独立H2库。 */ + @DynamicPropertySource static void externalDatabase(DynamicPropertyRegistry registry) { + String url=System.getProperty("auth.serialization.jdbc"); + if(url!=null){ + if(!url.startsWith("jdbc:mysql://127.0.0.1:"))throw new IllegalArgumentException("local isolated MySQL required"); + registry.add("spring.datasource.url",()->url); + registry.add("spring.datasource.username",()->System.getProperty("auth.serialization.user","root")); + registry.add("spring.datasource.password",()->System.getProperty("auth.serialization.password","")); + registry.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect"); + registry.add("spring.datasource.hikari.data-source-properties.allowPublicKeyRetrieval",()->true); + registry.add("spring.datasource.hikari.data-source-properties.useSSL",()->false); + registry.add("spring.datasource.hikari.data-source-properties.serverTimezone",()->"Asia/Shanghai"); + } + } + + @BeforeEach void setup() throws Exception { + var user=users.save(User.builder().phone("199002"+String.format("%05d",SEQ.incrementAndGet())) + .role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build()); + userId=user.getId(); + victim=sessions.createSession(user,Instant.now(),null); + other=sessions.createSession(user,Instant.now(),null); + try(Connection c=jdbc.getDataSource().getConnection()){ + String product=c.getMetaData().getDatabaseProductName(); + if("H2".equals(product))blockingQuery="select count(*) from information_schema.sessions where blocker_id is not null and session_state='BLOCKED' and lower(executing_statement) like '%users%'"; + else if("MySQL".equals(product))blockingQuery=c.getMetaData().getDatabaseMajorVersion()>=8 + ?"select count(*) from performance_schema.data_lock_waits w join performance_schema.data_locks l on w.requesting_engine_lock_id=l.engine_lock_id where l.object_name='users'" + :"select count(*) from information_schema.innodb_lock_waits w join information_schema.innodb_locks l on w.requested_lock_id=l.lock_id where l.lock_table like '%`.`users`'"; + else throw new IllegalStateException("unsupported diagnostic database"); + } + } + + private void awaitDatabaseBlocking() throws Exception { + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(System.nanoTime()0)return; + // MySQL 5.7 的锁元数据只有距上次读取超过 100ms 才刷新; + // 10ms 忙轮询会一直复用等待发生前的缓存,必须留出刷新间隔。 + Thread.sleep(200); + } + fail("required database row-lock blocking was not observed"); + } + + private void assertRevokedAndOtherSessionUnaffected() throws Exception { + assertEquals(0,tokens.countActiveSessionTokens(userId,victim.sessionId(),AuthSessionService.toLocal(Instant.now()))); + assertFalse(sessions.isSessionActive(userId,victim.sessionId(),Instant.now())); + mvc.perform(get("/auth/me").header("Authorization","Bearer "+victim.accessToken())).andExpect(status().isUnauthorized()); + assertTrue(sessions.isSessionActive(userId,other.sessionId(),Instant.now())); + mvc.perform(get("/auth/me").header("Authorization","Bearer "+other.accessToken())).andExpect(status().isOk()); + } + + @Test void refreshFirstLogoutWaitsAndRevokesCommittedSuccessor() throws Exception { + var paused=new CountDownLatch(1);var release=new CountDownLatch(1); + doAnswer(call->{paused.countDown();assertTrue(release.await(12,TimeUnit.SECONDS));return call.callRealMethod();}) + .when(audit).logTokenRefreshed(anyLong(),anyString(),any(),any()); + var pool=Executors.newFixedThreadPool(2); + try { + var refresh=pool.submit(()->sessions.refresh(victim.refreshToken(),Instant.now(),null)); + assertTrue(paused.await(10,TimeUnit.SECONDS)); + var logout=pool.submit(()->sessions.logout(userId,victim.sessionId(),Instant.now(),null)); + awaitDatabaseBlocking();release.countDown(); + var successor=refresh.get(15,TimeUnit.SECONDS);logout.get(15,TimeUnit.SECONDS); + assertRevokedAndOtherSessionUnaffected(); + mvc.perform(get("/auth/me").header("Authorization","Bearer "+successor.accessToken())).andExpect(status().isUnauthorized()); + } finally {release.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } + + @Test void logoutFirstRefreshWaitsAndCannotIssueSuccessor() throws Exception { + var paused=new CountDownLatch(1);var release=new CountDownLatch(1); + doAnswer(call->{paused.countDown();assertTrue(release.await(12,TimeUnit.SECONDS));return call.callRealMethod();}) + .when(audit).logSessionRevoked(anyLong(),anyString(),eq(AuthSessionService.REASON_LOGOUT),any(),any()); + var pool=Executors.newFixedThreadPool(2); + try { + var logout=pool.submit(()->sessions.logout(userId,victim.sessionId(),Instant.now(),null)); + assertTrue(paused.await(10,TimeUnit.SECONDS)); + var refresh=pool.submit(()->{ + assertThrows(InvalidTokenException.class,()->sessions.refresh(victim.refreshToken(),Instant.now(),null)); + }); + awaitDatabaseBlocking();release.countDown();logout.get(15,TimeUnit.SECONDS);refresh.get(15,TimeUnit.SECONDS); + assertRevokedAndOtherSessionUnaffected(); + } finally {release.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java index 0512796..6510054 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java @@ -108,6 +108,16 @@ class AuthSessionServiceTest { () -> authSessionService.logout(1L, " ", java.time.Instant.now(), null)); } + @Test + void logoutLocksUserBeforeRevokingSession() { + when(authTokenHasher.hash("sid:synthetic-session")).thenReturn("a".repeat(64)); + var now=java.time.Instant.now(); + authSessionService.logout(1L,"synthetic-session",now,null); + var order=org.mockito.Mockito.inOrder(userRepository,authTokenRepository); + order.verify(userRepository).findByIdForUpdate(1L); + order.verify(authTokenRepository).revokeSession(1L,"synthetic-session",AuthSessionService.toLocal(now),AuthSessionService.REASON_LOGOUT); + } + @Test void sessionRefIsIrreversibleAndFixedLength() { when(authTokenHasher.hash(anyString())).thenReturn("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); 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 46a67d2..9a44059 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(209); + assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息、8项在线简历及企业地点搜索操作均受保护").isEqualTo(210); 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/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index 8c48740..f618597 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(253).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(205); + assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(206); 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 f3b5649..7a12ebc 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(253).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(205); + assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(206); 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 new file mode 100644 index 0000000..75a671d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java @@ -0,0 +1,203 @@ +package com.mydreamjob.job; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.*; +import com.mydreamjob.company.*; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.*; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +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.*; + +/** H2 全栈验证:真实 Controller 绑定、鉴权、Flyway V60、持久化和运行时契约;不联网。 */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties="spring.datasource.url=jdbc:h2:mem:job_map_location;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@Transactional +class JobMapLocationIntegrationTest { + private static final AtomicInteger SEQ=new AtomicInteger(); + private static final String LOCATION=""" + {"status":"CONFIRMED","longitude":116.397463,"latitude":39.909187, + "coordinateSystem":"GCJ02","poiId":" public-landmark ","poiName":" 公开地标测试 "} + """; + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired UserRepository users; + @Autowired CompanyRepository companies; + @Autowired AuthSessionService sessions; + @Autowired EntityManager em; + @Autowired JdbcTemplate jdbc; + @MockBean RedisTemplate redis; + @MockBean com.mydreamjob.job.location.AmapPlaceClient placeClient; + @MockBean com.mydreamjob.job.location.JobLocationSearchLimiter searchLimiter; + private long companyId; + private String token; + + @BeforeEach void setup(){ + var owner=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet())) + .role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).verified(true).build()); + companyId=companies.saveAndFlush(Company.builder().ownerUserId(owner.getId()) + .name("岗位地图隔离测试企业").address("注册地址不得作为工作点位").verified(true).build()).getId(); + token=sessions.createSession(owner,Instant.now(),new AuditContext()).accessToken(); + } + + @Test void createPersistReadAndPublish() throws Exception { + long id=create(LOCATION); + em.flush();em.clear(); + mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.mapLocation.provider").value("AMAP")) + .andExpect(jsonPath("$.data.mapLocation.longitude").value(116.397463)) + .andExpect(jsonPath("$.data.mapLocation.poiId").value("public-landmark")); + mvc.perform(get("/jobs/public/"+id)).andExpect(status().isNotFound()); + mvc.perform(patch("/jobs/"+id+"/status").header("Authorization","Bearer "+token) + .contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"PUBLISHED\"}")) + .andExpect(status().isOk()); + mvc.perform(get("/jobs/public/"+id)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.mapLocation.coordinateSystem").value("GCJ02")); + mvc.perform(patch("/jobs/"+id+"/status").header("Authorization","Bearer "+token) + .contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"CLOSED\"}")) + .andExpect(status().isOk()); + mvc.perform(get("/jobs/public/"+id)).andExpect(status().isOk()); + } + + @Test void legacyDefaultAndMigrationColumns() throws Exception { + long id=create(null); + mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.mapLocation.status").value("UNSET")) + .andExpect(jsonPath("$.data.mapLocation.longitude").isEmpty()); + assertEquals(1,jdbc.queryForObject("select count(*) from \"flyway_schema_history\" where \"version\"='60' and \"success\"=true",Integer.class)); + jdbc.update("insert into jobs(company_id,title,description,city,employment_type,status) values(?,?,?,?,?,?)", + companyId,"旧版 SQL 岗位","描述","北京","FULL_TIME","DRAFT"); + assertEquals("UNSET",jdbc.queryForObject("select map_location_status from jobs where title=?",String.class,"旧版 SQL 岗位")); + } + + @Test void updateAddressInvalidatesAndExplicitUnsetKeepsAddress() throws Exception { + long id=create(LOCATION); + update(id,"{\"title\":\"仅改标题\"}").andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED")); + update(id,"{\"workLocation\":\"新工作地址\"}").andExpect(jsonPath("$.data.mapLocation.status").value("UNSET")) + .andExpect(jsonPath("$.data.mapLocation.longitude").isEmpty()); + update(id,"{\"mapLocation\":"+LOCATION+"}").andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED")); + update(id,"{\"mapLocation\":{\"status\":\"UNSET\"}}") + .andExpect(jsonPath("$.data.mapLocation.poiId").isEmpty()) + .andExpect(jsonPath("$.data.workLocation").value("新工作地址")); + } + + @ParameterizedTest + @ValueSource(strings={"{}","{\"status\":\"UNKNOWN\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":181,\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":91,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":39,\"coordinateSystem\":\"BD09\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116.1234567,\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"UNSET\",\"longitude\":116}", + "{\"status\":\"CONFIRMED\",\"longitude\":[],\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":\"116.3\",\"latitude\":39,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":true,\"coordinateSystem\":\"GCJ02\"}", + "{\"status\":\"CONFIRMED\",\"longitude\":116,\"latitude\":39,\"coordinateSystem\":\"GCJ02\",\"poiName\":\"a\\nb\"}"}) + void rejectInvalidBindingWithoutPartialWrite(String location) throws Exception { + long id=create(LOCATION); + mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"不得保存\",\"mapLocation\":"+location+"}")) + .andExpect(status().isBadRequest()); + mvc.perform(get("/jobs/"+id).header("Authorization","Bearer "+token)) + .andExpect(jsonPath("$.data.title").value("地图测试岗位")) + .andExpect(jsonPath("$.data.mapLocation.status").value("CONFIRMED")); + } + + @Test void missingAddressAndAnonymousWriteRejected() throws Exception { + var body=mapper.readTree(createBody(LOCATION)).deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("workLocation",""); + mvc.perform(post("/jobs").header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body))).andExpect(status().isBadRequest()); + mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(createBody(LOCATION))) + .andExpect(status().isUnauthorized()); + } + + @Test void otherCompanyCannotModifyLocation() throws Exception { + long id=create(LOCATION); + var other=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet())) + .role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).verified(true).build()); + companies.saveAndFlush(Company.builder().ownerUserId(other.getId()).name("另一测试企业").verified(true).build()); + String otherToken=sessions.createSession(other,Instant.now(),new AuditContext()).accessToken(); + mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+otherToken).contentType(MediaType.APPLICATION_JSON) + .content("{\"mapLocation\":{\"status\":\"UNSET\"}}")).andExpect(status().isNotFound()); + } + + @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()); + 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()); + for(String code:java.util.List.of("200","400","401","403","404","429","503")) + assertTrue(search.path("responses").has(code),"missing response "+code); + assertTrue(search.path("responses").path("200").path("content").has("application/json")); + var schemas=spec.path("components").path("schemas"); + assertTrue(schemas.path("JobCreateRequest").path("properties").has("mapLocation")); + assertTrue(schemas.path("JobUpdateRequest").path("properties").has("mapLocation")); + assertTrue(schemas.path("JobPublicViewDoc").path("properties").has("mapLocation")); + assertTrue(schemas.path("JobMapLocationView").path("properties").has("coordinateSystem")); + assertEquals("^GCJ02$",schemas.path("JobMapLocationView").path("properties").path("coordinateSystem").path("pattern").asText()); + assertTrue(schemas.path("JobMapLocationRequest").path("type").toString().contains("null")); + String output=System.getProperty("job.map.runtimeContract"); + if(output!=null)Files.writeString(Path.of(output),raw); + } + + @Test void locationSearchRealAuthenticationBoundary() throws Exception { + String path="/companies/me/job-location-suggestions"; + mvc.perform(get(path).param("city","北京").param("keyword","测试路")) + .andExpect(status().isUnauthorized()); + var student=users.saveAndFlush(User.builder().phone("1990088"+String.format("%04d",SEQ.incrementAndGet())) + .role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).build()); + String studentToken=sessions.createSession(student,Instant.now(),new AuditContext()).accessToken(); + mvc.perform(get(path).header("Authorization","Bearer "+studentToken).param("city","北京").param("keyword","测试路")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + org.mockito.Mockito.verifyNoInteractions(placeClient,searchLimiter); + org.mockito.Mockito.when(placeClient.configured()).thenReturn(true); + org.mockito.Mockito.when(placeClient.search("北京","测试路")).thenReturn(java.util.List.of()); + mvc.perform(get(path).header("Authorization","Bearer "+token).param("city","北京").param("keyword","测试路")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isEmpty()); + org.mockito.Mockito.verify(searchLimiter).acquire(companyId); + var owner=users.findById(companies.findById(companyId).orElseThrow().getOwnerUserId()).orElseThrow(); + jdbc.update("update users set is_active=false where id=?",owner.getId());em.clear(); + mvc.perform(get(path).header("Authorization","Bearer "+token).param("city","北京").param("keyword","测试路")) + .andExpect(status().isUnauthorized()); + org.mockito.Mockito.verify(placeClient,org.mockito.Mockito.times(1)).search("北京","测试路"); + } + + private long create(String location) throws Exception { + String raw=mvc.perform(post("/jobs").header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON) + .content(createBody(location))).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return mapper.readTree(raw).path("data").path("id").asLong(); + } + private String createBody(String location){ + return "{\"title\":\"地图测试岗位\",\"description\":\"合成测试\",\"city\":\"北京\",\"employmentType\":\"FULL_TIME\",\"workLocation\":\"实际工作地址\"" + +(location==null?"":",\"mapLocation\":"+location)+"}"; + } + private org.springframework.test.web.servlet.ResultActions update(long id,String body) throws Exception { + return mvc.perform(put("/jobs/"+id).header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON) + .content(body)).andExpect(status().isOk()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationMySqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationMySqlMigrationTest.java new file mode 100644 index 0000000..5d61525 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationMySqlMigrationTest.java @@ -0,0 +1,64 @@ +package com.mydreamjob.job; + +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.*; + +/** 独立 V59→V60:真实 MySQL 两版本,旧地址保留、六列、精度、历史 checksum 和幂等。 */ +class JobMapLocationMySqlMigrationTest { + @Test void mysql57V59ToV60() throws Exception { verify("mysql:5.7.36"); } + @Test void mysql8V59ToV60() throws Exception { verify("mysql:8.4"); } + + private 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(); + } + private void verify(String image) throws Exception { + try(var db=new MySQLContainer<>(image).withDatabaseName("job_map_migration") + .withUsername("maptest").withPassword("synthetic-test-only") + .withUrlParam("characterEncoding","UTF-8") + .withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024))) { + db.start(); + try(var c=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var s=c.createStatement()) { + s.execute("ALTER DATABASE job_map_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + flyway(db,"59").migrate(); + List history=history(s); + s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(930001,'19900889999','COMPANY',1,1)"); + s.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(930001,930001,'位置迁移测试企业',1)"); + s.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status,work_location) " + +"VALUES(930001,930001,'迁移测试岗位','合成数据','北京','FULL_TIME','DRAFT','旧工作地址')"); + assertEquals(1,flyway(db,"60").migrate().migrationsExecuted); + assertEquals(history,history(s)); + try(var r=s.executeQuery("SELECT work_location,map_location_status,map_longitude,map_latitude,map_poi_name FROM jobs WHERE id=930001")) { + assertTrue(r.next());assertEquals("旧工作地址",r.getString(1));assertEquals("UNSET",r.getString(2)); + assertNull(r.getBigDecimal(3));assertNull(r.getBigDecimal(4));assertNull(r.getString(5)); + } + try(var r=s.executeQuery("SELECT count(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='jobs' " + +"AND column_name IN ('map_longitude','map_latitude','map_coordinate_system','map_location_status','map_poi_id','map_poi_name')")) { + assertTrue(r.next());assertEquals(6,r.getInt(1)); + } + s.executeUpdate("UPDATE jobs SET map_longitude=116.397463,map_latitude=39.909187,map_coordinate_system='GCJ02'," + +"map_location_status='CONFIRMED',map_poi_id='test-poi',map_poi_name='公开地标' WHERE id=930001"); + try(var r=s.executeQuery("SELECT map_longitude,map_latitude FROM jobs WHERE id=930001")) { + assertTrue(r.next());assertEquals("116.397463",r.getBigDecimal(1).toPlainString()); + assertEquals("39.909187",r.getBigDecimal(2).toPlainString()); + } + assertEquals(0,flyway(db,"60").migrate().migrationsExecuted);flyway(db,"60").validate(); + assertEquals(history,history(s)); + } + } + } + private List history(Statement s) throws SQLException { + var rows=new ArrayList(); + try(var r=s.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'60' ORDER BY installed_rank")) { + while(r.next())rows.add(r.getString(1)+"|"+r.getString(2)+"|"+r.getBoolean(3)); + } + return rows; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationPolicyTest.java new file mode 100644 index 0000000..ef8243d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationPolicyTest.java @@ -0,0 +1,127 @@ +package com.mydreamjob.job; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.dto.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.math.BigDecimal; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class JobMapLocationPolicyTest { + static JobMapLocationRequest confirmed() { + var r = new JobMapLocationRequest(); + r.setStatus("CONFIRMED"); r.setCoordinateSystem("GCJ02"); + r.setLongitude(new BigDecimal("116.397463")); r.setLatitude(new BigDecimal("39.909187")); + r.setPoiId(" test-poi "); r.setPoiName(" 公开地标测试点 "); + return r; + } + + @Test void legacyAndExplicitUnsetAreEmpty() { + assertEquals("UNSET", JobMapLocationPolicy.normalize(null, "旧地址").status()); + var r = new JobMapLocationRequest(); r.setStatus("UNSET"); + assertNull(JobMapLocationPolicy.normalize(r, null).longitude()); + assertEquals("AMAP", JobMapLocationPolicy.empty().provider()); + } + + @Test void confirmedAndPendingNormalizeWithoutNetwork() { + var r = confirmed(); + assertEquals("test-poi", JobMapLocationPolicy.normalize(r,"实际工作地址").poiId()); + assertEquals("公开地标测试点", JobMapLocationPolicy.normalize(r,"地址").poiName()); + r.setStatus("PENDING_CONFIRMATION"); + assertEquals("PENDING_CONFIRMATION", JobMapLocationPolicy.normalize(r,"地址").status()); + } + + @ParameterizedTest @ValueSource(strings={"WGS84","BD09","gcj02",""}) + void rejectOtherCoordinateSystems(String system) { + var r=confirmed();r.setCoordinateSystem(system);assertBad(r,"地址"); + } + + @Test void rejectMissingGroupOrAddress() { + var r=confirmed();r.setLatitude(null);assertBad(r,"地址"); + r=confirmed();r.setLongitude(null);assertBad(r,"地址"); + r=confirmed();r.setCoordinateSystem(null);assertBad(r,"地址"); + assertBad(confirmed(),null);assertBad(confirmed()," "); + } + + @Test void rejectRangeAndPrecision() { + var r=confirmed();r.setLongitude(new BigDecimal("180.000001"));assertBad(r,"地址"); + r=confirmed();r.setLatitude(new BigDecimal("-90.000001"));assertBad(r,"地址"); + r=confirmed();r.setLongitude(new BigDecimal("116.1234567"));assertBad(r,"地址"); + } + + @Test void acceptRangeBoundaries() { + var r=confirmed();r.setLongitude(new BigDecimal("-180"));r.setLatitude(new BigDecimal("90")); + assertEquals(new BigDecimal("-180"),JobMapLocationPolicy.normalize(r,"地址").longitude()); + } + + @Test void rejectBadStatusOrConflictingUnset() { + var r=confirmed();r.setStatus(null);assertBad(r,"地址"); + r.setStatus("APPROVED");assertBad(r,"地址"); + r.setStatus("UNSET");assertBad(r,"地址"); + } + + @Test void rejectOverlengthAndControlCharacters() { + var r=confirmed();r.setPoiName("x".repeat(121));assertBad(r,"地址"); + r=confirmed();r.setPoiId("x".repeat(81));assertBad(r,"地址"); + r=confirmed();r.setPoiName("点位\u0001名称");assertBad(r,"地址"); + } + + @Test void createWithoutMapAndCreateWithMapRemainCompatible() { + var repo=mock(JobRepository.class);when(repo.save(any())).thenAnswer(i->i.getArgument(0)); + var service=new JobService(repo,mock(CompanyRepository.class),new ObjectMapper()); + var r=new JobCreateRequest();r.setWorkLocation(" 工作地址 "); + var legacy=service.createJob(1L,r);assertEquals("UNSET",legacy.getLocationStatus()); + r.setMapLocation(confirmed());var saved=service.createJob(1L,r); + assertEquals("CONFIRMED",saved.getLocationStatus());assertEquals("test-poi",saved.getPoiId()); + assertEquals(service.toView(saved).get("mapLocation"),service.toPublicView(saved).get("mapLocation")); + } + + @Test void updatePreservesOrInvalidatesAndCanReconfirm() { + var repo=mock(JobRepository.class); + var job=Job.builder().id(10L).companyId(1L).city("北京").workLocation("旧地址").build(); + JobMapLocationPolicy.apply(job,JobMapLocationPolicy.normalize(confirmed(),"旧地址")); + when(repo.findByIdAndCompanyId(10L,1L)).thenReturn(Optional.of(job)); + when(repo.save(any())).thenAnswer(i->i.getArgument(0)); + var service=new JobService(repo,mock(CompanyRepository.class),new ObjectMapper()); + service.updateJob(1L,10L,new JobUpdateRequest());assertEquals("CONFIRMED",job.getLocationStatus()); + var r=new JobUpdateRequest();r.setWorkLocation("新地址");service.updateJob(1L,10L,r); + assertEquals("UNSET",job.getLocationStatus());assertNull(job.getLongitude()); + r.setMapLocation(confirmed());service.updateJob(1L,10L,r);assertEquals("CONFIRMED",job.getLocationStatus()); + r=new JobUpdateRequest();r.setCity("上海");service.updateJob(1L,10L,r); + assertEquals("UNSET",job.getLocationStatus());assertNull(job.getPoiName()); + } + + @Test void invalidMapDoesNotMutateOtherFieldsOrSave() { + var repo=mock(JobRepository.class); + var job=Job.builder().id(10L).companyId(1L).title("原名").workLocation("地址").build(); + when(repo.findByIdAndCompanyId(10L,1L)).thenReturn(Optional.of(job)); + var service=new JobService(repo,mock(CompanyRepository.class),new ObjectMapper()); + var r=new JobUpdateRequest();r.setTitle("不应保存");r.setMapLocation(confirmed());r.getMapLocation().setLatitude(null); + assertThrows(BusinessException.class,()->service.updateJob(1L,10L,r)); + assertEquals("原名",job.getTitle());verify(repo,never()).save(any()); + } + + @Test void explicitUnsetClearsAndUnauthorizedOwnerCannotWrite() { + var repo=mock(JobRepository.class); + var job=Job.builder().id(10L).companyId(1L).workLocation("地址").build(); + JobMapLocationPolicy.apply(job,JobMapLocationPolicy.normalize(confirmed(),"地址")); + when(repo.findByIdAndCompanyId(10L,1L)).thenReturn(Optional.of(job)); + when(repo.save(any())).thenAnswer(i->i.getArgument(0)); + var service=new JobService(repo,mock(CompanyRepository.class),new ObjectMapper()); + var r=new JobUpdateRequest();r.setMapLocation(new JobMapLocationRequest());r.getMapLocation().setStatus("UNSET"); + service.updateJob(1L,10L,r);assertNull(job.getLongitude());assertEquals("地址",job.getWorkLocation()); + when(repo.findByIdAndCompanyId(10L,2L)).thenReturn(Optional.empty()); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(),assertThrows(BusinessException.class,()->service.updateJob(2L,10L,r)).getErrorCode()); + } + + private static void assertBad(JobMapLocationRequest r,String address) { + assertEquals(ErrorCode.BAD_REQUEST.getCode(),assertThrows(BusinessException.class,()->JobMapLocationPolicy.normalize(r,address)).getErrorCode()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceClientTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceClientTest.java new file mode 100644 index 0000000..eae8ec0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceClientTest.java @@ -0,0 +1,98 @@ +package com.mydreamjob.job.location; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.Flow; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AmapPlaceClientTest { + private static final String POI = """ + {"id":"TEST_POI","name":"合成办公点","pname":"北京市","cityname":"北京市", + "adname":"海淀区","address":"测试路1号","location":"116.1234567,39.9876543"} + """; + private AmapPlaceClient client(String json) { + return new AmapPlaceClient(uri -> json.getBytes(StandardCharsets.UTF_8), new ObjectMapper(), "synthetic-key"); + } + @Test void fixedHostEncodedQueryAndNormalizedResult() { + var client = new AmapPlaceClient(uri -> { + assertEquals("https", uri.getScheme()); + assertEquals("restapi.amap.com", uri.getHost()); + assertEquals("/v3/place/text", uri.getPath()); + String query = URLDecoder.decode(uri.getRawQuery(), StandardCharsets.UTF_8); + assertTrue(query.contains("keywords=测试 & = # 地址")); + assertTrue(query.contains("citylimit=true&offset=10&page=1")); + return ("{\"status\":\"1\",\"pois\":[" + POI + "]}").getBytes(StandardCharsets.UTF_8); + }, new ObjectMapper(), "synthetic-key"); + var items = client.search("北京", "测试 & = # 地址"); + assertEquals(1, items.size()); + var item = items.get(0); + assertEquals("北京市海淀区测试路1号", item.address()); + assertEquals("116.123457", item.longitude().toPlainString()); + assertEquals("39.987654", item.latitude().toPlainString()); + assertEquals("GCJ02", item.coordinateSystem()); + assertEquals("AMAP", item.provider()); + } + @Test void emptyPoisIsSuccessfulEmptyArray() { + assertTrue(client("{\"status\":\"1\",\"pois\":[]}").search("北京", "测试").isEmpty()); + } + @Test void invalidCoordinatesAndDuplicateIdsAreExcluded() { + String invalid = POI.replace("116.1234567,39.9876543", "181,39"); + String payload = "{\"status\":\"1\",\"pois\":[" + invalid + "," + POI + "," + POI + "]}"; + assertEquals(1, client(payload).search("北京", "测试").size()); + } + @Test void upstreamEmptyAddressArrayDoesNotCrash() { + var items = client("{\"status\":\"1\",\"pois\":[" + POI.replace("\"测试路1号\"", "[]") + "]}") + .search("北京", "测试"); + assertEquals("北京市海淀区", items.get(0).address()); + } + @ParameterizedTest @ValueSource(strings={"{}", "not json", "{\"status\":\"0\",\"info\":\"secret\"}", + "{\"status\":\"1\",\"pois\":null}"}) + void invalidProviderResponseIsNotEmptySuccess(String json) { + var ex = assertThrows(JobLocationSearchException.class, () -> client(json).search("北京", "测试")); + assertFalse(ex.rateLimited); + assertNull(ex.getCause()); + assertFalse(ex.toString().contains("secret")); + } + @Test void missingKeyNeverCallsTransport() throws Exception { + var transport = mock(AmapPlaceClient.Transport.class); + var client = new AmapPlaceClient(transport, new ObjectMapper(), " "); + assertFalse(client.configured()); + assertThrows(JobLocationSearchException.class, () -> client.search("北京", "测试")); + verifyNoInteractions(transport); + } + @Test void networkFailureSanitized() { + var client = new AmapPlaceClient(uri -> {throw new IOException("secret-key-in-url");},new ObjectMapper(),"synthetic"); + var ex = assertThrows(JobLocationSearchException.class, () -> client.search("北京", "测试")); + assertNull(ex.getCause()); + assertFalse(ex.toString().contains("secret")); + } + @Test void resultIsCappedAtTen() { + String pois = java.util.stream.IntStream.range(0, 15).mapToObj(i -> POI.replace("TEST_POI", "POI"+i)) + .collect(java.util.stream.Collectors.joining(",")); + assertEquals(10,client("{\"status\":\"1\",\"pois\":["+pois+"]}").search("北京","测试").size()); + } + @Test void oversizedBodyCancelsWhileStreaming() { + var body = new AmapPlaceClient.LimitedBody(); + var subscription = mock(Flow.Subscription.class); + body.onSubscribe(subscription); + body.onNext(List.of(ByteBuffer.allocate(262144))); + body.onNext(List.of(ByteBuffer.allocate(1))); + verify(subscription).cancel(); + assertThrows(java.util.concurrent.CompletionException.class, () -> body.getBody().toCompletableFuture().join()); + } + @Test void subscriberCompletesValidBody() { + var body = new AmapPlaceClient.LimitedBody(); + body.onSubscribe(mock(Flow.Subscription.class)); + body.onNext(List.of(ByteBuffer.wrap(new byte[]{1,2,3}))); + body.onComplete(); + assertArrayEquals(new byte[]{1,2,3},body.getBody().toCompletableFuture().join()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceHttpTransportTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceHttpTransportTest.java new file mode 100644 index 0000000..5a8c8ff --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceHttpTransportTest.java @@ -0,0 +1,47 @@ +package com.mydreamjob.job.location; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import static org.junit.jupiter.api.Assertions.*; + +/** 只请求本机合成 HTTP 服务,不消耗高德额度。 */ +class AmapPlaceHttpTransportTest { + HttpServer server; + @BeforeEach void start() throws Exception { + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0); + server.start(); + } + @AfterEach void stop() { server.stop(0); } + URI uri(String path) { return URI.create("http://127.0.0.1:"+server.getAddress().getPort()+path); } + AmapPlaceClient.Transport transport(Duration timeout) { + return AmapPlaceClient.httpTransport(HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(),timeout); + } + @Test void readsSmallBodyOverRealSocket() throws Exception { + server.createContext("/ok",e->{try(e){e.sendResponseHeaders(200,2);e.getResponseBody().write(new byte[]{1,2});}}); + assertArrayEquals(new byte[]{1,2},transport(Duration.ofSeconds(2)).get(uri("/ok"))); + } + @Test void doesNotFollowRedirects() { + var redirected=new java.util.concurrent.atomic.AtomicInteger(); + server.createContext("/redirect",e->{try(e){e.getResponseHeaders().set("Location",uri("/should-not-read").toString());e.sendResponseHeaders(302,-1);}}); + server.createContext("/should-not-read",e->{redirected.incrementAndGet();try(e){e.sendResponseHeaders(200,-1);}}); + assertThrows(Exception.class,()->transport(Duration.ofSeconds(2)).get(uri("/redirect"))); + assertEquals(0,redirected.get()); + } + @Test void bodyLargerThanLimitIsRejected() { + server.createContext("/large",e->{try(e){e.sendResponseHeaders(200,262145);e.getResponseBody().write(new byte[262145]);}}); + assertThrows(Exception.class,()->transport(Duration.ofSeconds(2)).get(uri("/large"))); + } + @Test void slowBodyAfterHeadersIsBoundedByTotalTimeout() { + server.createContext("/slow",e->{try(e){e.sendResponseHeaders(200,2);e.getResponseBody().write(1);e.getResponseBody().flush(); + try {Thread.sleep(600);} catch(InterruptedException ignored){Thread.currentThread().interrupt();} + try{e.getResponseBody().write(2);}catch(java.io.IOException ignored){} + }}); + assertThrows(Exception.class,()->transport(Duration.ofMillis(100)).get(uri("/slow"))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapTimeoutsTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapTimeoutsTest.java new file mode 100644 index 0000000..c269ba5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapTimeoutsTest.java @@ -0,0 +1,48 @@ +package com.mydreamjob.job.location; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class AmapTimeoutsTest { + private final ApplicationContextRunner context=new ApplicationContextRunner() + .withBean(ObjectMapper.class,ObjectMapper::new).withUserConfiguration(AmapPlaceClient.class) + .withPropertyValues("app.amap.web-service-key="); + + @Test void defaultsBoundToActualClient() { + context.run(ctx->{ + assertThat(ctx).hasNotFailed().hasSingleBean(AmapPlaceClient.class).hasSingleBean(AmapTimeouts.class); + assertEquals(8000,ctx.getBean(AmapTimeouts.class).connectTimeoutMs()); + assertEquals(12000,ctx.getBean(AmapTimeouts.class).totalTimeoutMs()); + assertFalse(ctx.getBean(AmapPlaceClient.class).configured()); + }); + } + + @Test void explicitPropertiesOverrideDefaults() { + context.withPropertyValues("app.amap.connect-timeout-ms=1000","app.amap.total-timeout-ms=6000").run(ctx->{ + assertThat(ctx).hasNotFailed(); + var t=ctx.getBean(AmapTimeouts.class); + assertEquals(java.time.Duration.ofMillis(1000),t.connectTimeout()); + assertEquals(java.time.Duration.ofMillis(6000),t.totalTimeout()); + }); + } + + @ParameterizedTest @CsvSource({"500,1000","10000,20000","8000,12000"}) + void acceptsInclusiveBoundaries(int connect,int total) { + assertDoesNotThrow(()->new AmapTimeouts(connect,total)); + } + + @ParameterizedTest @CsvSource({"0,12000","499,12000","10001,15000","8000,999","8000,20001","8000,8000","9000,8000","-1,12000"}) + void invalidBudgetsFailDuringBinding(int connect,int total) { + context.withPropertyValues("app.amap.connect-timeout-ms="+connect,"app.amap.total-timeout-ms="+total).run(ctx-> + assertThat(ctx).hasFailed()); + } + + @Test void nonNumericConfigurationFailsStartup() { + context.withPropertyValues("app.amap.connect-timeout-ms=not-a-number").run(ctx->assertThat(ctx).hasFailed()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchControllerTest.java new file mode 100644 index 0000000..4463732 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchControllerTest.java @@ -0,0 +1,93 @@ +package com.mydreamjob.job.location; + +import com.mydreamjob.common.exception.*; +import com.mydreamjob.common.logging.RequestIdAccessor; +import com.mydreamjob.company.*; +import com.mydreamjob.security.CurrentUserAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class JobLocationSearchControllerTest { + static final String PATH="/companies/me/job-location-suggestions"; + CurrentUserAccessor current = mock(CurrentUserAccessor.class); + CompanyService companies = mock(CompanyService.class); + JobLocationSearchLimiter limiter = mock(JobLocationSearchLimiter.class); + AmapPlaceClient client = mock(AmapPlaceClient.class); + MockMvc mvc; + @BeforeEach void setup() { + var ids = new RequestIdAccessor(); + mvc=MockMvcBuilders.standaloneSetup(new JobLocationSearchController(current,companies,limiter,client,ids)) + .setControllerAdvice(new GlobalExceptionHandler(ids,new ErrorHttpStatusRegistry())).build(); + when(current.requireUserId()).thenReturn(1L); + when(companies.findCompanyByOwnerUserId(1L)).thenReturn(Company.builder().id(42L).build()); + when(client.configured()).thenReturn(true); + when(client.search(anyString(),anyString())).thenReturn(List.of()); + } + @Test void trimsInputReturnsTypedEmptyAndNeverWrites() throws Exception { + mvc.perform(get(PATH).param("city"," 北京 ").param("keyword"," 测试路 ")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items").isEmpty()); + var order=inOrder(companies,limiter,client); + order.verify(companies).findCompanyByOwnerUserId(1L); + order.verify(client).configured(); + order.verify(limiter).acquire(42L); + order.verify(client).search("北京","测试路"); + verifyNoMoreInteractions(companies); + } + @Test void anonymousDeniedBeforeQuotaOrProvider() throws Exception { + when(current.requireUserId()).thenThrow(new InsufficientAuthenticationException("authentication required")); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")).andExpect(status().isUnauthorized()); + verifyNoInteractions(companies,limiter,client); + } + @Test void studentDeniedBeforeProvider() throws Exception { + when(companies.findCompanyByOwnerUserId(1L)).thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")).andExpect(status().isForbidden()); + verifyNoInteractions(limiter,client); + } + @Test void missingCompanyDeniedBeforeProvider() throws Exception { + when(companies.findCompanyByOwnerUserId(1L)).thenReturn(null); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(5001)); + verifyNoInteractions(limiter,client); + } + @ParameterizedTest @ValueSource(strings={"", " ", "x", "a\nb", "北京|上海"}) + void invalidKeywordDoesNotConsumeQuota(String keyword) throws Exception { + mvc.perform(get(PATH).param("city","北京").param("keyword",keyword)).andExpect(status().isBadRequest()); + verifyNoInteractions(limiter,client); + } + @Test void missingAndOversizedParametersRejected() throws Exception { + mvc.perform(get(PATH).param("city","北京")).andExpect(status().isBadRequest()); + mvc.perform(get(PATH).param("city","a".repeat(41)).param("keyword","测试")).andExpect(status().isBadRequest()); + mvc.perform(get(PATH).param("city","北京").param("keyword","a".repeat(81))).andExpect(status().isBadRequest()); + verifyNoInteractions(limiter,client); + } + @Test void missingKeyIs503WithoutQuotaOrNetwork() throws Exception { + when(client.configured()).thenReturn(false); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")) + .andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value(9001)); + verifyNoInteractions(limiter); + verify(client,never()).search(anyString(),anyString()); + } + @Test void quotaExceededIs429AndDoesNotCallProvider() throws Exception { + doThrow(new JobLocationSearchException(true)).when(limiter).acquire(42L); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")) + .andExpect(status().isTooManyRequests()).andExpect(header().string("Retry-After","60")) + .andExpect(jsonPath("$.code").value(429)); + verify(client,never()).search(anyString(),anyString()); + } + @Test void upstreamUnavailableIs503NotEmptySuccess() throws Exception { + when(client.search(anyString(),anyString())).thenThrow(new JobLocationSearchException(false)); + mvc.perform(get(PATH).param("city","北京").param("keyword","测试")) + .andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value(9001)) + .andExpect(jsonPath("$.data").doesNotExist()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchLimiterTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchLimiterTest.java new file mode 100644 index 0000000..f1336a9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchLimiterTest.java @@ -0,0 +1,50 @@ +package com.mydreamjob.job.location; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class JobLocationSearchLimiterTest { + @Test void missingRedisBeanClosesWithoutBreakingApplicationStartup() { + var provider=mock(org.springframework.beans.factory.ObjectProvider.class); + var limiter=new JobLocationSearchLimiter((org.springframework.beans.factory.ObjectProvider)provider,10,1000); + assertFalse(assertThrows(JobLocationSearchException.class,()->limiter.acquire(1)).rateLimited); + } + @Test void atomicScriptContainsCompanyAndGlobalBudget() { + var redis = mock(StringRedisTemplate.class); + when(redis.execute(any(RedisScript.class), anyList(), any(), any())).thenAnswer(call -> { + RedisScript script = call.getArgument(0); + List keys = call.getArgument(1); + assertTrue(script.getScriptAsString().contains("redis.call('EXPIRE'")); + assertTrue(script.getScriptAsString().contains("or g >=")); + assertTrue(keys.get(0).startsWith("job-location:{amap}:company:42:")); + assertTrue(keys.get(1).startsWith("job-location:{amap}:global:")); + assertEquals("10", call.getArgument(2)); + assertEquals("1000", call.getArgument(3)); + return 1L; + }); + assertDoesNotThrow(() -> new JobLocationSearchLimiter(redis,10,1000).acquire(42)); + } + @Test void zeroMeansRateLimited() { + var redis = mock(StringRedisTemplate.class); + when(redis.execute(any(RedisScript.class), anyList(), any(), any())).thenReturn(0L); + assertTrue(assertThrows(JobLocationSearchException.class, + () -> new JobLocationSearchLimiter(redis,10,1000).acquire(1)).rateLimited); + } + @Test void redisFailureClosesInsteadOfSpendingProviderQuota() { + var redis = mock(StringRedisTemplate.class); + when(redis.execute(any(RedisScript.class), anyList(), any(), any())).thenThrow(new RuntimeException("secret")); + var ex = assertThrows(JobLocationSearchException.class, + () -> new JobLocationSearchLimiter(redis,10,1000).acquire(1)); + assertFalse(ex.rateLimited); + assertNull(ex.getCause()); + } + @Test void nullRedisResultCloses() { + var redis = mock(StringRedisTemplate.class); + assertThrows(JobLocationSearchException.class, () -> new JobLocationSearchLimiter(redis,10,1000).acquire(1)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java index 80de832..6561655 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java @@ -28,7 +28,8 @@ class OnlineResumeMigrationTest { s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(910001,'19907111111','STUDENT',1,1)"); s.execute("INSERT INTO student_profiles(user_id,real_name,resume_url) VALUES(910001,'synthetic','910001_abcdef01.docx')"); List before=history(s); - assertEquals(1,flyway(db,null).migrate().migrationsExecuted); + // 本用例只验 V58→V59;新增后续迁移时不应把后续版本计入 V59 的一步断言。 + assertEquals(1,flyway(db,"59").migrate().migrationsExecuted); assertEquals(before,history(s)); try(var r=s.executeQuery("SELECT resume_url FROM student_profiles WHERE user_id=910001")){assertTrue(r.next());assertEquals("910001_abcdef01.docx",r.getString(1));} try(var p=c.prepareStatement("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,1,?,CURRENT_TIMESTAMP(6))")) { @@ -37,7 +38,7 @@ class OnlineResumeMigrationTest { try(var r=s.executeQuery("SELECT revision,CHAR_LENGTH(content_json) FROM online_resumes WHERE user_id=910001")){assertTrue(r.next());assertEquals(1,r.getLong(1));assertTrue(r.getInt(2)>30000);} assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,2,'{}',CURRENT_TIMESTAMP)")); assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(999999,1,'{}',CURRENT_TIMESTAMP)")); - assertEquals(0,flyway(db,null).migrate().migrationsExecuted);flyway(db,null).validate(); + assertEquals(0,flyway(db,"59").migrate().migrationsExecuted);flyway(db,"59").validate(); } } }