meeting-point/main.go

489 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
})
}