489 lines
12 KiB
Go
489 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"embed"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"io/fs"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
//go:embed templates/*
|
||
var templatesFS embed.FS
|
||
|
||
//go:embed static/*
|
||
var staticFS embed.FS
|
||
|
||
// Config 配置结构
|
||
type Config struct {
|
||
AmapKey string `json:"amap_key"` // Web服务API Key
|
||
AmapJsKey string `json:"amap_js_key"` // JS API Key
|
||
AmapJsSecret string `json:"amap_js_secret"` // JS API 安全密钥
|
||
Port string `json:"port"`
|
||
}
|
||
|
||
// Coordinate 坐标结构
|
||
type Coordinate struct {
|
||
Lng float64 `json:"lng"`
|
||
Lat float64 `json:"lat"`
|
||
}
|
||
|
||
// Location 位置信息
|
||
type Location struct {
|
||
Name string `json:"name"`
|
||
Address string `json:"address"`
|
||
Coordinate Coordinate `json:"coordinate"`
|
||
}
|
||
|
||
// CenterRequest 计算中心点请求
|
||
type CenterRequest struct {
|
||
Locations []Location `json:"locations"`
|
||
}
|
||
|
||
// SearchRequest 搜索请求
|
||
type SearchRequest struct {
|
||
Center Coordinate `json:"center"`
|
||
Keywords string `json:"keywords"`
|
||
Radius int `json:"radius"` // 搜索半径,单位米
|
||
}
|
||
|
||
// POI 兴趣点
|
||
type POI struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Address string `json:"address"`
|
||
Location Coordinate `json:"location"`
|
||
Distance string `json:"distance"`
|
||
Tel string `json:"tel"`
|
||
}
|
||
|
||
// AmapGeoResponse 高德地理编码响应
|
||
type AmapGeoResponse struct {
|
||
Status string `json:"status"`
|
||
Info string `json:"info"`
|
||
Count string `json:"count"`
|
||
Geocodes []struct {
|
||
FormattedAddress string `json:"formatted_address"`
|
||
Location string `json:"location"`
|
||
Level string `json:"level"`
|
||
} `json:"geocodes"`
|
||
}
|
||
|
||
// AmapPOI 高德POI项
|
||
type AmapPOI struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Address interface{} `json:"address"` // 可能是字符串或空数组
|
||
Location string `json:"location"`
|
||
Distance string `json:"distance"`
|
||
Tel interface{} `json:"tel"` // 可能是字符串或空数组
|
||
}
|
||
|
||
// AmapPOIResponse 高德POI搜索响应
|
||
type AmapPOIResponse struct {
|
||
Status string `json:"status"`
|
||
Info string `json:"info"`
|
||
Count string `json:"count"`
|
||
Suggestion struct {
|
||
Keywords []interface{} `json:"keywords"`
|
||
} `json:"suggestion"`
|
||
Pois []AmapPOI `json:"pois"`
|
||
}
|
||
|
||
// AmapTip 高德输入提示项
|
||
type AmapTip struct {
|
||
ID interface{} `json:"id"` // 可能是字符串或空数组
|
||
Name string `json:"name"`
|
||
District string `json:"district"`
|
||
Address interface{} `json:"address"` // 可能是字符串或空数组
|
||
Location interface{} `json:"location"` // 可能是字符串或空数组
|
||
}
|
||
|
||
// AmapTipsResponse 高德输入提示响应
|
||
type AmapTipsResponse struct {
|
||
Status string `json:"status"`
|
||
Info string `json:"info"`
|
||
Count string `json:"count"`
|
||
Tips []AmapTip `json:"tips"`
|
||
}
|
||
|
||
var config Config
|
||
|
||
func main() {
|
||
// 加载配置
|
||
loadConfig()
|
||
|
||
// 设置Gin模式
|
||
gin.SetMode(gin.ReleaseMode)
|
||
|
||
r := gin.Default()
|
||
|
||
// 从嵌入的文件系统加载模板
|
||
tmpl := template.Must(template.New("").ParseFS(templatesFS, "templates/*.html"))
|
||
r.SetHTMLTemplate(tmpl)
|
||
|
||
// 从嵌入的文件系统提供静态文件
|
||
staticSubFS, _ := fs.Sub(staticFS, "static")
|
||
r.StaticFS("/static", http.FS(staticSubFS))
|
||
|
||
// 路由
|
||
r.GET("/", indexHandler)
|
||
r.GET("/api/config", getConfigHandler)
|
||
r.POST("/api/geocode", geocodeHandler)
|
||
r.POST("/api/center", calculateCenterHandler)
|
||
r.POST("/api/search", searchPOIHandler)
|
||
r.GET("/api/tips", getTipsHandler)
|
||
|
||
port := config.Port
|
||
if port == "" {
|
||
port = "8080"
|
||
}
|
||
|
||
log.Printf("========================================")
|
||
log.Printf(" 会面点 Meeting Point")
|
||
log.Printf(" 服务启动在 http://localhost:%s", port)
|
||
log.Printf("========================================")
|
||
r.Run(":" + port)
|
||
}
|
||
|
||
func loadConfig() {
|
||
// 尝试从配置文件加载
|
||
file, err := os.Open("config.json")
|
||
if err == nil {
|
||
defer file.Close()
|
||
decoder := json.NewDecoder(file)
|
||
err = decoder.Decode(&config)
|
||
if err != nil {
|
||
log.Printf("解析配置文件失败: %v", err)
|
||
}
|
||
} else {
|
||
log.Printf("警告: 未找到 config.json 配置文件,请确保配置文件存在")
|
||
}
|
||
|
||
// 环境变量覆盖
|
||
if key := os.Getenv("AMAP_KEY"); key != "" {
|
||
config.AmapKey = key
|
||
}
|
||
if port := os.Getenv("PORT"); port != "" {
|
||
config.Port = port
|
||
}
|
||
|
||
if config.Port == "" {
|
||
config.Port = "8080"
|
||
}
|
||
|
||
if config.AmapKey == "" {
|
||
log.Printf("警告: 未配置高德地图 API Key,请在 config.json 中配置 amap_key")
|
||
}
|
||
}
|
||
|
||
func indexHandler(c *gin.Context) {
|
||
c.HTML(http.StatusOK, "index.html", nil)
|
||
}
|
||
|
||
func getConfigHandler(c *gin.Context) {
|
||
// 返回前端需要的配置
|
||
jsKey := config.AmapJsKey
|
||
if jsKey == "" {
|
||
jsKey = config.AmapKey // 兼容:如果没有单独配置JS Key,使用Web服务Key
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"amap_key": config.AmapKey,
|
||
"amap_js_key": jsKey,
|
||
"amap_js_secret": config.AmapJsSecret,
|
||
})
|
||
}
|
||
|
||
// geocodeHandler 地理编码 - 地址转坐标
|
||
func geocodeHandler(c *gin.Context) {
|
||
var req struct {
|
||
Address string `json:"address"`
|
||
City string `json:"city"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||
return
|
||
}
|
||
|
||
if req.Address == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "地址不能为空"})
|
||
return
|
||
}
|
||
|
||
// 调用高德地理编码API
|
||
apiURL := fmt.Sprintf(
|
||
"https://restapi.amap.com/v3/geocode/geo?key=%s&address=%s&city=%s",
|
||
config.AmapKey,
|
||
url.QueryEscape(req.Address),
|
||
url.QueryEscape(req.City),
|
||
)
|
||
|
||
resp, err := http.Get(apiURL)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
var amapResp AmapGeoResponse
|
||
if err := json.Unmarshal(body, &amapResp); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
|
||
return
|
||
}
|
||
|
||
if amapResp.Status != "1" || len(amapResp.Geocodes) == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到该地址"})
|
||
return
|
||
}
|
||
|
||
// 解析坐标
|
||
location := amapResp.Geocodes[0].Location
|
||
coords := strings.Split(location, ",")
|
||
if len(coords) != 2 {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "坐标格式错误"})
|
||
return
|
||
}
|
||
|
||
var lng, lat float64
|
||
fmt.Sscanf(coords[0], "%f", &lng)
|
||
fmt.Sscanf(coords[1], "%f", &lat)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"location": Location{
|
||
Name: req.Address,
|
||
Address: amapResp.Geocodes[0].FormattedAddress,
|
||
Coordinate: Coordinate{
|
||
Lng: lng,
|
||
Lat: lat,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
// calculateCenterHandler 计算多点中心
|
||
func calculateCenterHandler(c *gin.Context) {
|
||
var req CenterRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||
return
|
||
}
|
||
|
||
if len(req.Locations) < 2 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要2个位置点"})
|
||
return
|
||
}
|
||
|
||
// 计算几何中心(加权平均)
|
||
center := calculateGeometricCenter(req.Locations)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"center": center,
|
||
})
|
||
}
|
||
|
||
// calculateGeometricCenter 计算几何中心点
|
||
func calculateGeometricCenter(locations []Location) Coordinate {
|
||
// 使用球面坐标转换计算更准确的中心点
|
||
var x, y, z float64
|
||
|
||
for _, loc := range locations {
|
||
// 转换为弧度
|
||
latRad := loc.Coordinate.Lat * math.Pi / 180
|
||
lngRad := loc.Coordinate.Lng * math.Pi / 180
|
||
|
||
// 转换为笛卡尔坐标
|
||
x += math.Cos(latRad) * math.Cos(lngRad)
|
||
y += math.Cos(latRad) * math.Sin(lngRad)
|
||
z += math.Sin(latRad)
|
||
}
|
||
|
||
// 平均值
|
||
n := float64(len(locations))
|
||
x /= n
|
||
y /= n
|
||
z /= n
|
||
|
||
// 转换回经纬度
|
||
lng := math.Atan2(y, x) * 180 / math.Pi
|
||
hyp := math.Sqrt(x*x + y*y)
|
||
lat := math.Atan2(z, hyp) * 180 / math.Pi
|
||
|
||
return Coordinate{
|
||
Lng: lng,
|
||
Lat: lat,
|
||
}
|
||
}
|
||
|
||
// searchPOIHandler 搜索周边POI
|
||
func searchPOIHandler(c *gin.Context) {
|
||
var req SearchRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||
return
|
||
}
|
||
|
||
if req.Keywords == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
|
||
return
|
||
}
|
||
|
||
if req.Radius <= 0 {
|
||
req.Radius = 3000 // 默认3公里
|
||
}
|
||
|
||
// 调用高德POI搜索API
|
||
location := fmt.Sprintf("%f,%f", req.Center.Lng, req.Center.Lat)
|
||
apiURL := fmt.Sprintf(
|
||
"https://restapi.amap.com/v3/place/around?key=%s&location=%s&keywords=%s&radius=%d&offset=20&page=1&extensions=all",
|
||
config.AmapKey,
|
||
location,
|
||
url.QueryEscape(req.Keywords),
|
||
req.Radius,
|
||
)
|
||
|
||
resp, err := http.Get(apiURL)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
|
||
var amapResp AmapPOIResponse
|
||
if err := json.Unmarshal(body, &amapResp); err != nil {
|
||
log.Printf("解析POI响应失败: %v", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
|
||
return
|
||
}
|
||
|
||
if amapResp.Status != "1" {
|
||
log.Printf("高德POI搜索API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info})
|
||
return
|
||
}
|
||
|
||
// 转换POI数据
|
||
pois := make([]POI, 0, len(amapResp.Pois))
|
||
for _, p := range amapResp.Pois {
|
||
coords := strings.Split(p.Location, ",")
|
||
if len(coords) != 2 {
|
||
continue
|
||
}
|
||
var lng, lat float64
|
||
fmt.Sscanf(coords[0], "%f", &lng)
|
||
fmt.Sscanf(coords[1], "%f", &lat)
|
||
|
||
// 处理可能是数组或字符串的字段
|
||
address, _ := p.Address.(string)
|
||
tel, _ := p.Tel.(string)
|
||
|
||
pois = append(pois, POI{
|
||
ID: p.ID,
|
||
Name: p.Name,
|
||
Type: p.Type,
|
||
Address: address,
|
||
Location: Coordinate{
|
||
Lng: lng,
|
||
Lat: lat,
|
||
},
|
||
Distance: p.Distance,
|
||
Tel: tel,
|
||
})
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"pois": pois,
|
||
"count": len(pois),
|
||
})
|
||
}
|
||
|
||
// getTipsHandler 输入提示
|
||
func getTipsHandler(c *gin.Context) {
|
||
keywords := c.Query("keywords")
|
||
city := c.Query("city")
|
||
|
||
if keywords == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "关键词不能为空"})
|
||
return
|
||
}
|
||
|
||
apiURL := fmt.Sprintf(
|
||
"https://restapi.amap.com/v3/assistant/inputtips?key=%s&keywords=%s&city=%s&datatype=all",
|
||
config.AmapKey,
|
||
url.QueryEscape(keywords),
|
||
url.QueryEscape(city),
|
||
)
|
||
|
||
resp, err := http.Get(apiURL)
|
||
if err != nil {
|
||
log.Printf("请求高德API失败: %v", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
var amapResp AmapTipsResponse
|
||
if err := json.Unmarshal(body, &amapResp); err != nil {
|
||
log.Printf("解析响应失败: %v, body: %s", err, string(body))
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
|
||
return
|
||
}
|
||
|
||
if amapResp.Status != "1" {
|
||
log.Printf("高德API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info})
|
||
return
|
||
}
|
||
|
||
// 过滤有效的提示
|
||
tips := make([]map[string]interface{}, 0)
|
||
for _, tip := range amapResp.Tips {
|
||
// 解析 location 字段(可能是字符串或空数组)
|
||
locationStr, ok := tip.Location.(string)
|
||
if !ok || locationStr == "" {
|
||
continue // 跳过非字符串类型的 location
|
||
}
|
||
|
||
coords := strings.Split(locationStr, ",")
|
||
if len(coords) != 2 {
|
||
continue
|
||
}
|
||
var lng, lat float64
|
||
fmt.Sscanf(coords[0], "%f", &lng)
|
||
fmt.Sscanf(coords[1], "%f", &lat)
|
||
|
||
// 解析 address 字段(可能是字符串或数组)
|
||
addressStr, _ := tip.Address.(string)
|
||
|
||
// 解析 id 字段(可能是字符串或数组)
|
||
idStr, _ := tip.ID.(string)
|
||
|
||
tips = append(tips, map[string]interface{}{
|
||
"id": idStr,
|
||
"name": tip.Name,
|
||
"district": tip.District,
|
||
"address": addressStr,
|
||
"location": Coordinate{Lng: lng, Lat: lat},
|
||
})
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"tips": tips,
|
||
})
|
||
}
|