Initial import of map-asset-gateway
This commit is contained in:
@@ -0,0 +1,888 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user