Add AMap location and weather header with warm mini program theme
This commit is contained in:
@@ -31,3 +31,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: ""
|
||||
|
||||
@@ -30,3 +30,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: "c6e0887a6cd0aeaf8f1fd3dd5704b8c3"
|
||||
|
||||
@@ -9,7 +9,12 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type AMapConfig struct {
|
||||
Key string `mapstructure:"key"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AMap AMapConfig `mapstructure:"amap"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MinIO MinIOConfig `mapstructure:"minio"`
|
||||
@@ -67,6 +72,7 @@ type AdminConfig struct {
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetDefault("amap.key", "")
|
||||
v.SetDefault("server.port", 9090)
|
||||
v.SetDefault("server.mode", "debug")
|
||||
v.SetDefault("postgres.host", "192.168.100.127")
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotConfigured = errors.New("位置和天气服务暂未配置")
|
||||
var ErrCoordinates = errors.New("定位坐标无效")
|
||||
|
||||
type Weather struct {
|
||||
Description string `json:"description"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reported_at"`
|
||||
}
|
||||
type Result struct {
|
||||
LocationName string `json:"location_name"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Weather *Weather `json:"weather"`
|
||||
}
|
||||
type cached struct {
|
||||
result Result
|
||||
expires time.Time
|
||||
}
|
||||
type Client struct {
|
||||
key string
|
||||
baseURL string
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
cache map[string]cached
|
||||
}
|
||||
|
||||
func New(key string) *Client {
|
||||
return &Client{key: key, baseURL: "https://restapi.amap.com", http: &http.Client{Timeout: 5 * time.Second}, cache: make(map[string]cached)}
|
||||
}
|
||||
|
||||
// AMap returns [] instead of an empty string for some address components.
|
||||
func addressText(raw json.RawMessage) string {
|
||||
var text string
|
||||
_ = json.Unmarshal(raw, &text)
|
||||
return text
|
||||
}
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, target any) error {
|
||||
params.Set("key", c.key)
|
||||
params.Set("output", "JSON")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return errors.New("位置服务请求失败")
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
// Never return the transport error: it may contain the URL and API key.
|
||||
if err != nil {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target); err != nil {
|
||||
return errors.New("位置服务响应无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Lookup(ctx context.Context, latitude, longitude float64) (Result, error) {
|
||||
if math.IsNaN(latitude) || math.IsNaN(longitude) || math.IsInf(latitude, 0) || math.IsInf(longitude, 0) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 {
|
||||
return Result{}, ErrCoordinates
|
||||
}
|
||||
if c.key == "" {
|
||||
return Result{}, ErrNotConfigured
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%.3f,%.3f", longitude, latitude)
|
||||
c.mu.Lock()
|
||||
hit, found := c.cache[cacheKey]
|
||||
c.mu.Unlock()
|
||||
if found && time.Now().Before(hit.expires) {
|
||||
return hit.result, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
var geo struct {
|
||||
Status string `json:"status"`
|
||||
Regeocode struct {
|
||||
Address struct {
|
||||
Province json.RawMessage `json:"province"`
|
||||
City json.RawMessage `json:"city"`
|
||||
District json.RawMessage `json:"district"`
|
||||
Adcode string `json:"adcode"`
|
||||
} `json:"addressComponent"`
|
||||
} `json:"regeocode"`
|
||||
}
|
||||
err := c.get(ctx, "/v3/geocode/regeo", url.Values{"location": {fmt.Sprintf("%.6f,%.6f", longitude, latitude)}, "extensions": {"base"}}, &geo)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if geo.Status != "1" {
|
||||
return Result{}, errors.New("所在城市获取失败,请稍后重试")
|
||||
}
|
||||
address := geo.Regeocode.Address
|
||||
city := addressText(address.City)
|
||||
if city == "" {
|
||||
city = addressText(address.Province)
|
||||
}
|
||||
district := addressText(address.District)
|
||||
if city == "" || address.Adcode == "" {
|
||||
return Result{}, errors.New("当前位置暂不支持天气查询")
|
||||
}
|
||||
result := Result{City: city, District: district, LocationName: city}
|
||||
if district != "" && district != city {
|
||||
result.LocationName += " · " + district
|
||||
}
|
||||
var weather struct {
|
||||
Status string `json:"status"`
|
||||
Lives []struct {
|
||||
Description string `json:"weather"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reporttime"`
|
||||
} `json:"lives"`
|
||||
}
|
||||
err = c.get(ctx, "/v3/weather/weatherInfo", url.Values{"city": {address.Adcode}, "extensions": {"base"}}, &weather)
|
||||
if err == nil && weather.Status == "1" && len(weather.Lives) > 0 {
|
||||
live := weather.Lives[0]
|
||||
if strings.TrimSpace(live.Description) != "" && live.Temperature != "" {
|
||||
result.Weather = &Weather{live.Description, live.Temperature, live.ReportedAt}
|
||||
}
|
||||
}
|
||||
// Keep the city even if the weather provider fails; retry missing weather next time.
|
||||
if result.Weather != nil {
|
||||
c.mu.Lock()
|
||||
if len(c.cache) >= 256 {
|
||||
clear(c.cache)
|
||||
}
|
||||
c.cache[cacheKey] = cached{result, time.Now().Add(15 * time.Minute)}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLookupMunicipalityAndCache(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("key") != "test-key" {
|
||||
t.Error("key missing")
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v3/geocode/regeo":
|
||||
if r.URL.Query().Get("location") != "121.473700,31.230400" {
|
||||
t.Errorf("wrong coordinate order: %s", r.URL.Query().Get("location"))
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"district":"黄浦区","adcode":"310101"}}}`)
|
||||
case "/v3/weather/weatherInfo":
|
||||
if r.URL.Query().Get("city") != "310101" {
|
||||
t.Error("weather must use adcode")
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","lives":[{"weather":"晴","temperature":"24","reporttime":"2026-10-04 10:00:00"}]}`)
|
||||
default:
|
||||
t.Error("unexpected path")
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
for range 2 {
|
||||
result, err := client.Lookup(context.Background(), 31.2304, 121.4737)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.LocationName != "上海市 · 黄浦区" || result.Weather == nil || result.Weather.Temperature != "24" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("cache did not reduce calls: %d", calls.Load())
|
||||
}
|
||||
}
|
||||
func TestPartialWeatherFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v3/geocode/regeo" {
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"浙江省","city":"杭州市","district":"西湖区","adcode":"330106"}}}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
result, err := client.Lookup(context.Background(), 30.2, 120.1)
|
||||
if err != nil || result.LocationName != "杭州市 · 西湖区" || result.Weather != nil {
|
||||
t.Fatalf("must retain city without fabricated weather: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
func TestInvalidInputsAndProviderError(t *testing.T) {
|
||||
client := New("")
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err != ErrNotConfigured {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, latitude := range []float64{91, math.NaN(), math.Inf(1)} {
|
||||
if _, err := client.Lookup(context.Background(), latitude, 120); err != ErrCoordinates {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
client = New("secret-key")
|
||||
client.baseURL = srv.URL
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err == nil {
|
||||
t.Fatal("provider rejection accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/environment"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (a *API) environment(c *gin.Context) {
|
||||
lat, latErr := strconv.ParseFloat(c.Query("latitude"), 64)
|
||||
lon, lonErr := strconv.ParseFloat(c.Query("longitude"), 64)
|
||||
if latErr != nil || lonErr != nil {
|
||||
fail(c, http.StatusBadRequest, "请提供有效的定位坐标")
|
||||
return
|
||||
}
|
||||
result, err := a.env.Lookup(c.Request.Context(), lat, lon)
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, environment.ErrCoordinates) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if errors.Is(err, environment.ErrNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
fail(c, status, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
}
|
||||
@@ -12,18 +12,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/environment"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"github.com/sundynix/pets-be/internal/storage"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
env *environment.Client
|
||||
db *gorm.DB
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
|
||||
return &API{db: db, storage: storage, cfg: cfg}
|
||||
return &API{db: db, storage: storage, cfg: cfg, env: environment.New(cfg.AMap.Key)}
|
||||
}
|
||||
|
||||
func (a *API) Register(r *gin.Engine) {
|
||||
@@ -38,6 +40,7 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
user := api.Group("")
|
||||
user.Use(a.requireRole("user"))
|
||||
user.GET("/environment", a.environment)
|
||||
user.GET("/me", a.me)
|
||||
user.GET("/pets", a.listPets)
|
||||
user.POST("/pets", a.createPet)
|
||||
|
||||
Reference in New Issue
Block a user