Browse Source

feat(job): add map locations and bounded place search; serialize session logout

fix/mysql57-module-migrations-20261009
Zhang Shun 5 days ago
parent
commit
e46efe55ac
  1. 39
      docs/job-map-runtime-configuration.md
  2. 386
      docs/openapi-rc.json
  3. 62
      docs/verify_openapi.py
  4. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  5. 20
      mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java
  6. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  7. 63
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobMapLocationPolicy.java
  8. 16
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  9. 5
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java
  10. 3
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java
  11. 42
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationRequest.java
  12. 18
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobMapLocationView.java
  13. 3
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java
  14. 5
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java
  15. 16
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictCoordinateDeserializer.java
  16. 160
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapPlaceClient.java
  17. 24
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/AmapTimeouts.java
  18. 74
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchController.java
  19. 11
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchException.java
  20. 57
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSearchLimiter.java
  21. 16
      mydreamjob-server/src/main/java/com/mydreamjob/job/location/JobLocationSuggestion.java
  22. 8
      mydreamjob-server/src/main/resources/db/migration/V60__job_map_location.sql
  23. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  24. 129
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthLogoutRefreshSerializationIntegrationTest.java
  25. 10
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java
  26. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  27. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  28. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  29. 203
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java
  30. 64
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationMySqlMigrationTest.java
  31. 127
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationPolicyTest.java
  32. 98
      mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceClientTest.java
  33. 47
      mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapPlaceHttpTransportTest.java
  34. 48
      mydreamjob-server/src/test/java/com/mydreamjob/job/location/AmapTimeoutsTest.java
  35. 93
      mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchControllerTest.java
  36. 50
      mydreamjob-server/src/test/java/com/mydreamjob/job/location/JobLocationSearchLimiterTest.java
  37. 5
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java

39
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通过不自动继承。这里只提供配置与行为说明,不授予提交、推送、发布或服务器修改权限。

386
docs/openapi-rc.json

