package basemapapi import ( "context" "encoding/json" "errors" "fmt" "html/template" "net/http" "net/url" "strconv" "strings" "time" "map-asset-gateway/api-go/internal/basemap" ) type Server struct { store *basemap.Store system basemap.SystemInfo startedAt time.Time template *template.Template mux *http.ServeMux } func New(store *basemap.Store, system basemap.SystemInfo) (*Server, error) { tmpl, err := template.New("admin").Funcs(template.FuncMap{ "fmtTime": func(value time.Time) string { if value.IsZero() { return "-" } return value.Local().Format("2006-01-02 15:04:05") }, "fmtTimePtr": func(value *time.Time) string { if value == nil || value.IsZero() { return "-" } return value.Local().Format("2006-01-02 15:04:05") }, "defaultVersion": func(value *basemap.BasemapVersion) string { if value == nil { return "-" } return value.Version }, "tokenGrants": func(grants []basemap.TokenGrant) string { if len(grants) == 0 { return "-" } parts := make([]string, 0, len(grants)) for _, grant := range grants { if grant.BasemapVersion != nil { parts = append(parts, grant.BasemapCode+":"+*grant.BasemapVersion) } else { parts = append(parts, grant.BasemapCode+":*") } } return strings.Join(parts, ", ") }, "vectorTokenGrants": func(grants []basemap.VectorTokenGrant) string { if len(grants) == 0 { return "-" } parts := make([]string, 0, len(grants)) for _, grant := range grants { parts = append(parts, grant.VectorCode) } return strings.Join(parts, ", ") }, "versionLabel": func(item basemap.BasemapVersion) string { if item.IsDefault { return item.Version + "(默认)" } return item.Version }, "vectorPath": func(item basemap.VectorAsset) string { if value, ok := item.Metadata["relative_path"].(string); ok && strings.TrimSpace(value) != "" { return value } return item.FileName }, "statusText": statusText, "statusTone": statusTone, "enabledText": func(enabled bool) string { if enabled { return "启用" } return "停用" }, "enabledTone": func(enabled bool) string { if enabled { return "success" } return "danger" }, "formatBytes": formatBytes, "flashClass": func(value string) string { if strings.HasPrefix(strings.TrimSpace(value), "操作失败:") { return "danger" } return "" }, }).Parse(adminTemplate) if err != nil { return nil, err } server := &Server{ store: store, system: system, startedAt: time.Now().UTC(), template: tmpl, mux: http.NewServeMux(), } server.routes() return server, nil } func (s *Server) Handler() http.Handler { return s.mux } func (s *Server) routes() { s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusFound) }) s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /ready", s.handleReady) s.mux.HandleFunc("GET /api/v1/system", s.handleSystem) s.mux.HandleFunc("GET /api/v1/admin/dashboard", s.handleDashboardJSON) s.mux.HandleFunc("GET /api/v1/admin/basemaps", s.handleBasemapsJSON) s.mux.HandleFunc("GET /api/v1/admin/vectors", s.handleVectorsJSON) s.mux.HandleFunc("GET /api/v1/admin/scan-sources", s.handleScanSourcesJSON) s.mux.HandleFunc("POST /api/v1/admin/scan-sources", s.handleCreateScanSourceJSON) s.mux.HandleFunc("POST /api/v1/admin/scan-sources/{code}/run", s.handleRunScanJSON) s.mux.HandleFunc("POST /api/v1/admin/vectors/rescan", s.handleRunVectorScanJSON) s.mux.HandleFunc("POST /api/v1/admin/basemaps/{code}/default-version", s.handleSetDefaultVersionJSON) s.mux.HandleFunc("GET /api/v1/admin/targets", s.handleTargetsJSON) s.mux.HandleFunc("POST /api/v1/admin/targets", s.handleCreateTargetJSON) s.mux.HandleFunc("GET /api/v1/admin/tokens", s.handleTokensJSON) s.mux.HandleFunc("POST /api/v1/admin/tokens", s.handleCreateTokenJSON) s.mux.HandleFunc("POST /api/v1/admin/tokens/{id}/disable", s.handleDisableTokenJSON) s.mux.HandleFunc("GET /api/v1/admin/push-records", s.handlePushRecordsJSON) s.mux.HandleFunc("POST /api/v1/admin/pushes", s.handlePushJSON) s.mux.HandleFunc("GET /api/v1/admin/vector-push-records", s.handleVectorPushRecordsJSON) s.mux.HandleFunc("POST /api/v1/admin/vector-pushes", s.handleVectorPushJSON) s.mux.HandleFunc("GET /api/v1/catalog/basemaps", s.handleCatalogList) s.mux.HandleFunc("GET /api/v1/catalog/basemaps/{code}", s.handleCatalogGet) s.mux.HandleFunc("GET /api/v1/catalog/vectors", s.handleVectorCatalogList) s.mux.HandleFunc("GET /api/v1/catalog/vectors/{code}", s.handleVectorCatalogGet) s.mux.HandleFunc("GET /tiles/{code}/{version}/{tilePath...}", s.handleTile) s.mux.HandleFunc("GET /geojson/{assetPath...}", s.handleGeoJSONByPath) s.mux.HandleFunc("GET /shp/{assetPath...}", s.handleShapeByPath) s.mux.HandleFunc("GET /vectors/{code}/download", s.handleVectorDownload) s.mux.HandleFunc("GET /admin/", s.handleAdminPage) s.mux.HandleFunc("POST /admin/scan-sources", s.handleCreateScanSourceForm) s.mux.HandleFunc("POST /admin/scan-sources/{code}/run", s.handleRunScanForm) s.mux.HandleFunc("POST /admin/vectors/rescan", s.handleRunVectorScanForm) s.mux.HandleFunc("POST /admin/basemaps/{code}/default", s.handleSetDefaultVersionForm) s.mux.HandleFunc("POST /admin/targets", s.handleCreateTargetForm) s.mux.HandleFunc("POST /admin/tokens", s.handleCreateTokenForm) s.mux.HandleFunc("POST /admin/tokens/{id}/disable", s.handleDisableTokenForm) s.mux.HandleFunc("POST /admin/pushes", s.handlePushForm) s.mux.HandleFunc("POST /admin/vector-pushes", s.handleVectorPushForm) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "service": s.system.ServiceName, "version": s.system.ServiceVersion, }) } func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ready", "started_at": s.startedAt, "scan_root": s.system.ScanRoot, "sqlite": s.system.SQLitePath, }) } func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.system) } func (s *Server) handleDashboardJSON(w http.ResponseWriter, r *http.Request) { data, err := s.store.Dashboard(r.Context(), s.system, "", "") if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, data) } func (s *Server) handleBasemapsJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListBasemaps(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleVectorsJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListVectorAssets(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleScanSourcesJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListScanSources(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleCreateScanSourceJSON(w http.ResponseWriter, r *http.Request) { var input basemap.CreateScanSourceInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("decode json: %w", err)) return } item, err := s.store.EnsureScanSource(r.Context(), input) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleRunScanJSON(w http.ResponseWriter, r *http.Request) { run, err := s.store.RunScan(r.Context(), r.PathValue("code")) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, run) } func (s *Server) handleRunVectorScanJSON(w http.ResponseWriter, r *http.Request) { result, err := s.store.RunVectorScan(r.Context()) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, result) } func (s *Server) handleSetDefaultVersionJSON(w http.ResponseWriter, r *http.Request) { var payload struct { Version string `json:"version"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeError(w, http.StatusBadRequest, err) return } if err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), payload.Version); err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleTargetsJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListTargetSystems(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleCreateTargetJSON(w http.ResponseWriter, r *http.Request) { var input basemap.CreateTargetSystemInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, http.StatusBadRequest, err) return } item, err := s.store.CreateTargetSystem(r.Context(), input) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleTokensJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListTokens(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleCreateTokenJSON(w http.ResponseWriter, r *http.Request) { input, err := decodeTokenInput(r) if err != nil { writeError(w, http.StatusBadRequest, err) return } item, err := s.store.CreateToken(r.Context(), input) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleDisableTokenJSON(w http.ResponseWriter, r *http.Request) { if err := s.store.DisableToken(r.Context(), r.PathValue("id")); err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handlePushRecordsJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListPushRecords(r.Context(), 20) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handleVectorPushRecordsJSON(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListVectorPushRecords(r.Context(), 20) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, items) } func (s *Server) handlePushJSON(w http.ResponseWriter, r *http.Request) { var payload struct { TargetCode string `json:"target_code"` BasemapCode string `json:"basemap_code"` Version string `json:"version"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeError(w, http.StatusBadRequest, err) return } item, err := s.store.PushBasemapVersion(r.Context(), payload.TargetCode, payload.BasemapCode, payload.Version) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleVectorPushJSON(w http.ResponseWriter, r *http.Request) { var payload struct { TargetCode string `json:"target_code"` VectorCode string `json:"vector_code"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeError(w, http.StatusBadRequest, err) return } item, err := s.store.PushVectorAsset(r.Context(), payload.TargetCode, payload.VectorCode) if err != nil { writeError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleCatalogList(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } items, err := s.store.ListBasemaps(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, s.store.FilterCatalog(auth, items)) } func (s *Server) handleCatalogGet(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } item, err := s.store.GetBasemapByCode(r.Context(), r.PathValue("code")) if err != nil { writeError(w, http.StatusNotFound, err) return } filtered := s.store.FilterCatalog(auth, []basemap.Basemap{item}) if len(filtered) == 0 { writeError(w, http.StatusForbidden, errors.New("token has no access to this basemap")) return } writeJSON(w, http.StatusOK, filtered[0]) } func (s *Server) handleVectorCatalogList(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } items, err := s.store.ListVectorAssets(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, s.store.FilterVectors(auth, items)) } func (s *Server) handleVectorCatalogGet(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } item, err := s.store.GetVectorAssetByCode(r.Context(), r.PathValue("code")) if err != nil { writeError(w, http.StatusNotFound, err) return } filtered := s.store.FilterVectors(auth, []basemap.VectorAsset{item}) if len(filtered) == 0 { writeError(w, http.StatusForbidden, errors.New("token has no access to this vector asset")) return } writeJSON(w, http.StatusOK, filtered[0]) } func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } version := r.PathValue("version") tilePath := r.PathValue("tilePath") item, err := s.store.ResolveTile(r.Context(), auth, r.PathValue("code"), version, tilePath) if err != nil && errors.Is(err, basemap.ErrBasemapVersionNotFound) && isTileZoom(version) { item, err = s.store.ResolveDefaultTile(r.Context(), auth, r.PathValue("code"), version+"/"+tilePath) } if err != nil { writeError(w, http.StatusNotFound, err) return } w.Header().Set("Content-Type", item.ContentType) http.ServeFile(w, r, item.FilePath) } func (s *Server) handleVectorDownload(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } item, err := s.store.ResolveVectorDownload(r.Context(), auth, r.PathValue("code")) if err != nil { writeError(w, http.StatusNotFound, err) return } w.Header().Set("Content-Type", item.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName)) if item.Zipped { if err := basemap.WriteVectorZip(w, item); err != nil { writeError(w, http.StatusInternalServerError, err) } return } http.ServeFile(w, r, item.FilePath) } func (s *Server) handleGeoJSONByPath(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "geojson") if err != nil { writeError(w, http.StatusNotFound, err) return } w.Header().Set("Content-Type", item.ContentType) http.ServeFile(w, r, item.FilePath) } func (s *Server) handleShapeByPath(w http.ResponseWriter, r *http.Request) { auth, ok := s.requireToken(w, r) if !ok { return } item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "shapefile", "shpzip") if err != nil { writeError(w, http.StatusNotFound, err) return } w.Header().Set("Content-Type", item.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName)) if item.Zipped { if err := basemap.WriteVectorZip(w, item); err != nil { writeError(w, http.StatusInternalServerError, err) } return } http.ServeFile(w, r, item.FilePath) } func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { data, err := s.store.Dashboard(r.Context(), s.system, r.URL.Query().Get("flash"), r.URL.Query().Get("token")) if err != nil { writeError(w, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.template.Execute(w, data); err != nil { writeError(w, http.StatusInternalServerError, err) } } func (s *Server) handleCreateScanSourceForm(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.redirectAdmin(w, r, "", "", err) return } _, err := s.store.EnsureScanSource(r.Context(), basemap.CreateScanSourceInput{ Code: r.FormValue("code"), Name: r.FormValue("name"), RootPath: r.FormValue("root_path"), ManifestName: r.FormValue("manifest_name"), }) s.redirectAdmin(w, r, "扫描源已保存", "", err) } func (s *Server) handleRunScanForm(w http.ResponseWriter, r *http.Request) { _, err := s.store.RunScan(r.Context(), r.PathValue("code")) s.redirectAdmin(w, r, "底图扫描已完成", "", err) } func (s *Server) handleRunVectorScanForm(w http.ResponseWriter, r *http.Request) { _, err := s.store.RunVectorScan(r.Context()) s.redirectAdmin(w, r, "矢量扫描已完成", "", err) } func (s *Server) handleSetDefaultVersionForm(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.redirectAdmin(w, r, "", "", err) return } err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), r.FormValue("version")) s.redirectAdmin(w, r, "默认版本已更新", "", err) } func (s *Server) handleCreateTargetForm(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.redirectAdmin(w, r, "", "", err) return } headers := map[string]string{} for _, line := range splitLines(r.FormValue("headers")) { key, value, ok := strings.Cut(line, ":") if !ok { continue } headers[strings.TrimSpace(key)] = strings.TrimSpace(value) } _, err := s.store.CreateTargetSystem(r.Context(), basemap.CreateTargetSystemInput{ Code: r.FormValue("code"), Name: r.FormValue("name"), CallbackURL: r.FormValue("callback_url"), CallbackMethod: r.FormValue("callback_method"), CallbackHeaders: headers, }) s.redirectAdmin(w, r, "目标系统已保存", "", err) } func (s *Server) handleCreateTokenForm(w http.ResponseWriter, r *http.Request) { input, err := decodeTokenInputFromForm(r) if err != nil { s.redirectAdmin(w, r, "", "", err) return } item, err := s.store.CreateToken(r.Context(), input) if err != nil { s.redirectAdmin(w, r, "", "", err) return } s.redirectAdmin(w, r, "访问令牌已创建", item.Token, nil) } func (s *Server) handleDisableTokenForm(w http.ResponseWriter, r *http.Request) { err := s.store.DisableToken(r.Context(), r.PathValue("id")) s.redirectAdmin(w, r, "访问令牌已停用", "", err) } func (s *Server) handlePushForm(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.redirectAdmin(w, r, "", "", err) return } _, err := s.store.PushBasemapVersion(r.Context(), r.FormValue("target_code"), r.FormValue("basemap_code"), r.FormValue("version")) s.redirectAdmin(w, r, "底图推送已完成", "", err) } func (s *Server) handleVectorPushForm(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.redirectAdmin(w, r, "", "", err) return } _, err := s.store.PushVectorAsset(r.Context(), r.FormValue("target_code"), r.FormValue("vector_code")) s.redirectAdmin(w, r, "矢量推送已完成", "", err) } func (s *Server) requireToken(w http.ResponseWriter, r *http.Request) (basemap.TokenAuth, bool) { rawToken := extractToken(r) auth, err := s.store.AuthorizeToken(r.Context(), rawToken) if err != nil { writeError(w, http.StatusUnauthorized, err) return basemap.TokenAuth{}, false } return auth, true } func extractToken(r *http.Request) string { if value := strings.TrimSpace(r.Header.Get("X-Access-Token")); value != "" { return value } if header := strings.TrimSpace(r.Header.Get("Authorization")); header != "" { if strings.HasPrefix(strings.ToLower(header), "bearer ") { return strings.TrimSpace(header[7:]) } } for _, key := range []string{"token", "access_token"} { if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" { return value } } return "" } func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) { var payload struct { Name string `json:"name"` BasemapCodes []string `json:"basemap_codes"` VectorCodes []string `json:"vector_codes"` ExpiresAt string `json:"expires_at"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { return basemap.CreateTokenInput{}, err } input := basemap.CreateTokenInput{ Name: payload.Name, BasemapCodes: payload.BasemapCodes, VectorCodes: payload.VectorCodes, } if strings.TrimSpace(payload.ExpiresAt) != "" { parsed, err := time.Parse(time.RFC3339, payload.ExpiresAt) if err != nil { return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err) } input.ExpiresAt = &parsed } return input, nil } func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error) { if err := r.ParseForm(); err != nil { return basemap.CreateTokenInput{}, err } input := basemap.CreateTokenInput{ Name: r.FormValue("name"), BasemapCodes: splitCSV(r.FormValue("basemap_codes")), VectorCodes: splitCSV(r.FormValue("vector_codes")), } if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" { if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 { expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute) input.ExpiresAt = &expiresAt } } return input, nil } func splitCSV(value string) []string { fields := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' }) result := make([]string, 0, len(fields)) for _, field := range fields { field = strings.TrimSpace(field) if field != "" { result = append(result, field) } } return result } func splitLines(value string) []string { raw := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") lines := make([]string, 0, len(raw)) for _, line := range raw { line = strings.TrimSpace(line) if line != "" { lines = append(lines, line) } } return lines } func isTileZoom(value string) bool { if value == "" { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true } func statusText(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "": return "-" case "active", "enabled": return "启用" case "disabled": return "停用" case "ready": return "就绪" case "succeeded": return "成功" case "failed": return "失败" case "pending": return "待处理" case "running": return "运行中" case "processing": return "处理中" case "current": return "当前" case "error": return "异常" default: return value } } func statusTone(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "active", "enabled", "ready", "succeeded", "current": return "success" case "pending", "running", "processing": return "warning" case "disabled", "failed", "error", "expired", "invalid": return "danger" default: return "neutral" } } func formatBytes(size int64) string { if size < 0 { return "-" } units := []string{"B", "KB", "MB", "GB", "TB"} value := float64(size) unit := 0 for value >= 1024 && unit < len(units)-1 { value /= 1024 unit++ } if unit == 0 { return fmt.Sprintf("%d %s", size, units[unit]) } return fmt.Sprintf("%.1f %s", value, units[unit]) } func localizeAdminError(err error) string { if err == nil { return "" } msg := strings.TrimSpace(err.Error()) quoted := quotedSegments(msg) switch { case msg == "scan source code is required": return "扫描源编码不能为空" case msg == "scan source root path is required": return "扫描根目录不能为空" case strings.HasPrefix(msg, "resolve root path:"): return "扫描根目录解析失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "resolve root path:")) case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" not found`): return fmt.Sprintf("未找到扫描源:%s", quoted[0]) case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" is disabled`): return fmt.Sprintf("扫描源已停用:%s", quoted[0]) case strings.HasPrefix(msg, "read scan root:"): return "读取扫描目录失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "read scan root:")) case strings.HasPrefix(msg, "scan root ") && strings.HasSuffix(msg, " is not a directory"): return "配置的扫描根路径不是目录:" + strings.TrimSuffix(strings.TrimPrefix(msg, "scan root "), " is not a directory") case msg == "target system code is required": return "目标系统编码不能为空" case msg == "callback url is required": return "回调地址不能为空" case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" not found`): return fmt.Sprintf("未找到目标系统:%s", quoted[0]) case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" is disabled`): return fmt.Sprintf("目标系统已停用:%s", quoted[0]) case msg == "token name is required": return "令牌名称不能为空" case msg == "at least one basemap code or vector code is required": return "至少填写一个底图编码或矢量资源编码/路径" case msg == "no valid basemap codes provided": return "未提供有效的底图编码" case len(quoted) == 1 && strings.HasPrefix(msg, `token "`) && strings.HasSuffix(msg, `" not found`): return fmt.Sprintf("未找到访问令牌:%s", quoted[0]) case msg == "basemap code and version are required": return "底图编码和版本不能为空" case len(quoted) == 1 && strings.HasPrefix(msg, `basemap "`) && strings.HasSuffix(msg, `" not found`): return fmt.Sprintf("未找到底图资源:%s", quoted[0]) case len(quoted) == 2 && strings.HasPrefix(msg, `version "`) && strings.Contains(msg, `" not found for basemap "`): return fmt.Sprintf("底图资源 %s 未找到版本 %s", quoted[1], quoted[0]) case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" not found`): return fmt.Sprintf("未找到矢量资源:%s", quoted[0]) case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" is not available in requested format`): return fmt.Sprintf("矢量资源当前不可按所选格式提供:%s", quoted[0]) default: return msg } } func quotedSegments(value string) []string { parts := strings.Split(value, `"`) result := make([]string, 0, len(parts)/2) for i := 1; i < len(parts); i += 2 { result = append(result, parts[i]) } return result } func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, flash string, token string, err error) { if err != nil { flash = "操作失败:" + localizeAdminError(err) } values := url.Values{} if flash != "" { values.Set("flash", flash) } if token != "" { values.Set("token", token) } location := "/admin/" if encoded := values.Encode(); encoded != "" { location += "?" + encoded } http.Redirect(w, r, location, http.StatusSeeOther) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func writeError(w http.ResponseWriter, status int, err error) { writeJSON(w, status, map[string]any{ "error": err.Error(), }) } func runStartupScan(ctx context.Context, store *basemap.Store) error { if !store.AutoScanEnabled() || strings.TrimSpace(store.ScanRoot()) == "" { return nil } _, err := store.RunScan(ctx, "local-basemaps") return err }