37 changed files with 1897 additions and 27 deletions
@ -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通过不自动继承。这里只提供配置与行为说明,不授予提交、推送、发布或服务器修改权限。 |
||||
@ -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); } |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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) { |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); } |
||||
|
} |
||||
@ -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))); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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) {} |
||||
@ -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; |
||||
@ -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));} |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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"))); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue