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 }