|
|
@@ -0,0 +1,207 @@
|
|
|
+package com.ruoyi.app.map;
|
|
|
+
|
|
|
+import com.alibaba.fastjson2.JSONArray;
|
|
|
+import com.alibaba.fastjson2.JSONObject;
|
|
|
+import com.ruoyi.common.exception.ServiceException;
|
|
|
+import com.ruoyi.common.utils.MessageUtils;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.function.Supplier;
|
|
|
+
|
|
|
+/** 选址聚合;来源切换不改变结果字段,也不跨来源静默降级。 */
|
|
|
+@Service
|
|
|
+public class MapLocationService {
|
|
|
+ private static final Logger logger = LoggerFactory.getLogger(MapLocationService.class);
|
|
|
+ private final MapSourceSelector selector;
|
|
|
+ private final SelfHostedMapClient selfHosted;
|
|
|
+ private final GoogleMapLocationClient google;
|
|
|
+
|
|
|
+ public MapLocationService(MapSourceSelector selector, SelfHostedMapClient selfHosted, GoogleMapLocationClient google) {
|
|
|
+ this.selector = selector;
|
|
|
+ this.selfHosted = selfHosted;
|
|
|
+ this.google = google;
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<MapLocation> search(String query) {
|
|
|
+ if (query == null || query.isBlank() || query.trim().length() > 200) throw invalid();
|
|
|
+ String keyword = query.trim();
|
|
|
+ return lookup("search", () -> selector.selfHostedEnabled()
|
|
|
+ ? locations(selfHosted.getArray("/search", Map.of("q", keyword, "limit", 20)), true, 20)
|
|
|
+ : locations(googleResults(google.getObject("/maps/api/place/textsearch/json",
|
|
|
+ Map.of("query", keyword))), false, 20));
|
|
|
+ }
|
|
|
+
|
|
|
+ public List<MapLocation> nearby(BigDecimal latitude, BigDecimal longitude, Integer radius, Integer limit) {
|
|
|
+ requireCoordinates(latitude, longitude);
|
|
|
+ int meters = radius == null ? 300 : radius;
|
|
|
+ int count = limit == null ? 10 : limit;
|
|
|
+ if (meters < 10 || meters > 2000 || count < 1 || count > 50) throw invalid();
|
|
|
+ return lookup("nearby", () -> selector.selfHostedEnabled()
|
|
|
+ ? locations(selfHosted.getArray("/nearby", Map.of("lat", latitude, "lng", longitude,
|
|
|
+ "radius", meters, "limit", count)), true, count)
|
|
|
+ : locations(googleResults(google.getObject("/maps/api/place/nearbysearch/json",
|
|
|
+ Map.of("location", latitude.toPlainString() + "," + longitude.toPlainString(),
|
|
|
+ "radius", meters))), false, count));
|
|
|
+ }
|
|
|
+
|
|
|
+ public MapLocation reverse(BigDecimal latitude, BigDecimal longitude) {
|
|
|
+ requireCoordinates(latitude, longitude);
|
|
|
+ return lookup("reverse", () -> {
|
|
|
+ MapLocation location;
|
|
|
+ if (selector.selfHostedEnabled()) {
|
|
|
+ location = selfHostedLocation(selfHosted.getObject("/reverse_geocode",
|
|
|
+ Map.of("lat", latitude, "lng", longitude)));
|
|
|
+ } else {
|
|
|
+ JSONArray results = googleResults(google.getObject("/maps/api/geocode/json",
|
|
|
+ Map.of("latlng", latitude.toPlainString() + "," + longitude.toPlainString())));
|
|
|
+ location = results.isEmpty() ? coordinateLocation(latitude, longitude) : googleLocation(results.getJSONObject(0));
|
|
|
+ if (location.getLatitude().compareTo(latitude) != 0 || location.getLongitude().compareTo(longitude) != 0) {
|
|
|
+ location.setPrecision("nearby_place");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 反查返回附近名称,坐标必须始终保持用户点选的原始精度。
|
|
|
+ location.setId(("SELF_HOSTED".equals(location.getSource()) ? "self_hosted:" : "google:")
|
|
|
+ + "pin:" + latitude.toPlainString() + "," + longitude.toPlainString());
|
|
|
+ location.setLatitude(latitude);
|
|
|
+ location.setLongitude(longitude);
|
|
|
+ location.setRequiresConfirmation(true);
|
|
|
+ return location;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public MapLocation place(String id) {
|
|
|
+ if (id == null || id.isBlank() || id.length() > 212) throw invalid();
|
|
|
+ boolean local = id.startsWith("self_hosted:");
|
|
|
+ if (!local && !id.startsWith("google:")) throw invalid();
|
|
|
+ String providerId = id.substring(local ? 12 : 7);
|
|
|
+ if (providerId.isBlank() || providerId.length() > 200) throw invalid();
|
|
|
+ return lookup("place", () -> {
|
|
|
+ // 显式 ID 来源优先于当前开关,避免切换后把自部署 ID 发送给 Google。
|
|
|
+ if (local) return selfHostedLocation(selfHosted.getObject("/place_detail", Map.of("id", providerId)));
|
|
|
+ JSONObject response = google.getObject("/maps/api/place/details/json", Map.of("place_id", providerId));
|
|
|
+ if (response == null || !"OK".equals(response.getString("status"))) throw malformed();
|
|
|
+ return googleLocation(response.getJSONObject("result"));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<MapLocation> locations(JSONArray rows, boolean local, int limit) {
|
|
|
+ if (rows == null) throw malformed();
|
|
|
+ List<MapLocation> result = new ArrayList<>();
|
|
|
+ for (int index = 0; index < Math.min(rows.size(), limit); index++) {
|
|
|
+ JSONObject row = rows.getJSONObject(index);
|
|
|
+ result.add(local ? selfHostedLocation(row) : googleLocation(row));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private JSONArray googleResults(JSONObject response) {
|
|
|
+ if (response == null) throw malformed();
|
|
|
+ if ("ZERO_RESULTS".equals(response.getString("status"))) return new JSONArray();
|
|
|
+ if (!"OK".equals(response.getString("status")) || response.getJSONArray("results") == null) throw malformed();
|
|
|
+ return response.getJSONArray("results");
|
|
|
+ }
|
|
|
+
|
|
|
+ private MapLocation selfHostedLocation(JSONObject row) {
|
|
|
+ if (row == null) throw malformed();
|
|
|
+ MapLocation result = baseLocation(row.getString("id"), row.getString("name"),
|
|
|
+ row.getString("address"), row.getBigDecimal("lat"), row.getBigDecimal("lng"), true);
|
|
|
+ String precision = row.getString("precision");
|
|
|
+ result.setPrecision(precision == null || precision.isBlank() ? "unknown" : precision);
|
|
|
+ result.setRequiresConfirmation(!"address".equals(precision)
|
|
|
+ || Boolean.TRUE.equals(row.getBoolean("requires_confirmation")));
|
|
|
+ BigDecimal distance = row.getBigDecimal("distance_meters");
|
|
|
+ if (distance != null && distance.signum() < 0) throw malformed();
|
|
|
+ result.setDistanceMeters(distance);
|
|
|
+ result.setCountry(row.getString("country"));
|
|
|
+ result.setProvince(row.getString("province"));
|
|
|
+ result.setCity(row.getString("city"));
|
|
|
+ result.setDistrict(row.getString("district"));
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private MapLocation googleLocation(JSONObject row) {
|
|
|
+ if (row == null || row.getJSONObject("geometry") == null) throw malformed();
|
|
|
+ JSONObject geometry = row.getJSONObject("geometry");
|
|
|
+ JSONObject coordinates = geometry.getJSONObject("location");
|
|
|
+ if (coordinates == null) throw malformed();
|
|
|
+ String address = row.getString("formatted_address");
|
|
|
+ if (address == null || address.isBlank()) address = row.getString("vicinity");
|
|
|
+ MapLocation result = baseLocation(row.getString("place_id"), row.getString("name"), address,
|
|
|
+ coordinates.getBigDecimal("lat"), coordinates.getBigDecimal("lng"), false);
|
|
|
+ String type = geometry.getString("location_type");
|
|
|
+ result.setPrecision("ROOFTOP".equals(type) ? "address" : type == null ? "poi" : type.toLowerCase(Locale.ROOT));
|
|
|
+ result.setRequiresConfirmation(!"address".equals(result.getPrecision()));
|
|
|
+ JSONArray components = row.getJSONArray("address_components");
|
|
|
+ if (components != null) {
|
|
|
+ for (int index = 0; index < components.size(); index++) {
|
|
|
+ JSONObject component = components.getJSONObject(index);
|
|
|
+ if (component == null || component.getJSONArray("types") == null) continue;
|
|
|
+ JSONArray types = component.getJSONArray("types");
|
|
|
+ String name = component.getString("long_name");
|
|
|
+ if (types.contains("country")) result.setCountry(name);
|
|
|
+ if (types.contains("administrative_area_level_1")) result.setProvince(name);
|
|
|
+ if (types.contains("locality")) result.setCity(name);
|
|
|
+ if (types.contains("administrative_area_level_2") && result.getCity() == null) result.setCity(name);
|
|
|
+ if (types.contains("sublocality") || types.contains("sublocality_level_1")
|
|
|
+ || types.contains("administrative_area_level_3")) result.setDistrict(name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private MapLocation baseLocation(String id, String name, String address, BigDecimal latitude,
|
|
|
+ BigDecimal longitude, boolean local) {
|
|
|
+ if (id == null || id.isBlank() || id.length() > 200 || !validCoordinates(latitude, longitude)) throw malformed();
|
|
|
+ if (name == null || name.isBlank()) name = address;
|
|
|
+ if (name == null || name.isBlank()) throw malformed();
|
|
|
+ MapLocation result = new MapLocation();
|
|
|
+ result.setId((local ? "self_hosted:" : "google:") + id);
|
|
|
+ result.setSource(local ? "SELF_HOSTED" : "GOOGLE");
|
|
|
+ result.setName(name);
|
|
|
+ result.setAddress(address);
|
|
|
+ result.setLatitude(latitude);
|
|
|
+ result.setLongitude(longitude);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private MapLocation coordinateLocation(BigDecimal latitude, BigDecimal longitude) {
|
|
|
+ String coordinates = latitude.toPlainString() + "," + longitude.toPlainString();
|
|
|
+ MapLocation result = baseLocation("pin:" + coordinates, coordinates, null, latitude, longitude, false);
|
|
|
+ result.setPrecision("coordinate");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void requireCoordinates(BigDecimal latitude, BigDecimal longitude) {
|
|
|
+ if (!validCoordinates(latitude, longitude)) throw invalid();
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean validCoordinates(BigDecimal latitude, BigDecimal longitude) {
|
|
|
+ return latitude != null && longitude != null && latitude.abs().compareTo(BigDecimal.valueOf(90)) <= 0
|
|
|
+ && longitude.abs().compareTo(BigDecimal.valueOf(180)) <= 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private <T> T lookup(String operation, Supplier<T> request) {
|
|
|
+ try {
|
|
|
+ return request.get();
|
|
|
+ } catch (RuntimeException exception) {
|
|
|
+ // 日志不包含用户地址、坐标、令牌、Key 或底层异常中的 URL。
|
|
|
+ logger.warn("map location lookup failed operation={}", operation);
|
|
|
+ throw new ServiceException(MessageUtils.message("map.location.unavailable"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private ServiceException invalid() {
|
|
|
+ return new ServiceException(MessageUtils.message("map.location.invalid"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private IllegalStateException malformed() {
|
|
|
+ return new IllegalStateException("map location response invalid");
|
|
|
+ }
|
|
|
+}
|