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<AMapOptions> options)
{
private AMapOptions Settings => options.Value;
public bool Available => !string.IsNullOrWhiteSpace(Settings.WebServiceKey);
private async Task<JsonDocument> GetAsync(string path, Dictionary<string, string?> 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<Coordinate> 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<PlaceCandidate[]> SearchAsync(RecommendationQuery query, CancellationToken ct)
{
var categories = query.Categories.Length > 0 ? query.Categories : Enum.GetValues<PlaceCategory>();
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<PlaceCandidate>();
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<PlaceCandidate>().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<object[]> 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;
}
}
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<AMapOptions> options)
{
private AMapOptions Settings => options.Value;
public bool Available => !string.IsNullOrWhiteSpace(Settings.WebServiceKey);
private async Task<JsonDocument> GetAsync(string path, Dictionary<string, string?> 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<Coordinate> 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<PlaceCandidate[]> SearchAsync(RecommendationQuery query, CancellationToken ct)
{
var categories = query.Categories.Length > 0 ? query.Categories : Enum.GetValues<PlaceCategory>();
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<PlaceCandidate>();
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<PlaceCandidate>().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<object[]> 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;
}
}