@ -3210,7 +3210,7 @@
"岗位" "岗位"
], ],
"summary": "创建岗位", "summary": "创建岗位",
"description": "仅企业账号可调用,新岗位默认 DRAFT 状态", "description": "仅企业账号可调用,新岗位默认 DRAFT 状态。可选 mapLocation 保存岗位实际工作位置;缺省/null 为 UNSET,不推断企业注册地址。非 UNSET 必须具备实际工作地址、成对数字坐标及 GCJ02;位置参数非法返回 HTTP 400,业务校验为 1001。无高德密钥、图片或外部服务调用。",
"operationId": "createJob", "operationId": "createJob",
"requestBody": { "requestBody": {
"content": { "content": {
@ -3534,7 +3534,7 @@
"岗位" "岗位"
], ],
"summary": "更新岗位", "summary": "更新岗位",
"description": "仅岗位所属企业可修改", "description": "仅岗位所属企业可修改,跨企业岗位保持 404。mapLocation 是可选整体替换;缺省/null 且城市与工作地址未变时保持原值;城市或地址改变但未重传点位时自动失效为 UNSET。显式 status=UNSET 清空位置;非 UNSET 必须提供成对数字坐标、GCJ02 和更新后非空地址,非法位置参数 HTTP 400,业务校验为 1001;失败不产生部分写入。",
"operationId": "updateJob", "operationId": "updateJob",
"parameters": [ "parameters": [
{ {
@ -9387,14 +9387,14 @@
} }
} }
}, },
"400": {
"description": "非法 period"
},
"401": { "401": {
"description": "未登录" "description": "未登录"
}, },
"403": { "403": {
"description": "非 ADMIN" "description": "非 ADMIN"
},
"400": {
"description": "非法 period"
} }
} }
} }
@ -9442,14 +9442,14 @@
} }
} }
}, },
"400": {
"description": "非法参数"
},
"401": { "401": {
"description": "未登录" "description": "未登录"
}, },
"403": { "403": {
"description": "非 ADMIN" "description": "非 ADMIN"
},
"400": {
"description": "非法参数"
} }
} }
} }
@ -9534,14 +9534,14 @@
} }
} }
}, },
"400": {
"description": "非法日期范围"
},
"401": { "401": {
"description": "未登录" "description": "未登录"
}, },
"403": { "403": {
"description": "非 ADMIN" "description": "非 ADMIN"
},
"400": {
"description": "非法日期范围"
} }
} }
} }
@ -9947,7 +9947,7 @@
"dataState": "SUFFICIENT" "dataState": "SUFFICIENT"
}, },
"SALARY_TRANSPARENCY": { "SALARY_TRANSPARENCY": {
"averageScore": 4.0, "averageScore": 4,
"sampleCount": 4, "sampleCount": 4,
"dataState": "SUFFICIENT" "dataState": "SUFFICIENT"
}, },
@ -9972,7 +9972,7 @@
"dataState": "SUFFICIENT" "dataState": "SUFFICIENT"
}, },
"WORK_LIFE_BALANCE": { "WORK_LIFE_BALANCE": {
"averageScore": 4.0, "averageScore": 4,
"sampleCount": 4, "sampleCount": 4,
"dataState": "SUFFICIENT" "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": { "components": {
@ -15216,6 +15320,10 @@
"description": "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", "description": "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。",
"maxLength": 255, "maxLength": 255,
"minLength": 0 "minLength": 0
},
"mapLocation": {
"$ref": "#/components/schemas/JobMapLocationRequest",
"description": "可选地图位置。缺省/null 创建为 UNSET;对象按整体校验保存。仅实际工作地点,不从企业注册地址推断。"
} }
}, },
"required": [ "required": [
@ -15465,6 +15573,10 @@
"description": "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", "description": "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。",
"maxLength": 255, "maxLength": 255,
"minLength": 0 "minLength": 0
},
"mapLocation": {
"$ref": "#/components/schemas/JobMapLocationRequest",
"description": "可选地图位置整体替换。缺省/null 且城市与地址未变时保持;城市或地址变更但未重传位置时失效为 UNSET。显式 {status:UNSET} 清空。非 UNSET 必须成对坐标和 GCJ02,且更新后地址非空。"
} }
} }
}, },
@ -20975,6 +21087,10 @@
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"description": "更新时间" "description": "更新时间"
},
"mapLocation": {
"$ref": "#/components/schemas/JobMapLocationView",
"description": "岗位实际工作位置;无位置时 status=UNSET,其他位置字段为 null;与地图展示技术解耦"
} }
} }
}, },
@ -21070,6 +21186,10 @@
"company": { "company": {
"$ref": "#/components/schemas/JobDocCompanySummary", "$ref": "#/components/schemas/JobDocCompanySummary",
"description": "公开企业摘要(7 字段白名单)" "description": "公开企业摘要(7 字段白名单)"
},
"mapLocation": {
"$ref": "#/components/schemas/JobMapLocationView",
"description": "岗位实际工作位置;仅 CONFIRMED 用于确认点位展示;无位置时 status=UNSET,不返回企业注册地址"
} }
} }
}, },
@ -21570,7 +21690,10 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"experienceType": { "experienceType": {
"type": ["string", "null"], "type": [
"string",
"null"
],
"pattern": "^(FULL_TIME|INTERNSHIP)$", "pattern": "^(FULL_TIME|INTERNSHIP)$",
"description": "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。" "description": "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。"
}, },
@ -21695,6 +21818,241 @@
"content", "content",
"revision" "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": { "securitySchemes": {

62
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 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'] paths = d['paths']
@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 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 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys() assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].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 '409/6002' in paths[_legacy_path]['put']['description']
assert 'revision' 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') 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("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

4
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java

@ -287,6 +287,10 @@ public class AuthSessionService {
if (userId == null || sessionId == null || sessionId.isBlank()) { if (userId == null || sessionId == null || sessionId.isBlank()) {
throw new InvalidTokenException(); throw new InvalidTokenException();
} }
// 与登录、刷新和密码变更统一 users -> auth_tokens 锁顺序。
// 必须先等刷新事务提交,再开始撤销语句,避免遗漏尚未可见的后继 Token。
// 不新增 active 校验,保留已失效/不存在会话的幂等登出语义。
userRepository.findByIdForUpdate(userId);
authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT);
auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow));
} }

