using System.Globalization; using System.Text.Json; using EARSystem.Core; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; namespace EARSystem.Infrastructure; public class AMapOptions { public string WebServiceKey { get; set; } = ""; public string BrowserKey { get; set; } = ""; public string JsSecurityCode { get; set; } = ""; public bool AllowExternalReferences { get; set; } } public class MapServiceException(string message) : Exception(message); public class AMapClient(HttpClient http, IOptions options) { private AMapOptions Settings => options.Value; public bool Available => !string.IsNullOrWhiteSpace(Settings.WebServiceKey); private async Task GetAsync(string path, Dictionary query, CancellationToken ct) { if (!Available) throw new MapServiceException("尚未配置高德 Web 服务密钥,请选择演示模式。"); query["key"] = Settings.WebServiceKey; try { using var response = await http.GetAsync(QueryHelpers.AddQueryString("https://restapi.amap.com/" + path, query), ct); if (!response.IsSuccessStatusCode) throw new MapServiceException("地图服务暂不可用,请稍后重试或切换演示模式。"); var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); if (Text(document.RootElement, "status") != "1") { document.Dispose(); throw new MapServiceException("地图接口调用失败,请检查密钥权限或配额;可以切换演示模式。"); } return document; } catch (HttpRequestException) { throw new MapServiceException("无法连接地图服务,请稍后重试或切换演示模式。"); } catch (JsonException) { throw new MapServiceException("地图服务返回格式异常,请稍后重试。"); } } public async Task ConvertAsync(Coordinate coordinate, CancellationToken ct) { if (coordinate.System == "GCJ02") return coordinate; using var doc = await GetAsync("v3/assistant/coordinate/convert", new() { ["locations"] = $"{coordinate.Longitude.ToString("F6", CultureInfo.InvariantCulture)},{coordinate.Latitude.ToString("F6", CultureInfo.InvariantCulture)}", ["coordsys"] = "gps" }, ct); var parts = Text(doc.RootElement, "locations").Split(','); if (parts.Length != 2 || !double.TryParse(parts[0], CultureInfo.InvariantCulture, out var lng) || !double.TryParse(parts[1], CultureInfo.InvariantCulture, out var lat) || !double.IsFinite(lng) || !double.IsFinite(lat) || Math.Abs(lng)>180 || Math.Abs(lat)>90) throw new MapServiceException("坐标转换失败,请手动选点。"); return new(lng, lat); } public async Task SearchAsync(RecommendationQuery query, CancellationToken ct) { var categories = query.Categories.Length > 0 ? query.Categories : Enum.GetValues(); var results = await Task.WhenAll(categories.Distinct().Select(async category => { using var doc = await GetAsync("v5/place/around", new() { ["location"] = $"{query.Location.Longitude.ToString("F6", CultureInfo.InvariantCulture)},{query.Location.Latitude.ToString("F6", CultureInfo.InvariantCulture)}", ["radius"] = query.RadiusMeters.ToString(CultureInfo.InvariantCulture), ["keywords"] = Labels.Category(category), ["page_size"] = "20", ["page_num"] = "1", ["show_fields"] = "business" }, ct); if (!doc.RootElement.TryGetProperty("pois", out var pois) || pois.ValueKind != JsonValueKind.Array) return Array.Empty(); return pois.EnumerateArray().Select(p => { var point = Text(p, "location").Split(','); if (point.Length != 2 || !double.TryParse(point[0], CultureInfo.InvariantCulture, out var lng) || !double.TryParse(point[1], CultureInfo.InvariantCulture, out var lat)) return null; double? rating = null; decimal? cost = null; if (p.TryGetProperty("business", out var business) && business.ValueKind == JsonValueKind.Object) { if (double.TryParse(Text(business, "rating"), CultureInfo.InvariantCulture, out var r) && r is >= 1 and <= 5) rating = r; if (decimal.TryParse(Text(business, "cost"), CultureInfo.InvariantCulture, out var money) && money >= 0) cost = money; } var id = Text(p, "id"); if (string.IsNullOrWhiteSpace(id) || !double.IsFinite(lng) || !double.IsFinite(lat) || Math.Abs(lng) > 180 || Math.Abs(lat) > 90) return null; return new PlaceCandidate("amap:" + id, null, Text(p, "name"), Text(p, "address"), Category(p, category), lng, lat, cost, rating, null, null, 0, "Online", "类型推测"); }).OfType().ToArray(); })); return results.SelectMany(x => x).DistinctBy(x => x.Id) .OrderBy(x => RecommendationScorer.Distance(query.Location, x.Longitude, x.Latitude)).Take(100).ToArray(); } public async Task GeocodeAsync(string address, CancellationToken ct) { using var doc = await GetAsync("v3/geocode/geo", new() { ["address"] = address }, ct); if (!doc.RootElement.TryGetProperty("geocodes", out var values)) return []; return values.EnumerateArray().Take(5).Select(x => (object)new { name = Text(x, "formatted_address"), location = Text(x, "location"), system = "GCJ02" }).ToArray(); } public static string Text(JsonElement value, string key) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(key, out var property) && property.ValueKind is JsonValueKind.String or JsonValueKind.Number ? property.ToString() : ""; // Prefer the provider's declared POI type; only fall back to the retrieval category when it is missing. private static PlaceCategory Category(JsonElement poi, PlaceCategory fallback) { var type = Text(poi, "type"); if (type.Contains("咖啡")) return PlaceCategory.Cafe; if (type.Contains("公园")) return PlaceCategory.Park; if (type.Contains("书店") || type.Contains("图书")) return PlaceCategory.Bookstore; if (new[] { "博物", "美术", "展览", "展馆" }.Any(type.Contains)) return PlaceCategory.Gallery; if (new[] { "娱乐", "休闲", "影院" }.Any(type.Contains)) return PlaceCategory.Entertainment; if (type.Contains("餐饮") || type.Contains("餐厅")) return PlaceCategory.Dining; return fallback; } }