Initial import of map-asset-gateway

This commit is contained in:
2026-04-14 13:10:10 +08:00
commit 1893ad3857
30 changed files with 6676 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
package basemapapi
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"map-asset-gateway/api-go/internal/basemap"
)
func TestCompatibilityRoutes(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
tileRoot := filepath.Join(root, "tiles")
vectorRoot := filepath.Join(root, "vectors")
sqlitePath := filepath.Join(root, "basemap.db")
if err := os.MkdirAll(filepath.Join(tileRoot, "google_image", "0", "0"), 0o755); err != nil {
t.Fatalf("mkdir tile root: %v", err)
}
if err := os.WriteFile(filepath.Join(tileRoot, "google_image", "0", "0", "0.webp"), []byte("tile"), 0o644); err != nil {
t.Fatalf("write tile: %v", err)
}
if err := os.MkdirAll(vectorRoot, 0o755); err != nil {
t.Fatalf("mkdir vector root: %v", err)
}
if err := os.WriteFile(filepath.Join(vectorRoot, "全国行政区.geojson"), []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {
t.Fatalf("write geojson: %v", err)
}
store, err := basemap.OpenStore(basemap.StoreConfig{
SQLitePath: sqlitePath,
ScanRoot: tileRoot,
VectorScanRoot: vectorRoot,
APIBaseURL: "http://127.0.0.1:8910",
TileBaseURL: "http://127.0.0.1:8910",
VectorBaseURL: "http://127.0.0.1:8910",
})
if err != nil {
t.Fatalf("open store: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
}()
if _, err := store.RunScan(ctx, "local-basemaps"); err != nil {
t.Fatalf("run scan: %v", err)
}
if _, err := store.RunVectorScan(ctx); err != nil {
t.Fatalf("run vector scan: %v", err)
}
token, err := store.CreateToken(ctx, basemap.CreateTokenInput{
Name: "compat",
BasemapCodes: []string{"google_image"},
VectorCodes: []string{"全国行政区.geojson"},
})
if err != nil {
t.Fatalf("create token: %v", err)
}
server, err := New(store, basemap.SystemInfo{
ServiceName: "map-asset-gateway",
ServiceVersion: "test",
APIBaseURL: "http://127.0.0.1:8910",
TileBaseURL: "http://127.0.0.1:8910",
VectorBaseURL: "http://127.0.0.1:8910",
SQLitePath: sqlitePath,
ScanRoot: tileRoot,
VectorScanRoot: vectorRoot,
})
if err != nil {
t.Fatalf("new server: %v", err)
}
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
response, err := http.Get(httpServer.URL + "/tiles/google_image/0/0/0.webp?token=" + token.Token)
if err != nil {
t.Fatalf("request default tile route: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected tile status: %d", response.StatusCode)
}
tileBody, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read tile body: %v", err)
}
if string(tileBody) != "tile" {
t.Fatalf("unexpected tile body: %q", string(tileBody))
}
response, err = http.Get(httpServer.URL + "/geojson/%E5%85%A8%E5%9B%BD%E8%A1%8C%E6%94%BF%E5%8C%BA.geojson?token=" + token.Token)
if err != nil {
t.Fatalf("request geojson route: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected geojson status: %d", response.StatusCode)
}
}
@@ -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
}