20
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 org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
/** /**
* 岗位实体 * 岗位实体
@ -66,6 +67,25 @@ public class Job {
@Column(name = "work_location", length = 255) @Column(name = "work_location", length = 255)
private String workLocation; 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) @Column(name = "status", nullable = false, length = 20)
@Builder.Default @Builder.Default
private String status = "DRAFT"; private String status = "DRAFT";

4
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, @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class))) content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class)))
@PostMapping @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, @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class))) content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class)))
@PutMapping("/{jobId}") @PutMapping("/{jobId}")

63
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); }
}

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

@ -54,6 +54,7 @@ public class JobService {
*/ */
@Transactional @Transactional
public Job createJob(Long companyId, JobCreateRequest req) { public Job createJob(Long companyId, JobCreateRequest req) {
var mapLocation = JobMapLocationPolicy.normalize(req.getMapLocation(), normalizeText(req.getWorkLocation()));
Job job = Job.builder() Job job = Job.builder()
.companyId(companyId) .companyId(companyId)
.title(req.getTitle()) .title(req.getTitle())
@ -70,6 +71,7 @@ public class JobService {
.workLocation(normalizeText(req.getWorkLocation())) .workLocation(normalizeText(req.getWorkLocation()))
.status(JobStatus.DRAFT.name()) .status(JobStatus.DRAFT.name())
.build(); .build();
JobMapLocationPolicy.apply(job, mapLocation);
return jobRepository.save(job); return jobRepository.save(job);
} }
@ -95,6 +97,16 @@ public class JobService {
requireVerifiedCompany(companyId); 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.getTitle() != null) job.setTitle(req.getTitle());
if (req.getDescription() != null) job.setDescription(req.getDescription()); if (req.getDescription() != null) job.setDescription(req.getDescription());
if (req.getRequirements() != null) job.setRequirements(req.getRequirements()); if (req.getRequirements() != null) job.setRequirements(req.getRequirements());
@ -111,6 +123,8 @@ public class JobService {
// salaryMonths:null 保持原值(本批不提供显式清空);非 null 必须为 1..24。 // salaryMonths:null 保持原值(本批不提供显式清空);非 null 必须为 1..24。
if (req.getSalaryMonths() != null) job.setSalaryMonths(validateSalaryMonths(req.getSalaryMonths())); if (req.getSalaryMonths() != null) job.setSalaryMonths(validateSalaryMonths(req.getSalaryMonths()));
if (mapLocation != null) JobMapLocationPolicy.apply(job, mapLocation);
return jobRepository.save(job); return jobRepository.save(job);
} }
@ -272,6 +286,7 @@ public class JobService {
view.put("benefits", benefitsFromJson(job.getBenefitsJson())); view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths()); view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation()); view.put("workLocation", job.getWorkLocation());
view.put("mapLocation", JobMapLocationPolicy.view(job));
view.put("status", job.getStatus()); view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt()); view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt()); view.put("createdAt", job.getCreatedAt());
@ -298,6 +313,7 @@ public class JobService {
view.put("benefits", benefitsFromJson(job.getBenefitsJson())); view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths()); view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation()); view.put("workLocation", job.getWorkLocation());
view.put("mapLocation", JobMapLocationPolicy.view(job));
view.put("status", job.getStatus()); view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt()); view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt()); view.put("createdAt", job.getCreatedAt());

5
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.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@ -75,4 +76,8 @@ public class JobCreateRequest {
description = "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;" description = "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 255 字符拒绝且不截断。") + "非空去两端空白后保存;超过 255 字符拒绝且不截断。")
private String workLocation; private String workLocation;
@Valid
@Schema(description = "可选地图位置。缺省/null 创建为 UNSET;对象按整体校验保存。仅实际工作地点,不从企业注册地址推断。")
private JobMapLocationRequest mapLocation;
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java

@ -59,6 +59,9 @@ public class JobDetailViewDoc {
@Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) @Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255)
private String workLocation; private String workLocation;
@Schema(description = "岗位实际工作位置;无位置时 status=UNSET,其他位置字段为 null;与地图展示技术解耦")
private JobMapLocationView mapLocation;
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") @Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED")
private String status; private String status;

42
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;
}

18
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) {
}

3
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java

