package handlers import ( "context" "encoding/json" "fmt" "net/http" "sort" "sync" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/httpx" ) type RegionOptionsHandler struct { client *http.Client mu sync.Mutex cache []regionProvince } type regionProvince struct { Code string `json:"code"` Name string `json:"name"` Cities []regionCity `json:"cities"` } type regionCity struct { Code string `json:"code"` Name string `json:"name"` } type remoteProvince struct { Code string `json:"code"` Name string `json:"name"` } type remoteCity struct { Code string `json:"code"` Name string `json:"name"` Province string `json:"province"` } func NewRegionOptionsHandler() *RegionOptionsHandler { return &RegionOptionsHandler{ client: &http.Client{Timeout: 12 * time.Second}, } } func (h *RegionOptionsHandler) Get(w http.ResponseWriter, r *http.Request) { items, err := h.load(r.Context()) if err != nil { httpx.WriteError(w, err) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": items}) } func (h *RegionOptionsHandler) load(ctx context.Context) ([]regionProvince, error) { h.mu.Lock() if len(h.cache) > 0 { cached := h.cache h.mu.Unlock() return cached, nil } h.mu.Unlock() // Data source: // https://github.com/uiwjs/province-city-china // Using province + city JSON only, then reducing to the province/city structure // needed by ops workbench location management. provinces, err := h.fetchProvinces(ctx, "https://unpkg.com/province-city-china/dist/province.json") if err != nil { return nil, err } cities, err := h.fetchCities(ctx, "https://unpkg.com/province-city-china/dist/city.json") if err != nil { return nil, err } cityMap := make(map[string][]regionCity) for _, item := range cities { if item.Province == "" || item.Code == "" { continue } fullCode := item.Province + item.Code + "00" cityMap[item.Province] = append(cityMap[item.Province], regionCity{ Code: fullCode, Name: item.Name, }) } for key := range cityMap { sort.Slice(cityMap[key], func(i, j int) bool { return cityMap[key][i].Code < cityMap[key][j].Code }) } items := make([]regionProvince, 0, len(provinces)) for _, item := range provinces { if len(item.Code) < 2 { continue } provinceCode := item.Code[:2] province := regionProvince{ Code: item.Code, Name: item.Name, } if entries := cityMap[provinceCode]; len(entries) > 0 { province.Cities = entries } else { // 直辖市 / 特殊地区没有单独的地级市列表时,退化成自身即可。 province.Cities = []regionCity{{ Code: item.Code, Name: item.Name, }} } items = append(items, province) } h.mu.Lock() h.cache = items h.mu.Unlock() return items, nil } func (h *RegionOptionsHandler) fetchProvinces(ctx context.Context, url string) ([]remoteProvince, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用") } resp, err := h.client.Do(req) if err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("省级数据拉取失败: %d", resp.StatusCode)) } var items []remoteProvince if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "省级数据格式无效") } return items, nil } func (h *RegionOptionsHandler) fetchCities(ctx context.Context, url string) ([]remoteCity, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用") } resp, err := h.client.Do(req) if err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", "省市数据源不可用") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, apperr.New(http.StatusBadGateway, "region_source_unavailable", fmt.Sprintf("市级数据拉取失败: %d", resp.StatusCode)) } var items []remoteCity if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { return nil, apperr.New(http.StatusBadGateway, "region_source_invalid", "市级数据格式无效") } return items, nil }