@ -59,6 +59,9 @@ public class JobPublicViewDoc {
@Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) @Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255)
private String workLocation; private String workLocation;
@Schema(description = "岗位实际工作位置;仅 CONFIRMED 用于确认点位展示;无位置时 status=UNSET,不返回企业注册地址")
private JobMapLocationView mapLocation;
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") @Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED")
private String status; private String status;

5
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.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
@ -70,4 +71,8 @@ public class JobUpdateRequest {
description = "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;" description = "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 255 字符拒绝且不截断。") + "非空去两端空白后保存;超过 255 字符拒绝且不截断。")
private String workLocation; private String workLocation;
@Valid
@Schema(description = "可选地图位置整体替换。缺省/null 且城市与地址未变时保持;城市或地址变更但未重传位置时失效为 UNSET。显式 {status:UNSET} 清空。非 UNSET 必须成对坐标和 GCJ02,且更新后地址非空。")
private JobMapLocationRequest mapLocation;
} }

16
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<BigDecimal> {
@Override
public BigDecimal deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (parser.currentToken().isNumeric()) return parser.getDecimalValue();
return (BigDecimal) context.handleUnexpectedToken(BigDecimal.class, parser);
}
}

160
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<byte[]> {
private final HttpResponse.BodySubscriber<byte[]> delegate = HttpResponse.BodySubscribers.ofByteArray();
private Flow.Subscription subscription;
private int received;
private boolean failed;
public CompletionStage<byte[]> getBody() { return delegate.getBody(); }
public void onSubscribe(Flow.Subscription value) { subscription = value; delegate.onSubscribe(value); }
public void onNext(List<ByteBuffer> 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<JobLocationSuggestion> 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<JobLocationSuggestion>();
var ids = new HashSet<String>();
// 最多解析服务端约定的前 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);
}
}

24
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); }
}

74
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<JobLocationSuggestion> 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<Suggestions> 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<ApiResponse<Void>> 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)));
}
}

11
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;
}
}

57
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<Long> 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<StringRedisTemplate> redis;
private final int companyPerMinute;
private final int globalPerHour;
@Autowired
public JobLocationSearchLimiter(ObjectProvider<StringRedisTemplate> 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);
}
}

16
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) {}

8
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;

4
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(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("192 paths 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(192); assertThat(pathCount - modulePaths).as("193 paths 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(193);
assertThat(opCount - moduleOperations).as("235 operations 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(235); assertThat(opCount - moduleOperations).as("236 operations 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(236);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

129
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<String,Object> 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()<deadline){
if(jdbc.queryForObject(blockingQuery,Integer.class)>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));}
}
}

10
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java

@ -108,6 +108,16 @@ class AuthSessionServiceTest {
() -> authSessionService.logout(1L, " ", java.time.Instant.now(), null)); () -> 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 @Test
void sessionRefIsIrreversibleAndFixedLength() { void sessionRefIsIrreversibleAndFixedLength() {
when(authTokenHasher.hash(anyString())).thenReturn("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); when(authTokenHasher.hash(anyString())).thenReturn("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");

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

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
} }
} }
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息及新增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"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

4
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")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(205); assertThat(runtime.path("paths").size()).isEqualTo(206);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

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

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(205); assertThat(runtime.path("paths").size()).isEqualTo(206);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

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

64
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<String> 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<String> history(Statement s) throws SQLException {
var rows=new ArrayList<String>();
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;
}
}

127
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());
}
}

98
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());
}
}

47
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")));
}
}

48
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());
}
}

93
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());
}
}

50
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<StringRedisTemplate>)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<String> 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));
}
}

5
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 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')"); s.execute("INSERT INTO student_profiles(user_id,real_name,resume_url) VALUES(910001,'synthetic','910001_abcdef01.docx')");
List<String> before=history(s); List<String> before=history(s);
assertEquals(1,flyway(db,null).migrate().migrationsExecuted); // 本用例只验 V58→V59;新增后续迁移时不应把后续版本计入 V59 的一步断言。
assertEquals(1,flyway(db,"59").migrate().migrationsExecuted);
assertEquals(before,history(s)); 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 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))")) { 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);} 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(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)")); 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();
} }
} }
} }

Loading…
Cancel
Save