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
+535
View File
@@ -0,0 +1,535 @@
package basemap
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"map-asset-gateway/api-go/internal/uid"
)
var ErrBasemapVersionNotFound = errors.New("basemap version not found")
func (s *Store) CreateToken(ctx context.Context, input CreateTokenInput) (CreatedToken, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return CreatedToken{}, errors.New("token name is required")
}
if len(input.BasemapCodes) == 0 {
if len(input.VectorCodes) == 0 {
return CreatedToken{}, errors.New("at least one basemap code or vector code is required")
}
}
resolvedVectorAssets := map[string]VectorAsset{}
for _, ref := range input.VectorCodes {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
}
asset, err := s.GetVectorAssetByRef(ctx, ref)
if err != nil {
return CreatedToken{}, err
}
resolvedVectorAssets[asset.Code] = asset
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return CreatedToken{}, err
}
defer tx.Rollback()
token, tokenHash, err := s.generateTokenSecret()
if err != nil {
return CreatedToken{}, err
}
now := nowUTC()
tokenID := uid.New()
prefix := token
if len(prefix) > 16 {
prefix = prefix[:16]
}
_, err = tx.ExecContext(ctx, `
INSERT INTO service_tokens (id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at)
VALUES (?, ?, ?, ?, 'active', ?, NULL, ?)
`, tokenID, name, tokenHash, prefix, nullableTime(input.ExpiresAt), toRFC3339(now))
if err != nil {
return CreatedToken{}, err
}
normalizedCodes := map[string]struct{}{}
for _, code := range input.BasemapCodes {
normalized := normalizeBasemapCode(code)
if normalized != "" {
normalizedCodes[normalized] = struct{}{}
}
}
if len(normalizedCodes) == 0 {
if len(resolvedVectorAssets) == 0 {
return CreatedToken{}, errors.New("no valid basemap codes provided")
}
}
for _, code := range sortedKeys(normalizedCodes) {
var basemapID string
err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, code).Scan(&basemapID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return CreatedToken{}, fmt.Errorf("basemap %q not found", code)
}
return CreatedToken{}, err
}
grantID := uid.New()
if _, err := tx.ExecContext(ctx, `
INSERT INTO token_grants (id, token_id, basemap_id, basemap_version_id, permission, created_at)
VALUES (?, ?, ?, NULL, ?, ?)
`, grantID, tokenID, basemapID, readPermission, toRFC3339(now)); err != nil {
return CreatedToken{}, err
}
}
vectorCodes := make([]string, 0, len(resolvedVectorAssets))
for code := range resolvedVectorAssets {
vectorCodes = append(vectorCodes, code)
}
sort.Strings(vectorCodes)
for _, code := range vectorCodes {
asset := resolvedVectorAssets[code]
grantID := uid.New()
if _, err := tx.ExecContext(ctx, `
INSERT INTO vector_token_grants (id, token_id, vector_asset_id, permission, created_at)
VALUES (?, ?, ?, ?, ?)
`, grantID, tokenID, asset.ID, readPermission, toRFC3339(now)); err != nil {
return CreatedToken{}, err
}
}
if err := tx.Commit(); err != nil {
return CreatedToken{}, err
}
meta, err := s.getTokenByID(ctx, tokenID)
if err != nil {
return CreatedToken{}, err
}
return CreatedToken{
Meta: meta,
Token: token,
}, nil
}
func (s *Store) ListTokens(ctx context.Context) ([]ServiceToken, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
FROM service_tokens
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ServiceToken
for rows.Next() {
item, _, err := scanServiceToken(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
grantsByToken, err := s.loadAllTokenGrants(ctx)
if err != nil {
return nil, err
}
vectorGrantsByToken, err := s.loadAllVectorTokenGrants(ctx)
if err != nil {
return nil, err
}
for i := range items {
items[i].Grants = grantsByToken[items[i].ID]
items[i].VectorGrants = vectorGrantsByToken[items[i].ID]
}
return items, nil
}
func (s *Store) loadAllTokenGrants(ctx context.Context) (map[string][]TokenGrant, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
g.id,
g.token_id,
g.basemap_id,
b.code,
g.basemap_version_id,
v.version,
g.permission,
g.created_at
FROM token_grants g
JOIN basemaps b ON b.id = g.basemap_id
LEFT JOIN basemap_versions v ON v.id = g.basemap_version_id
ORDER BY g.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string][]TokenGrant{}
for rows.Next() {
item, err := scanTokenGrant(rows)
if err != nil {
return nil, err
}
result[item.TokenID] = append(result[item.TokenID], item)
}
return result, rows.Err()
}
func (s *Store) loadAllVectorTokenGrants(ctx context.Context) (map[string][]VectorTokenGrant, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
g.id,
g.token_id,
g.vector_asset_id,
v.code,
g.permission,
g.created_at
FROM vector_token_grants g
JOIN vector_assets v ON v.id = g.vector_asset_id
ORDER BY g.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string][]VectorTokenGrant{}
for rows.Next() {
item, err := scanVectorTokenGrant(rows)
if err != nil {
return nil, err
}
result[item.TokenID] = append(result[item.TokenID], item)
}
return result, rows.Err()
}
func (s *Store) DisableToken(ctx context.Context, tokenID string) error {
result, err := s.db.ExecContext(ctx, `UPDATE service_tokens SET status = 'disabled' WHERE id = ?`, strings.TrimSpace(tokenID))
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("token %q not found", tokenID)
}
return nil
}
func (s *Store) getTokenByID(ctx context.Context, tokenID string) (ServiceToken, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
FROM service_tokens
WHERE id = ?
`, tokenID)
item, _, err := scanServiceToken(row)
if err != nil {
return ServiceToken{}, err
}
grants, err := s.loadAllTokenGrants(ctx)
if err != nil {
return ServiceToken{}, err
}
item.Grants = grants[item.ID]
vectorGrants, err := s.loadAllVectorTokenGrants(ctx)
if err != nil {
return ServiceToken{}, err
}
item.VectorGrants = vectorGrants[item.ID]
return item, nil
}
func (s *Store) AuthorizeToken(ctx context.Context, rawToken string) (TokenAuth, error) {
rawToken = strings.TrimSpace(rawToken)
if rawToken == "" {
return TokenAuth{}, errors.New("missing token")
}
sum := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(sum[:])
row := s.db.QueryRowContext(ctx, `
SELECT id, name, token_hash, token_prefix, status, expires_at, last_used_at, created_at
FROM service_tokens
WHERE token_hash = ?
`, tokenHash)
item, _, err := scanServiceToken(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return TokenAuth{}, errors.New("invalid token")
}
return TokenAuth{}, err
}
if item.Status != "active" {
return TokenAuth{}, errors.New("token is disabled")
}
if item.ExpiresAt != nil && item.ExpiresAt.Before(nowUTC()) {
return TokenAuth{}, errors.New("token is expired")
}
grantsByToken, err := s.loadAllTokenGrants(ctx)
if err != nil {
return TokenAuth{}, err
}
item.Grants = grantsByToken[item.ID]
vectorGrantsByToken, err := s.loadAllVectorTokenGrants(ctx)
if err != nil {
return TokenAuth{}, err
}
item.VectorGrants = vectorGrantsByToken[item.ID]
permissions := map[string]map[string]VersionScope{}
for _, grant := range item.Grants {
group, ok := permissions[grant.Permission]
if !ok {
group = map[string]VersionScope{}
permissions[grant.Permission] = group
}
scope := group[grant.BasemapCode]
if grant.BasemapVersion == nil {
scope.AllVersions = true
scope.Versions = nil
} else if !scope.AllVersions {
if scope.Versions == nil {
scope.Versions = map[string]struct{}{}
}
scope.Versions[*grant.BasemapVersion] = struct{}{}
}
group[grant.BasemapCode] = scope
}
vectorPermissions := map[string]map[string]struct{}{}
for _, grant := range item.VectorGrants {
group, ok := vectorPermissions[grant.Permission]
if !ok {
group = map[string]struct{}{}
vectorPermissions[grant.Permission] = group
}
group[grant.VectorCode] = struct{}{}
}
_, _ = s.db.ExecContext(ctx, `UPDATE service_tokens SET last_used_at = ? WHERE id = ?`, toRFC3339(nowUTC()), item.ID)
return TokenAuth{
Token: item,
Permissions: permissions,
VectorPermissions: vectorPermissions,
}, nil
}
func (auth TokenAuth) CanReadBasemap(code string, version string) bool {
group, ok := auth.Permissions[readPermission]
if !ok {
return false
}
scope, ok := group[normalizeBasemapCode(code)]
if !ok {
return false
}
if scope.AllVersions {
return true
}
if version == "" {
return len(scope.Versions) > 0
}
_, ok = scope.Versions[strings.TrimSpace(version)]
return ok
}
func (s *Store) FilterCatalog(auth TokenAuth, basemaps []Basemap) []Basemap {
filtered := make([]Basemap, 0, len(basemaps))
for _, basemap := range basemaps {
if !auth.CanReadBasemap(basemap.Code, "") {
continue
}
copyItem := basemap
copyItem.Versions = nil
copyItem.Default = nil
for _, version := range basemap.Versions {
if auth.CanReadBasemap(basemap.Code, version.Version) {
copyItem.Versions = append(copyItem.Versions, version)
if version.IsDefault {
copyVersion := version
copyItem.Default = &copyVersion
}
}
}
if len(copyItem.Versions) == 0 {
continue
}
if copyItem.Default == nil {
copyVersion := copyItem.Versions[0]
copyItem.Default = &copyVersion
}
filtered = append(filtered, copyItem)
}
return filtered
}
func (s *Store) ResolveTile(ctx context.Context, auth TokenAuth, basemapCode, version, tilePath string) (TileDescriptor, error) {
basemapCode = normalizeBasemapCode(basemapCode)
version = strings.TrimSpace(version)
tilePath = filepath.Clean(strings.TrimPrefix(strings.TrimSpace(tilePath), "/"))
if tilePath == "." || tilePath == "" {
return TileDescriptor{}, errors.New("tile path is required")
}
if !auth.CanReadBasemap(basemapCode, version) {
return TileDescriptor{}, errors.New("token has no access to this basemap")
}
row := s.db.QueryRowContext(ctx, `
SELECT
v.id,
v.basemap_id,
b.code,
v.version,
v.status,
v.is_default,
v.manifest_path,
v.tile_root_path,
v.url_template,
v.tile_format,
v.tile_scheme,
v.min_zoom,
v.max_zoom,
v.bbox_json,
v.attribution,
v.metadata_json,
v.created_at,
v.updated_at
FROM basemap_versions v
JOIN basemaps b ON b.id = v.basemap_id
WHERE b.code = ? AND v.version = ?
`, basemapCode, version)
versionRow, err := scanBasemapVersionRow(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return TileDescriptor{}, fmt.Errorf("%w: %s/%s", ErrBasemapVersionNotFound, basemapCode, version)
}
return TileDescriptor{}, err
}
versionItem := decodeBasemapVersion(versionRow)
basemap, err := s.GetBasemapByCode(ctx, basemapCode)
if err != nil {
return TileDescriptor{}, err
}
filePath, err := safeJoin(versionItem.TileRootPath, tilePath)
if err != nil {
return TileDescriptor{}, err
}
info, err := os.Stat(filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return TileDescriptor{}, fmt.Errorf("tile %s not found", tilePath)
}
return TileDescriptor{}, err
}
if info.IsDir() {
return TileDescriptor{}, errors.New("tile path is a directory")
}
contentType := mime.TypeByExtension(filepath.Ext(filePath))
if contentType == "" {
contentType = "application/octet-stream"
}
return TileDescriptor{
FilePath: filePath,
ContentType: contentType,
Basemap: basemap,
Version: versionItem,
Token: auth.Token,
RelativePath: tilePath,
}, nil
}
func (s *Store) ResolveDefaultTile(ctx context.Context, auth TokenAuth, basemapCode, tilePath string) (TileDescriptor, error) {
basemapCode = normalizeBasemapCode(basemapCode)
if basemapCode == "" {
return TileDescriptor{}, errors.New("basemap code is required")
}
if !auth.CanReadBasemap(basemapCode, "") {
return TileDescriptor{}, errors.New("token has no access to this basemap")
}
version, err := s.GetDefaultBasemapVersion(ctx, basemapCode)
if err != nil {
return TileDescriptor{}, err
}
if !auth.CanReadBasemap(basemapCode, version.Version) {
return TileDescriptor{}, errors.New("token has no access to the default basemap version")
}
return s.ResolveTile(ctx, auth, basemapCode, version.Version, tilePath)
}
func (s *Store) GetDefaultBasemapVersion(ctx context.Context, basemapCode string) (BasemapVersion, error) {
basemapCode = normalizeBasemapCode(basemapCode)
row := s.db.QueryRowContext(ctx, `
SELECT
v.id,
v.basemap_id,
b.code,
v.version,
v.status,
v.is_default,
v.manifest_path,
v.tile_root_path,
v.url_template,
v.tile_format,
v.tile_scheme,
v.min_zoom,
v.max_zoom,
v.bbox_json,
v.attribution,
v.metadata_json,
v.created_at,
v.updated_at
FROM basemap_versions v
JOIN basemaps b ON b.id = v.basemap_id
WHERE b.code = ?
ORDER BY v.is_default DESC, v.version ASC
LIMIT 1
`, basemapCode)
versionRow, err := scanBasemapVersionRow(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return BasemapVersion{}, fmt.Errorf("basemap %q has no versions", basemapCode)
}
return BasemapVersion{}, err
}
return decodeBasemapVersion(versionRow), nil
}
func safeJoin(root, relative string) (string, error) {
root = filepath.Clean(root)
if root == "." || root == "" {
return "", errors.New("invalid tile root")
}
target := filepath.Clean(filepath.Join(root, relative))
rel, err := filepath.Rel(root, target)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("invalid tile path")
}
return target, nil
}
+302
View File
@@ -0,0 +1,302 @@
package basemap
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"strings"
"map-asset-gateway/api-go/internal/uid"
)
func (s *Store) EnsureScanSource(ctx context.Context, input CreateScanSourceInput) (ScanSource, error) {
code := normalizeBasemapCode(input.Code)
if code == "" {
return ScanSource{}, errors.New("scan source code is required")
}
name := strings.TrimSpace(input.Name)
if name == "" {
name = code
}
rootPath := strings.TrimSpace(input.RootPath)
if rootPath == "" {
return ScanSource{}, errors.New("scan source root path is required")
}
absRoot, err := filepath.Abs(rootPath)
if err != nil {
return ScanSource{}, fmt.Errorf("resolve root path: %w", err)
}
manifestName := strings.TrimSpace(input.ManifestName)
if manifestName == "" {
manifestName = defaultManifestName
}
now := nowUTC()
id := uid.Deterministic("scan-source", code)
_, err = s.db.ExecContext(ctx, `
INSERT INTO scan_sources (
id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 1, '', ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
root_path = excluded.root_path,
manifest_name = excluded.manifest_name,
enabled = 1,
updated_at = excluded.updated_at
`, id, code, name, absRoot, manifestName, toRFC3339(now), toRFC3339(now))
if err != nil {
return ScanSource{}, fmt.Errorf("upsert scan source: %w", err)
}
return s.GetScanSourceByCode(ctx, code)
}
func (s *Store) GetScanSourceByCode(ctx context.Context, code string) (ScanSource, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
FROM scan_sources
WHERE code = ?
`, normalizeBasemapCode(code))
item, err := scanScanSource(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ScanSource{}, fmt.Errorf("scan source %q not found", code)
}
return ScanSource{}, err
}
return item, nil
}
func (s *Store) ListScanSources(ctx context.Context) ([]ScanSource, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, name, root_path, manifest_name, enabled, metadata_json, created_at, updated_at
FROM scan_sources
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ScanSource
for rows.Next() {
item, err := scanScanSource(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListBasemaps(ctx context.Context) ([]Basemap, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, name, type, status, description, created_at, updated_at
FROM basemaps
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Basemap
indexByID := map[string]int{}
for rows.Next() {
item, err := scanBasemap(rows)
if err != nil {
return nil, err
}
indexByID[item.ID] = len(items)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
versionRows, err := s.db.QueryContext(ctx, `
SELECT
v.id,
v.basemap_id,
b.code,
v.version,
v.status,
v.is_default,
v.manifest_path,
v.tile_root_path,
v.url_template,
v.tile_format,
v.tile_scheme,
v.min_zoom,
v.max_zoom,
v.bbox_json,
v.attribution,
v.metadata_json,
v.created_at,
v.updated_at
FROM basemap_versions v
JOIN basemaps b ON b.id = v.basemap_id
ORDER BY b.code, v.version
`)
if err != nil {
return nil, err
}
defer versionRows.Close()
for versionRows.Next() {
row, err := scanBasemapVersionRow(versionRows)
if err != nil {
return nil, err
}
index, ok := indexByID[row.BasemapID]
if !ok {
continue
}
version := decodeBasemapVersion(row)
items[index].Versions = append(items[index].Versions, version)
if version.IsDefault {
copyValue := version
items[index].Default = &copyValue
}
}
if err := versionRows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetBasemapByCode(ctx context.Context, code string) (Basemap, error) {
items, err := s.ListBasemaps(ctx)
if err != nil {
return Basemap{}, err
}
normalized := normalizeBasemapCode(code)
for _, item := range items {
if item.Code == normalized {
return item, nil
}
}
return Basemap{}, fmt.Errorf("basemap %q not found", code)
}
func (s *Store) SetDefaultVersion(ctx context.Context, basemapCode, version string) error {
basemapCode = normalizeBasemapCode(basemapCode)
version = strings.TrimSpace(version)
if basemapCode == "" || version == "" {
return errors.New("basemap code and version are required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var basemapID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, basemapCode).Scan(&basemapID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("basemap %q not found", basemapCode)
}
return err
}
result, err := tx.ExecContext(ctx, `
UPDATE basemap_versions
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
WHERE basemap_id = ?
`, version, toRFC3339(nowUTC()), basemapID)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("version %q not found for basemap %q", version, basemapCode)
}
if _, err := tx.ExecContext(ctx, `UPDATE basemaps SET updated_at = ? WHERE id = ?`, toRFC3339(nowUTC()), basemapID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ListScanRuns(ctx context.Context, limit int) ([]ScanRun, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx, `
SELECT
r.id,
r.scan_source_id,
s.code,
r.status,
r.scanned_count,
r.added_count,
r.updated_count,
r.removed_count,
r.summary_json,
r.started_at,
r.finished_at
FROM scan_runs r
JOIN scan_sources s ON s.id = r.scan_source_id
ORDER BY r.started_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ScanRun
for rows.Next() {
item, err := scanScanRun(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListPushRecords(ctx context.Context, limit int) ([]PushRecord, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx, `
SELECT
p.id,
p.target_system_id,
t.code,
p.basemap_version_id,
b.code,
v.version,
p.status,
p.request_json,
p.response_status,
p.response_body,
p.error_message,
p.pushed_at,
p.finished_at
FROM push_records p
JOIN target_systems t ON t.id = p.target_system_id
JOIN basemap_versions v ON v.id = p.basemap_version_id
JOIN basemaps b ON b.id = v.basemap_id
ORDER BY p.pushed_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PushRecord
for rows.Next() {
item, err := scanPushRecord(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
@@ -0,0 +1,106 @@
package basemap
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestInsarCompatibilityHelpers(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(filepath.Join(vectorRoot, "landuse"), 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)
}
for _, name := range []string{"parcel.shp", "parcel.dbf", "parcel.shx"} {
if err := os.WriteFile(filepath.Join(vectorRoot, "landuse", name), []byte(name), 0o644); err != nil {
t.Fatalf("write shapefile part %s: %v", name, err)
}
}
store, err := OpenStore(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, defaultScanSourceCode); err != nil {
t.Fatalf("run basemap scan: %v", err)
}
if _, err := store.RunVectorScan(ctx); err != nil {
t.Fatalf("run vector scan: %v", err)
}
created, err := store.CreateToken(ctx, CreateTokenInput{
Name: "insar-v2",
BasemapCodes: []string{"google_image"},
VectorCodes: []string{"全国行政区.geojson", "landuse/parcel.shp"},
})
if err != nil {
t.Fatalf("create token: %v", err)
}
auth, err := store.AuthorizeToken(ctx, created.Token)
if err != nil {
t.Fatalf("authorize token: %v", err)
}
tile, err := store.ResolveDefaultTile(ctx, auth, "google_image", "0/0/0.webp")
if err != nil {
t.Fatalf("resolve default tile: %v", err)
}
if tile.Version.Version != "current" {
t.Fatalf("unexpected default version: %s", tile.Version.Version)
}
geojson, err := store.ResolveVectorDownloadByRef(ctx, auth, "全国行政区.geojson", "geojson")
if err != nil {
t.Fatalf("resolve geojson by path: %v", err)
}
if geojson.Asset.Format != "geojson" {
t.Fatalf("unexpected geojson format: %s", geojson.Asset.Format)
}
if filepath.Base(geojson.FilePath) != "全国行政区.geojson" {
t.Fatalf("unexpected geojson file: %s", geojson.FilePath)
}
shp, err := store.ResolveVectorDownloadByRef(ctx, auth, "landuse/parcel.shp", "shapefile")
if err != nil {
t.Fatalf("resolve shapefile by path: %v", err)
}
if !shp.Zipped {
t.Fatal("expected shapefile download to be zipped")
}
if shp.FileName != "parcel.zip" {
t.Fatalf("unexpected shapefile name: %s", shp.FileName)
}
if len(shp.PackageFiles) != 3 {
t.Fatalf("unexpected shapefile package size: %d", len(shp.PackageFiles))
}
}
@@ -0,0 +1,51 @@
package basemap
import "context"
func (s *Store) Dashboard(ctx context.Context, info SystemInfo, flash string, createdToken string) (DashboardData, error) {
basemaps, err := s.ListBasemaps(ctx)
if err != nil {
return DashboardData{}, err
}
vectorAssets, err := s.ListVectorAssets(ctx)
if err != nil {
return DashboardData{}, err
}
scanSources, err := s.ListScanSources(ctx)
if err != nil {
return DashboardData{}, err
}
scanRuns, err := s.ListScanRuns(ctx, 10)
if err != nil {
return DashboardData{}, err
}
targetSystems, err := s.ListTargetSystems(ctx)
if err != nil {
return DashboardData{}, err
}
tokens, err := s.ListTokens(ctx)
if err != nil {
return DashboardData{}, err
}
pushRecords, err := s.ListPushRecords(ctx, 10)
if err != nil {
return DashboardData{}, err
}
vectorPushRecords, err := s.ListVectorPushRecords(ctx, 10)
if err != nil {
return DashboardData{}, err
}
return DashboardData{
SystemInfo: info,
Basemaps: basemaps,
VectorAssets: vectorAssets,
ScanSources: scanSources,
ScanRuns: scanRuns,
TargetSystems: targetSystems,
Tokens: tokens,
PushRecords: pushRecords,
VectorPushRecords: vectorPushRecords,
Flash: flash,
CreatedToken: createdToken,
}, nil
}
@@ -0,0 +1,8 @@
package basemap
func defaultText(value, fallback string) string {
if value != "" {
return value
}
return fallback
}
@@ -0,0 +1,93 @@
package basemap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
func LoadManifest(path string) (Manifest, error) {
content, err := os.ReadFile(path)
if err != nil {
return Manifest{}, err
}
var item Manifest
if err := json.Unmarshal(content, &item); err != nil {
return Manifest{}, fmt.Errorf("decode manifest %s: %w", path, err)
}
item.Code = normalizeCode(item.Code)
item.Name = strings.TrimSpace(item.Name)
item.Type = defaultText(strings.TrimSpace(item.Type), "xyz")
item.Description = strings.TrimSpace(item.Description)
item.Version = strings.TrimSpace(item.Version)
item.Status = defaultText(strings.TrimSpace(item.Status), "ready")
item.TileFormat = normalizeTileFormat(item.TileFormat)
item.TileScheme = defaultText(strings.TrimSpace(item.TileScheme), "xyz")
item.Attribution = strings.TrimSpace(item.Attribution)
item.RootPath = strings.TrimSpace(item.RootPath)
if item.RootPath == "" {
item.RootPath = "tiles"
}
if item.MaxZoom < item.MinZoom {
return Manifest{}, fmt.Errorf("manifest %s has invalid zoom range", path)
}
if len(item.BBox) != 0 && len(item.BBox) != 4 {
return Manifest{}, fmt.Errorf("manifest %s has invalid bbox", path)
}
if item.Code == "" {
return Manifest{}, fmt.Errorf("manifest %s missing code", path)
}
if item.Name == "" {
item.Name = item.Code
}
if item.Version == "" {
item.Version = filepath.Base(filepath.Dir(path))
}
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
return item, nil
}
func normalizeCode(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return ""
}
var builder strings.Builder
lastDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastDash = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastDash = false
case r == '_' || r == '-' || r == '.' || r == ' ':
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func normalizeTileFormat(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "jpg", "jpeg":
return "jpg"
case "png", "webp", "pbf":
return value
case "":
return "png"
default:
return value
}
}
+235
View File
@@ -0,0 +1,235 @@
package basemap
import "time"
type SystemInfo struct {
ServiceName string `json:"service_name"`
ServiceVersion string `json:"service_version"`
Commit string `json:"commit"`
Env string `json:"env"`
APIBaseURL string `json:"api_base_url"`
TileBaseURL string `json:"tile_base_url"`
VectorBaseURL string `json:"vector_base_url"`
SQLitePath string `json:"sqlite_path"`
ScanRoot string `json:"scan_root"`
VectorScanRoot string `json:"vector_scan_root"`
StartedAt time.Time `json:"started_at"`
}
type Basemap struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Description string `json:"description,omitempty"`
Default *BasemapVersion `json:"default_version,omitempty"`
Versions []BasemapVersion `json:"versions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type BasemapVersion struct {
ID string `json:"id"`
BasemapID string `json:"basemap_id"`
BasemapCode string `json:"basemap_code"`
Version string `json:"version"`
Status string `json:"status"`
IsDefault bool `json:"is_default"`
ManifestPath string `json:"manifest_path"`
TileRootPath string `json:"tile_root_path"`
URLTemplate string `json:"url_template"`
TileFormat string `json:"tile_format"`
TileScheme string `json:"tile_scheme"`
MinZoom int `json:"min_zoom"`
MaxZoom int `json:"max_zoom"`
BBox []float64 `json:"bbox,omitempty"`
Attribution string `json:"attribution,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ScanSource struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
RootPath string `json:"root_path"`
ManifestName string `json:"manifest_name"`
Enabled bool `json:"enabled"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ScanRun struct {
ID string `json:"id"`
ScanSourceID string `json:"scan_source_id"`
SourceCode string `json:"source_code"`
Status string `json:"status"`
ScannedCount int `json:"scanned_count"`
AddedCount int `json:"added_count"`
UpdatedCount int `json:"updated_count"`
RemovedCount int `json:"removed_count"`
Summary map[string]any `json:"summary,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
type TargetSystem struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
CallbackURL string `json:"callback_url"`
CallbackMethod string `json:"callback_method"`
CallbackHeaders map[string]string `json:"callback_headers,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ServiceToken struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Grants []TokenGrant `json:"grants,omitempty"`
VectorGrants []VectorTokenGrant `json:"vector_grants,omitempty"`
}
type TokenGrant struct {
ID string `json:"id"`
TokenID string `json:"token_id"`
BasemapID string `json:"basemap_id"`
BasemapCode string `json:"basemap_code"`
BasemapVersionID *string `json:"basemap_version_id,omitempty"`
BasemapVersion *string `json:"basemap_version,omitempty"`
Permission string `json:"permission"`
CreatedAt time.Time `json:"created_at"`
}
type PushRecord struct {
ID string `json:"id"`
TargetSystemID string `json:"target_system_id"`
TargetSystemCode string `json:"target_system_code"`
BasemapVersionID string `json:"basemap_version_id"`
BasemapCode string `json:"basemap_code"`
BasemapVersion string `json:"basemap_version"`
Status string `json:"status"`
Request map[string]any `json:"request,omitempty"`
ResponseStatus *int `json:"response_status,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
PushedAt time.Time `json:"pushed_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
type VectorAsset struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Format string `json:"format"`
Status string `json:"status"`
Description string `json:"description,omitempty"`
FilePath string `json:"file_path"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
Checksum string `json:"checksum"`
DownloadURL string `json:"download_url"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type VectorTokenGrant struct {
ID string `json:"id"`
TokenID string `json:"token_id"`
VectorID string `json:"vector_id"`
VectorCode string `json:"vector_code"`
Permission string `json:"permission"`
CreatedAt time.Time `json:"created_at"`
}
type VectorPushRecord struct {
ID string `json:"id"`
TargetSystemID string `json:"target_system_id"`
TargetSystemCode string `json:"target_system_code"`
VectorAssetID string `json:"vector_asset_id"`
VectorCode string `json:"vector_code"`
Status string `json:"status"`
Request map[string]any `json:"request,omitempty"`
ResponseStatus *int `json:"response_status,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
PushedAt time.Time `json:"pushed_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
type Manifest struct {
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Version string `json:"version"`
Status string `json:"status"`
TileFormat string `json:"tile_format"`
TileScheme string `json:"tile_scheme"`
MinZoom int `json:"min_zoom"`
MaxZoom int `json:"max_zoom"`
BBox []float64 `json:"bbox"`
Attribution string `json:"attribution"`
RootPath string `json:"root_path"`
IsDefault bool `json:"is_default"`
Metadata map[string]any `json:"metadata"`
}
type CreateScanSourceInput struct {
Code string `json:"code"`
Name string `json:"name"`
RootPath string `json:"root_path"`
ManifestName string `json:"manifest_name"`
}
type CreateTargetSystemInput struct {
Code string `json:"code"`
Name string `json:"name"`
CallbackURL string `json:"callback_url"`
CallbackMethod string `json:"callback_method"`
CallbackHeaders map[string]string `json:"callback_headers"`
}
type CreateTokenInput struct {
Name string
BasemapCodes []string
VectorCodes []string
ExpiresAt *time.Time
}
type TokenAuth struct {
Token ServiceToken
// permission -> basemap_code -> version scope
Permissions map[string]map[string]VersionScope
VectorPermissions map[string]map[string]struct{}
}
type VersionScope struct {
AllVersions bool
Versions map[string]struct{}
}
type DashboardData struct {
SystemInfo SystemInfo
Basemaps []Basemap
VectorAssets []VectorAsset
ScanSources []ScanSource
ScanRuns []ScanRun
TargetSystems []TargetSystem
Tokens []ServiceToken
PushRecords []PushRecord
VectorPushRecords []VectorPushRecord
Flash string
CreatedToken string
}
+327
View File
@@ -0,0 +1,327 @@
package basemap
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"map-asset-gateway/api-go/internal/uid"
)
func (s *Store) CreateTargetSystem(ctx context.Context, input CreateTargetSystemInput) (TargetSystem, error) {
code := normalizeBasemapCode(input.Code)
if code == "" {
return TargetSystem{}, errors.New("target system code is required")
}
name := strings.TrimSpace(input.Name)
if name == "" {
name = code
}
callbackURL := strings.TrimSpace(input.CallbackURL)
if callbackURL == "" {
return TargetSystem{}, errors.New("callback url is required")
}
method := strings.ToUpper(strings.TrimSpace(input.CallbackMethod))
if method == "" {
method = http.MethodPost
}
if input.CallbackHeaders == nil {
input.CallbackHeaders = map[string]string{}
}
now := nowUTC()
id := uid.Deterministic("target-system", code)
_, err := s.db.ExecContext(ctx, `
INSERT INTO target_systems (
id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
callback_url = excluded.callback_url,
callback_method = excluded.callback_method,
callback_headers_json = excluded.callback_headers_json,
enabled = 1,
updated_at = excluded.updated_at
`, id, code, name, callbackURL, method, writeJSON(input.CallbackHeaders), toRFC3339(now), toRFC3339(now))
if err != nil {
return TargetSystem{}, err
}
return s.GetTargetSystemByCode(ctx, code)
}
func (s *Store) GetTargetSystemByCode(ctx context.Context, code string) (TargetSystem, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
FROM target_systems
WHERE code = ?
`, normalizeBasemapCode(code))
item, err := scanTargetSystem(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return TargetSystem{}, fmt.Errorf("target system %q not found", code)
}
return TargetSystem{}, err
}
return item, nil
}
func (s *Store) ListTargetSystems(ctx context.Context) ([]TargetSystem, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, name, callback_url, callback_method, callback_headers_json, enabled, created_at, updated_at
FROM target_systems
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TargetSystem
for rows.Next() {
item, err := scanTargetSystem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) PushBasemapVersion(ctx context.Context, targetCode, basemapCode, version string) (PushRecord, error) {
target, err := s.GetTargetSystemByCode(ctx, targetCode)
if err != nil {
return PushRecord{}, err
}
if !target.Enabled {
return PushRecord{}, fmt.Errorf("target system %q is disabled", target.Code)
}
basemapCode = normalizeBasemapCode(basemapCode)
version = strings.TrimSpace(version)
row := s.db.QueryRowContext(ctx, `
SELECT
v.id,
v.basemap_id,
b.code,
v.version,
v.status,
v.is_default,
v.manifest_path,
v.tile_root_path,
v.url_template,
v.tile_format,
v.tile_scheme,
v.min_zoom,
v.max_zoom,
v.bbox_json,
v.attribution,
v.metadata_json,
v.created_at,
v.updated_at
FROM basemap_versions v
JOIN basemaps b ON b.id = v.basemap_id
WHERE b.code = ? AND v.version = ?
`, basemapCode, version)
versionRow, err := scanBasemapVersionRow(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PushRecord{}, fmt.Errorf("basemap version %s/%s not found", basemapCode, version)
}
return PushRecord{}, err
}
versionItem := decodeBasemapVersion(versionRow)
basemapItem, err := s.GetBasemapByCode(ctx, basemapCode)
if err != nil {
return PushRecord{}, err
}
payload := map[string]any{
"event": "basemap.published",
"service": strings.TrimSpace(s.cfg.APIBaseURL),
"basemap": basemapItem,
"version": versionItem,
"pushed_at": toRFC3339(nowUTC()),
"targetCode": target.Code,
}
body, err := json.Marshal(payload)
if err != nil {
return PushRecord{}, err
}
request, err := http.NewRequestWithContext(ctx, target.CallbackMethod, target.CallbackURL, bytes.NewReader(body))
if err != nil {
return PushRecord{}, err
}
request.Header.Set("Content-Type", "application/json")
for key, value := range target.CallbackHeaders {
request.Header.Set(key, value)
}
response, err := s.httpClient.Do(request)
record := PushRecord{
ID: uid.New(),
TargetSystemID: target.ID,
TargetSystemCode: target.Code,
BasemapVersionID: versionItem.ID,
BasemapCode: basemapItem.Code,
BasemapVersion: versionItem.Version,
Request: payload,
PushedAt: nowUTC(),
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
} else {
defer response.Body.Close()
record.ResponseBody = limitedReadAll(response.Body, 256*1024)
status := response.StatusCode
record.ResponseStatus = &status
if status >= 200 && status < 300 {
record.Status = "succeeded"
} else {
record.Status = "failed"
record.ErrorMessage = fmt.Sprintf("target returned status %d", status)
}
}
finishedAt := nowUTC()
record.FinishedAt = &finishedAt
_, insertErr := s.db.ExecContext(ctx, `
INSERT INTO push_records (
id, target_system_id, basemap_version_id, status, request_json, response_status, response_body, error_message, pushed_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, record.ID, record.TargetSystemID, record.BasemapVersionID, record.Status, writeJSON(record.Request), record.ResponseStatus, record.ResponseBody, record.ErrorMessage, toRFC3339(record.PushedAt), nullableTime(record.FinishedAt))
if insertErr != nil {
return PushRecord{}, insertErr
}
if err != nil {
return record, err
}
if record.Status != "succeeded" {
return record, errors.New(record.ErrorMessage)
}
return record, nil
}
func (s *Store) ListVectorPushRecords(ctx context.Context, limit int) ([]VectorPushRecord, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx, `
SELECT
p.id,
p.target_system_id,
t.code,
p.vector_asset_id,
v.code,
p.status,
p.request_json,
p.response_status,
p.response_body,
p.error_message,
p.pushed_at,
p.finished_at
FROM vector_push_records p
JOIN target_systems t ON t.id = p.target_system_id
JOIN vector_assets v ON v.id = p.vector_asset_id
ORDER BY p.pushed_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []VectorPushRecord
for rows.Next() {
item, err := scanVectorPushRecord(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) PushVectorAsset(ctx context.Context, targetCode, vectorCode string) (VectorPushRecord, error) {
target, err := s.GetTargetSystemByCode(ctx, targetCode)
if err != nil {
return VectorPushRecord{}, err
}
if !target.Enabled {
return VectorPushRecord{}, fmt.Errorf("target system %q is disabled", target.Code)
}
vectorCode = normalizeBasemapCode(vectorCode)
asset, err := s.GetVectorAssetByCode(ctx, vectorCode)
if err != nil {
return VectorPushRecord{}, err
}
payload := map[string]any{
"event": "vector.published",
"service": strings.TrimSpace(s.cfg.APIBaseURL),
"vector": asset,
"pushed_at": toRFC3339(nowUTC()),
"targetCode": target.Code,
}
body, err := json.Marshal(payload)
if err != nil {
return VectorPushRecord{}, err
}
request, err := http.NewRequestWithContext(ctx, target.CallbackMethod, target.CallbackURL, bytes.NewReader(body))
if err != nil {
return VectorPushRecord{}, err
}
request.Header.Set("Content-Type", "application/json")
for key, value := range target.CallbackHeaders {
request.Header.Set(key, value)
}
response, err := s.httpClient.Do(request)
record := VectorPushRecord{
ID: uid.New(),
TargetSystemID: target.ID,
TargetSystemCode: target.Code,
VectorAssetID: asset.ID,
VectorCode: asset.Code,
Request: payload,
PushedAt: nowUTC(),
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
} else {
defer response.Body.Close()
record.ResponseBody = limitedReadAll(response.Body, 256*1024)
status := response.StatusCode
record.ResponseStatus = &status
if status >= 200 && status < 300 {
record.Status = "succeeded"
} else {
record.Status = "failed"
record.ErrorMessage = fmt.Sprintf("target returned status %d", status)
}
}
finishedAt := nowUTC()
record.FinishedAt = &finishedAt
_, insertErr := s.db.ExecContext(ctx, `
INSERT INTO vector_push_records (
id, target_system_id, vector_asset_id, status, request_json, response_status, response_body, error_message, pushed_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, record.ID, record.TargetSystemID, record.VectorAssetID, record.Status, writeJSON(record.Request), record.ResponseStatus, record.ResponseBody, record.ErrorMessage, toRFC3339(record.PushedAt), nullableTime(record.FinishedAt))
if insertErr != nil {
return VectorPushRecord{}, insertErr
}
if err != nil {
return record, err
}
if record.Status != "succeeded" {
return record, errors.New(record.ErrorMessage)
}
return record, nil
}
+578
View File
@@ -0,0 +1,578 @@
package basemap
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"map-asset-gateway/api-go/internal/uid"
)
type scannedVersion struct {
BasemapCode string
Name string
Type string
Status string
Description string
Version string
Manifest string
TileRoot string
TileFormat string
TileScheme string
MinZoom int
MaxZoom int
BBox []float64
Attribution string
Metadata map[string]any
IsDefault bool
}
func (s *Store) RunScan(ctx context.Context, sourceCode string) (ScanRun, error) {
source, err := s.GetScanSourceByCode(ctx, sourceCode)
if err != nil {
return ScanRun{}, err
}
if !source.Enabled {
return ScanRun{}, fmt.Errorf("scan source %q is disabled", sourceCode)
}
startedAt := nowUTC()
runID := uid.New()
if _, err := s.db.ExecContext(ctx, `
INSERT INTO scan_runs (
id, scan_source_id, status, scanned_count, added_count, updated_count, removed_count, summary_json, started_at, finished_at
) VALUES (?, ?, 'running', 0, 0, 0, 0, '', ?, ?)
`, runID, source.ID, toRFC3339(startedAt), toRFC3339(startedAt)); err != nil {
return ScanRun{}, fmt.Errorf("create scan run: %w", err)
}
run, finalErr := s.runScan(ctx, runID, source, startedAt)
return run, finalErr
}
func (s *Store) runScan(ctx context.Context, runID string, source ScanSource, startedAt time.Time) (ScanRun, error) {
rootInfo, err := os.Stat(source.RootPath)
if err != nil {
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, fmt.Errorf("stat scan root: %w", err))
}
if !rootInfo.IsDir() {
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, fmt.Errorf("scan root %s is not a directory", source.RootPath))
}
manifestItems, coveredRoots, err := s.findManifestVersions(source)
if err != nil {
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, err)
}
fallbackItems, err := s.findDirectTileRoots(source, coveredRoots)
if err != nil {
return s.finishScanRun(ctx, runID, source, startedAt, "failed", nil, 0, 0, 0, 0, err)
}
allItems := append(manifestItems, fallbackItems...)
sort.Slice(allItems, func(i, j int) bool {
if allItems[i].BasemapCode == allItems[j].BasemapCode {
return allItems[i].Version < allItems[j].Version
}
return allItems[i].BasemapCode < allItems[j].BasemapCode
})
addedCount, updatedCount, removedCount, err := s.persistScanResult(ctx, source, allItems)
if err != nil {
return s.finishScanRun(ctx, runID, source, startedAt, "failed", allItems, len(allItems), addedCount, updatedCount, removedCount, err)
}
return s.finishScanRun(ctx, runID, source, startedAt, "succeeded", allItems, len(allItems), addedCount, updatedCount, removedCount, nil)
}
func (s *Store) finishScanRun(
ctx context.Context,
runID string,
source ScanSource,
startedAt time.Time,
status string,
items []scannedVersion,
scannedCount int,
addedCount int,
updatedCount int,
removedCount int,
runErr error,
) (ScanRun, error) {
finishedAt := nowUTC()
summary := map[string]any{
"source_code": source.Code,
"scan_root": source.RootPath,
"scanned_count": scannedCount,
"added_count": addedCount,
"updated_count": updatedCount,
"removed_count": removedCount,
}
if runErr != nil {
summary["error"] = runErr.Error()
}
if len(items) > 0 {
codes := map[string]struct{}{}
for _, item := range items {
codes[item.BasemapCode] = struct{}{}
}
summary["basemap_codes"] = sortedKeys(codes)
}
_, updateErr := s.db.ExecContext(ctx, `
UPDATE scan_runs
SET status = ?, scanned_count = ?, added_count = ?, updated_count = ?, removed_count = ?, summary_json = ?, finished_at = ?
WHERE id = ?
`, status, scannedCount, addedCount, updatedCount, removedCount, writeJSON(summary), toRFC3339(finishedAt), runID)
if updateErr != nil {
if runErr != nil {
return ScanRun{}, fmt.Errorf("%v; update scan run: %w", runErr, updateErr)
}
return ScanRun{}, updateErr
}
run := ScanRun{
ID: runID,
ScanSourceID: source.ID,
SourceCode: source.Code,
Status: status,
ScannedCount: scannedCount,
AddedCount: addedCount,
UpdatedCount: updatedCount,
RemovedCount: removedCount,
Summary: summary,
StartedAt: startedAt,
FinishedAt: finishedAt,
}
if runErr != nil {
return run, runErr
}
return run, nil
}
func (s *Store) findManifestVersions(source ScanSource) ([]scannedVersion, map[string]struct{}, error) {
manifestName := strings.TrimSpace(source.ManifestName)
if manifestName == "" {
manifestName = defaultManifestName
}
var items []scannedVersion
coveredRoots := map[string]struct{}{}
err := filepath.WalkDir(source.RootPath, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
if !strings.EqualFold(entry.Name(), manifestName) {
return nil
}
manifest, err := LoadManifest(path)
if err != nil {
return err
}
tileRoot, err := resolveTileRoot(filepath.Dir(path), manifest.RootPath)
if err != nil {
return err
}
format, minZoom, maxZoom, found, err := inspectTileRoot(tileRoot, manifest.TileFormat)
if err != nil {
return err
}
if found {
manifest.TileFormat = format
manifest.MinZoom = minZoom
manifest.MaxZoom = maxZoom
}
relPath, err := filepath.Rel(source.RootPath, tileRoot)
if err == nil {
first := strings.Split(filepath.ToSlash(relPath), "/")[0]
if first != "" && first != "." {
coveredRoots[first] = struct{}{}
}
}
items = append(items, scannedVersion{
BasemapCode: manifest.Code,
Name: defaultText(manifest.Name, manifest.Code),
Type: defaultText(manifest.Type, "xyz"),
Status: defaultText(manifest.Status, "ready"),
Description: manifest.Description,
Version: defaultText(manifest.Version, "current"),
Manifest: path,
TileRoot: tileRoot,
TileFormat: normalizeTileFormat(manifest.TileFormat),
TileScheme: defaultText(manifest.TileScheme, "xyz"),
MinZoom: manifest.MinZoom,
MaxZoom: manifest.MaxZoom,
BBox: manifest.BBox,
Attribution: manifest.Attribution,
Metadata: manifest.Metadata,
IsDefault: manifest.IsDefault,
})
return nil
})
if err != nil {
return nil, nil, fmt.Errorf("walk manifests: %w", err)
}
return items, coveredRoots, nil
}
func (s *Store) findDirectTileRoots(source ScanSource, skip map[string]struct{}) ([]scannedVersion, error) {
entries, err := os.ReadDir(source.RootPath)
if err != nil {
return nil, fmt.Errorf("read scan root: %w", err)
}
var items []scannedVersion
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if _, exists := skip[name]; exists {
continue
}
rootPath := filepath.Join(source.RootPath, name)
format, minZoom, maxZoom, found, err := inspectTileRoot(rootPath, "")
if err != nil {
return nil, err
}
if !found {
continue
}
code := normalizeBasemapCode(name)
if code == "" {
continue
}
items = append(items, scannedVersion{
BasemapCode: code,
Name: name,
Type: "xyz",
Status: "ready",
Version: "current",
TileRoot: rootPath,
TileFormat: format,
TileScheme: "xyz",
MinZoom: minZoom,
MaxZoom: maxZoom,
Metadata: map[string]any{
"discovered_from": "directory_scan",
},
IsDefault: true,
})
}
return items, nil
}
func inspectTileRoot(rootPath string, hintFormat string) (string, int, int, bool, error) {
entries, err := os.ReadDir(rootPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", 0, 0, false, nil
}
return "", 0, 0, false, fmt.Errorf("read tile root %s: %w", rootPath, err)
}
var zooms []int
format := normalizeTileFormat(hintFormat)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
zoom, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
zooms = append(zooms, zoom)
if format == "" || format == "png" {
detected, found, err := detectTileFormat(filepath.Join(rootPath, entry.Name()))
if err != nil {
return "", 0, 0, false, err
}
if found {
format = detected
}
}
}
if len(zooms) == 0 {
return "", 0, 0, false, nil
}
sort.Ints(zooms)
if format == "" {
format = "png"
}
return format, zooms[0], zooms[len(zooms)-1], true, nil
}
func detectTileFormat(zoomPath string) (string, bool, error) {
xEntries, err := os.ReadDir(zoomPath)
if err != nil {
return "", false, fmt.Errorf("read zoom path %s: %w", zoomPath, err)
}
for _, xEntry := range xEntries {
if !xEntry.IsDir() {
continue
}
tileEntries, err := os.ReadDir(filepath.Join(zoomPath, xEntry.Name()))
if err != nil {
return "", false, err
}
for _, tileEntry := range tileEntries {
if tileEntry.IsDir() {
continue
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(tileEntry.Name())), ".")
if ext == "" {
continue
}
return normalizeTileFormat(ext), true, nil
}
}
return "", false, nil
}
func resolveTileRoot(baseDir, relativePath string) (string, error) {
if strings.TrimSpace(relativePath) == "" {
relativePath = "tiles"
}
if filepath.IsAbs(relativePath) {
return filepath.Clean(relativePath), nil
}
resolved := filepath.Clean(filepath.Join(baseDir, relativePath))
return resolved, nil
}
func (s *Store) persistScanResult(ctx context.Context, source ScanSource, items []scannedVersion) (int, int, int, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, 0, err
}
defer tx.Rollback()
existing := map[string]struct{}{}
rows, err := tx.QueryContext(ctx, `
SELECT b.code, v.version
FROM basemap_versions v
JOIN basemaps b ON b.id = v.basemap_id
WHERE v.scan_source_id = ?
`, source.ID)
if err != nil {
return 0, 0, 0, err
}
for rows.Next() {
var code string
var version string
if err := rows.Scan(&code, &version); err != nil {
rows.Close()
return 0, 0, 0, err
}
existing[code+"::"+version] = struct{}{}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, 0, err
}
now := nowUTC()
found := map[string]struct{}{}
touchedBasemaps := map[string]struct{}{}
addedCount := 0
updatedCount := 0
for _, item := range items {
key := item.BasemapCode + "::" + item.Version
found[key] = struct{}{}
if _, exists := existing[key]; exists {
updatedCount++
} else {
addedCount++
}
if err := s.upsertScannedVersionTx(ctx, tx, source, item, now); err != nil {
return 0, 0, 0, err
}
touchedBasemaps[item.BasemapCode] = struct{}{}
}
removedCount := 0
for key := range existing {
if _, ok := found[key]; ok {
continue
}
parts := strings.SplitN(key, "::", 2)
if len(parts) != 2 {
continue
}
if err := s.deleteVersionBySourceTx(ctx, tx, source.ID, parts[0], parts[1]); err != nil {
return 0, 0, 0, err
}
touchedBasemaps[parts[0]] = struct{}{}
removedCount++
}
for code := range touchedBasemaps {
if err := s.normalizeBasemapDefaultsTx(ctx, tx, code, now); err != nil {
return 0, 0, 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, err
}
return addedCount, updatedCount, removedCount, nil
}
func (s *Store) upsertScannedVersionTx(ctx context.Context, tx *sql.Tx, source ScanSource, item scannedVersion, now time.Time) error {
basemapID := uid.Deterministic("basemap", item.BasemapCode)
versionID := uid.Deterministic("basemap-version", item.BasemapCode, item.Version)
_, err := tx.ExecContext(ctx, `
INSERT INTO basemaps (id, code, name, type, status, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
type = excluded.type,
status = excluded.status,
description = excluded.description,
updated_at = excluded.updated_at
`, basemapID, item.BasemapCode, item.Name, item.Type, item.Status, item.Description, toRFC3339(now), toRFC3339(now))
if err != nil {
return err
}
isDefault := 0
if item.IsDefault {
isDefault = 1
}
_, err = tx.ExecContext(ctx, `
INSERT INTO basemap_versions (
id, basemap_id, scan_source_id, version, status, is_default, manifest_path, tile_root_path, url_template,
tile_format, tile_scheme, min_zoom, max_zoom, bbox_json, attribution, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(basemap_id, version) DO UPDATE SET
scan_source_id = excluded.scan_source_id,
status = excluded.status,
is_default = CASE WHEN excluded.is_default = 1 THEN 1 ELSE basemap_versions.is_default END,
manifest_path = excluded.manifest_path,
tile_root_path = excluded.tile_root_path,
url_template = excluded.url_template,
tile_format = excluded.tile_format,
tile_scheme = excluded.tile_scheme,
min_zoom = excluded.min_zoom,
max_zoom = excluded.max_zoom,
bbox_json = excluded.bbox_json,
attribution = excluded.attribution,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
`, versionID, basemapID, source.ID, item.Version, item.Status, isDefault, item.Manifest, item.TileRoot, s.buildURLTemplate(item.BasemapCode, item.Version, item.TileFormat), item.TileFormat, item.TileScheme, item.MinZoom, item.MaxZoom, writeJSON(item.BBox), item.Attribution, writeJSON(item.Metadata), toRFC3339(now), toRFC3339(now))
if err != nil {
return err
}
if item.IsDefault {
_, err = tx.ExecContext(ctx, `
UPDATE basemap_versions
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
WHERE basemap_id = ?
`, item.Version, toRFC3339(now), basemapID)
if err != nil {
return err
}
}
return nil
}
func (s *Store) deleteVersionBySourceTx(ctx context.Context, tx *sql.Tx, sourceID, basemapCode, version string) error {
result, err := tx.ExecContext(ctx, `
DELETE FROM basemap_versions
WHERE scan_source_id = ? AND version = ? AND basemap_id = (SELECT id FROM basemaps WHERE code = ?)
`, sourceID, version, basemapCode)
if err != nil {
return err
}
if _, err := result.RowsAffected(); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
DELETE FROM basemaps
WHERE code = ? AND NOT EXISTS (
SELECT 1 FROM basemap_versions WHERE basemap_id = basemaps.id
)
`, basemapCode)
return err
}
func (s *Store) normalizeBasemapDefaultsTx(ctx context.Context, tx *sql.Tx, basemapCode string, now time.Time) error {
var basemapID string
err := tx.QueryRowContext(ctx, `SELECT id FROM basemaps WHERE code = ?`, basemapCode).Scan(&basemapID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}
rows, err := tx.QueryContext(ctx, `
SELECT version, is_default
FROM basemap_versions
WHERE basemap_id = ?
ORDER BY is_default DESC, version ASC
`, basemapID)
if err != nil {
return err
}
defer rows.Close()
type versionFlag struct {
Version string
IsDefault bool
}
var versions []versionFlag
for rows.Next() {
var item versionFlag
var isDefault int
if err := rows.Scan(&item.Version, &isDefault); err != nil {
return err
}
item.IsDefault = isDefault == 1
versions = append(versions, item)
}
if err := rows.Err(); err != nil {
return err
}
if len(versions) == 0 {
return nil
}
defaultVersion := versions[0].Version
for _, item := range versions {
if item.IsDefault {
defaultVersion = item.Version
break
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE basemap_versions
SET is_default = CASE WHEN version = ? THEN 1 ELSE 0 END, updated_at = ?
WHERE basemap_id = ?
`, defaultVersion, toRFC3339(now), basemapID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE basemaps SET updated_at = ? WHERE id = ?`, toRFC3339(now), basemapID)
return err
}
func (s *Store) buildURLTemplate(code, version, format string) string {
base := trimURL(s.cfg.TileBaseURL)
if base == "" {
base = trimURL(s.cfg.APIBaseURL)
}
format = normalizeTileFormat(format)
return fmt.Sprintf("%s/tiles/%s/%s/{z}/{x}/{y}.%s", base, code, version, format)
}
+364
View File
@@ -0,0 +1,364 @@
package basemap
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base32"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
)
const (
defaultScanSourceCode = "local-basemaps"
defaultManifestName = "manifest.json"
readPermission = "read"
defaultVectorSourceCode = "local-vectors"
)
type StoreConfig struct {
SQLitePath string
ScanRoot string
VectorScanRoot string
APIBaseURL string
TileBaseURL string
VectorBaseURL string
AutoScanOnStart bool
}
type Store struct {
db *sql.DB
cfg StoreConfig
httpClient *http.Client
}
type CreatedToken struct {
Meta ServiceToken `json:"meta"`
Token string `json:"token"`
}
type TileDescriptor struct {
FilePath string
ContentType string
Basemap Basemap
Version BasemapVersion
Token ServiceToken
RelativePath string
}
func OpenStore(cfg StoreConfig) (*Store, error) {
if strings.TrimSpace(cfg.SQLitePath) == "" {
return nil, errors.New("sqlite path is required")
}
if err := os.MkdirAll(filepath.Dir(cfg.SQLitePath), 0o755); err != nil {
return nil, fmt.Errorf("create sqlite dir: %w", err)
}
db, err := sql.Open("sqlite", cfg.SQLitePath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetConnMaxLifetime(10 * time.Minute)
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := &Store{
db: db,
cfg: cfg,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
if err := store.bootstrap(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if strings.TrimSpace(cfg.ScanRoot) != "" {
if _, err := store.EnsureScanSource(context.Background(), CreateScanSourceInput{
Code: defaultScanSourceCode,
Name: "Local Basemaps",
RootPath: cfg.ScanRoot,
ManifestName: defaultManifestName,
}); err != nil {
_ = db.Close()
return nil, err
}
}
if strings.TrimSpace(cfg.VectorScanRoot) != "" {
if err := os.MkdirAll(cfg.VectorScanRoot, 0o755); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create vector scan root: %w", err)
}
}
return store, nil
}
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
func (s *Store) bootstrap(ctx context.Context) error {
statements := []string{
`PRAGMA foreign_keys = ON;`,
`CREATE TABLE IF NOT EXISTS basemaps (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS basemap_versions (
id TEXT PRIMARY KEY,
basemap_id TEXT NOT NULL,
scan_source_id TEXT,
version TEXT NOT NULL,
status TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
manifest_path TEXT NOT NULL DEFAULT '',
tile_root_path TEXT NOT NULL,
url_template TEXT NOT NULL,
tile_format TEXT NOT NULL,
tile_scheme TEXT NOT NULL,
min_zoom INTEGER NOT NULL DEFAULT 0,
max_zoom INTEGER NOT NULL DEFAULT 0,
bbox_json TEXT NOT NULL DEFAULT '',
attribution TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(basemap_id, version),
FOREIGN KEY (basemap_id) REFERENCES basemaps(id) ON DELETE CASCADE,
FOREIGN KEY (scan_source_id) REFERENCES scan_sources(id) ON DELETE SET NULL
);`,
`CREATE TABLE IF NOT EXISTS scan_sources (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
root_path TEXT NOT NULL,
manifest_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
metadata_json TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS scan_runs (
id TEXT PRIMARY KEY,
scan_source_id TEXT NOT NULL,
status TEXT NOT NULL,
scanned_count INTEGER NOT NULL DEFAULT 0,
added_count INTEGER NOT NULL DEFAULT 0,
updated_count INTEGER NOT NULL DEFAULT 0,
removed_count INTEGER NOT NULL DEFAULT 0,
summary_json TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
FOREIGN KEY (scan_source_id) REFERENCES scan_sources(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS target_systems (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
callback_url TEXT NOT NULL,
callback_method TEXT NOT NULL,
callback_headers_json TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS service_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
status TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS token_grants (
id TEXT PRIMARY KEY,
token_id TEXT NOT NULL,
basemap_id TEXT NOT NULL,
basemap_version_id TEXT,
permission TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (token_id) REFERENCES service_tokens(id) ON DELETE CASCADE,
FOREIGN KEY (basemap_id) REFERENCES basemaps(id) ON DELETE CASCADE,
FOREIGN KEY (basemap_version_id) REFERENCES basemap_versions(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS push_records (
id TEXT PRIMARY KEY,
target_system_id TEXT NOT NULL,
basemap_version_id TEXT NOT NULL,
status TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
response_status INTEGER,
response_body TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
pushed_at TEXT NOT NULL,
finished_at TEXT,
FOREIGN KEY (target_system_id) REFERENCES target_systems(id) ON DELETE CASCADE,
FOREIGN KEY (basemap_version_id) REFERENCES basemap_versions(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS vector_assets (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
format TEXT NOT NULL,
status TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL DEFAULT '',
download_url TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS vector_token_grants (
id TEXT PRIMARY KEY,
token_id TEXT NOT NULL,
vector_asset_id TEXT NOT NULL,
permission TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (token_id) REFERENCES service_tokens(id) ON DELETE CASCADE,
FOREIGN KEY (vector_asset_id) REFERENCES vector_assets(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS vector_push_records (
id TEXT PRIMARY KEY,
target_system_id TEXT NOT NULL,
vector_asset_id TEXT NOT NULL,
status TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
response_status INTEGER,
response_body TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
pushed_at TEXT NOT NULL,
finished_at TEXT,
FOREIGN KEY (target_system_id) REFERENCES target_systems(id) ON DELETE CASCADE,
FOREIGN KEY (vector_asset_id) REFERENCES vector_assets(id) ON DELETE CASCADE
);`,
`CREATE INDEX IF NOT EXISTS idx_versions_basemap ON basemap_versions(basemap_id);`,
`CREATE INDEX IF NOT EXISTS idx_versions_source ON basemap_versions(scan_source_id);`,
`CREATE INDEX IF NOT EXISTS idx_grants_token ON token_grants(token_id);`,
`CREATE INDEX IF NOT EXISTS idx_push_target ON push_records(target_system_id);`,
`CREATE INDEX IF NOT EXISTS idx_vector_grants_token ON vector_token_grants(token_id);`,
`CREATE INDEX IF NOT EXISTS idx_vector_push_target ON vector_push_records(target_system_id);`,
}
for _, statement := range statements {
if _, err := s.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("bootstrap sqlite: %w", err)
}
}
return nil
}
func (s *Store) AutoScanEnabled() bool {
return s.cfg.AutoScanOnStart
}
func (s *Store) ScanRoot() string {
return s.cfg.ScanRoot
}
func (s *Store) VectorScanRoot() string {
return s.cfg.VectorScanRoot
}
func (s *Store) generateTokenSecret() (string, string, error) {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
secret := strings.ToLower(encoder.EncodeToString(raw[:]))
token := "bmp_" + secret
sum := sha256.Sum256([]byte(token))
return token, hex.EncodeToString(sum[:]), nil
}
func normalizeBasemapCode(value string) string {
return normalizeCode(value)
}
func nowUTC() time.Time {
return time.Now().UTC().Round(time.Second)
}
func toRFC3339(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func nullableTime(t *time.Time) any {
if t == nil || t.IsZero() {
return nil
}
return toRFC3339(*t)
}
func scanNullString(value string) sql.NullString {
if strings.TrimSpace(value) == "" {
return sql.NullString{}
}
return sql.NullString{String: value, Valid: true}
}
func readMaybeJSON[T any](value string, target *T) error {
if strings.TrimSpace(value) == "" {
return nil
}
return json.Unmarshal([]byte(value), target)
}
func writeJSON(value any) string {
if value == nil {
return ""
}
encoded, err := json.Marshal(value)
if err != nil {
return ""
}
return string(encoded)
}
func sortedKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func trimURL(value string) string {
return strings.TrimRight(strings.TrimSpace(value), "/")
}
func limitedReadAll(body io.Reader, limit int64) string {
content, err := io.ReadAll(io.LimitReader(body, limit))
if err != nil {
return ""
}
return string(content)
}
@@ -0,0 +1,344 @@
package basemap
import (
"database/sql"
"fmt"
"strings"
"time"
)
type basemapVersionRow struct {
ID string
BasemapID string
BasemapCode string
Version string
Status string
IsDefault bool
ManifestPath string
TileRootPath string
URLTemplate string
TileFormat string
TileScheme string
MinZoom int
MaxZoom int
BBoxJSON string
Attribution string
MetadataJSON string
CreatedAt string
UpdatedAt string
}
func parseTime(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return parsed
}
func scanBasemapVersionRow(scanner interface {
Scan(dest ...any) error
}) (basemapVersionRow, error) {
var row basemapVersionRow
var isDefault int
if err := scanner.Scan(
&row.ID,
&row.BasemapID,
&row.BasemapCode,
&row.Version,
&row.Status,
&isDefault,
&row.ManifestPath,
&row.TileRootPath,
&row.URLTemplate,
&row.TileFormat,
&row.TileScheme,
&row.MinZoom,
&row.MaxZoom,
&row.BBoxJSON,
&row.Attribution,
&row.MetadataJSON,
&row.CreatedAt,
&row.UpdatedAt,
); err != nil {
return basemapVersionRow{}, err
}
row.IsDefault = isDefault == 1
return row, nil
}
func decodeBasemapVersion(row basemapVersionRow) BasemapVersion {
item := BasemapVersion{
ID: row.ID,
BasemapID: row.BasemapID,
BasemapCode: row.BasemapCode,
Version: row.Version,
Status: row.Status,
IsDefault: row.IsDefault,
ManifestPath: row.ManifestPath,
TileRootPath: row.TileRootPath,
URLTemplate: row.URLTemplate,
TileFormat: row.TileFormat,
TileScheme: row.TileScheme,
MinZoom: row.MinZoom,
MaxZoom: row.MaxZoom,
Attribution: row.Attribution,
CreatedAt: parseTime(row.CreatedAt),
UpdatedAt: parseTime(row.UpdatedAt),
}
if strings.TrimSpace(row.BBoxJSON) != "" {
_ = readMaybeJSON(row.BBoxJSON, &item.BBox)
}
if strings.TrimSpace(row.MetadataJSON) != "" {
_ = readMaybeJSON(row.MetadataJSON, &item.Metadata)
}
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
return item
}
func scanBasemap(scanner interface {
Scan(dest ...any) error
}) (Basemap, error) {
var item Basemap
var createdAt string
var updatedAt string
if err := scanner.Scan(
&item.ID,
&item.Code,
&item.Name,
&item.Type,
&item.Status,
&item.Description,
&createdAt,
&updatedAt,
); err != nil {
return Basemap{}, err
}
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
return item, nil
}
func scanScanSource(scanner interface {
Scan(dest ...any) error
}) (ScanSource, error) {
var item ScanSource
var enabled int
var metadataJSON string
var createdAt string
var updatedAt string
if err := scanner.Scan(
&item.ID,
&item.Code,
&item.Name,
&item.RootPath,
&item.ManifestName,
&enabled,
&metadataJSON,
&createdAt,
&updatedAt,
); err != nil {
return ScanSource{}, err
}
item.Enabled = enabled == 1
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
if metadataJSON != "" {
_ = readMaybeJSON(metadataJSON, &item.Metadata)
}
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
return item, nil
}
func scanScanRun(scanner interface {
Scan(dest ...any) error
}) (ScanRun, error) {
var item ScanRun
var summaryJSON string
var startedAt string
var finishedAt string
if err := scanner.Scan(
&item.ID,
&item.ScanSourceID,
&item.SourceCode,
&item.Status,
&item.ScannedCount,
&item.AddedCount,
&item.UpdatedCount,
&item.RemovedCount,
&summaryJSON,
&startedAt,
&finishedAt,
); err != nil {
return ScanRun{}, err
}
item.StartedAt = parseTime(startedAt)
item.FinishedAt = parseTime(finishedAt)
if summaryJSON != "" {
_ = readMaybeJSON(summaryJSON, &item.Summary)
}
if item.Summary == nil {
item.Summary = map[string]any{}
}
return item, nil
}
func scanTargetSystem(scanner interface {
Scan(dest ...any) error
}) (TargetSystem, error) {
var item TargetSystem
var enabled int
var headersJSON string
var createdAt string
var updatedAt string
if err := scanner.Scan(
&item.ID,
&item.Code,
&item.Name,
&item.CallbackURL,
&item.CallbackMethod,
&headersJSON,
&enabled,
&createdAt,
&updatedAt,
); err != nil {
return TargetSystem{}, err
}
item.Enabled = enabled == 1
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
if headersJSON != "" {
_ = readMaybeJSON(headersJSON, &item.CallbackHeaders)
}
if item.CallbackHeaders == nil {
item.CallbackHeaders = map[string]string{}
}
return item, nil
}
func scanServiceToken(scanner interface {
Scan(dest ...any) error
}) (ServiceToken, string, error) {
var item ServiceToken
var tokenHash string
var expiresAt sql.NullString
var lastUsedAt sql.NullString
var createdAt string
if err := scanner.Scan(
&item.ID,
&item.Name,
&tokenHash,
&item.TokenPrefix,
&item.Status,
&expiresAt,
&lastUsedAt,
&createdAt,
); err != nil {
return ServiceToken{}, "", err
}
item.CreatedAt = parseTime(createdAt)
if expiresAt.Valid {
value := parseTime(expiresAt.String)
item.ExpiresAt = &value
}
if lastUsedAt.Valid {
value := parseTime(lastUsedAt.String)
item.LastUsedAt = &value
}
return item, tokenHash, nil
}
func scanTokenGrant(scanner interface {
Scan(dest ...any) error
}) (TokenGrant, error) {
var item TokenGrant
var versionID sql.NullString
var version sql.NullString
var createdAt string
if err := scanner.Scan(
&item.ID,
&item.TokenID,
&item.BasemapID,
&item.BasemapCode,
&versionID,
&version,
&item.Permission,
&createdAt,
); err != nil {
return TokenGrant{}, err
}
if versionID.Valid {
item.BasemapVersionID = &versionID.String
}
if version.Valid {
item.BasemapVersion = &version.String
}
item.CreatedAt = parseTime(createdAt)
return item, nil
}
func scanPushRecord(scanner interface {
Scan(dest ...any) error
}) (PushRecord, error) {
var item PushRecord
var requestJSON string
var responseStatus sql.NullInt64
var finishedAt sql.NullString
var pushedAt string
if err := scanner.Scan(
&item.ID,
&item.TargetSystemID,
&item.TargetSystemCode,
&item.BasemapVersionID,
&item.BasemapCode,
&item.BasemapVersion,
&item.Status,
&requestJSON,
&responseStatus,
&item.ResponseBody,
&item.ErrorMessage,
&pushedAt,
&finishedAt,
); err != nil {
return PushRecord{}, err
}
if requestJSON != "" {
_ = readMaybeJSON(requestJSON, &item.Request)
}
if item.Request == nil {
item.Request = map[string]any{}
}
if responseStatus.Valid {
value := int(responseStatus.Int64)
item.ResponseStatus = &value
}
item.PushedAt = parseTime(pushedAt)
if finishedAt.Valid {
value := parseTime(finishedAt.String)
item.FinishedAt = &value
}
return item, nil
}
func mustOneRow[T any](item T, err error) (T, error) {
if err != nil {
return item, err
}
return item, nil
}
func unexpectedRowsAffected(count int64, expected ...int64) error {
for _, value := range expected {
if count == value {
return nil
}
}
return fmt.Errorf("unexpected rows affected: %d", count)
}
@@ -0,0 +1,102 @@
package basemap
import (
"database/sql"
)
func scanVectorAsset(scanner interface {
Scan(dest ...any) error
}) (VectorAsset, error) {
var item VectorAsset
var metadataJSON string
var createdAt string
var updatedAt string
if err := scanner.Scan(
&item.ID,
&item.Code,
&item.Name,
&item.Format,
&item.Status,
&item.Description,
&item.FilePath,
&item.FileName,
&item.FileSize,
&item.Checksum,
&item.DownloadURL,
&metadataJSON,
&createdAt,
&updatedAt,
); err != nil {
return VectorAsset{}, err
}
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
if metadataJSON != "" {
_ = readMaybeJSON(metadataJSON, &item.Metadata)
}
if item.Metadata == nil {
item.Metadata = map[string]any{}
}
return item, nil
}
func scanVectorTokenGrant(scanner interface {
Scan(dest ...any) error
}) (VectorTokenGrant, error) {
var item VectorTokenGrant
var createdAt string
if err := scanner.Scan(
&item.ID,
&item.TokenID,
&item.VectorID,
&item.VectorCode,
&item.Permission,
&createdAt,
); err != nil {
return VectorTokenGrant{}, err
}
item.CreatedAt = parseTime(createdAt)
return item, nil
}
func scanVectorPushRecord(scanner interface {
Scan(dest ...any) error
}) (VectorPushRecord, error) {
var item VectorPushRecord
var requestJSON string
var responseStatus sql.NullInt64
var finishedAt sql.NullString
var pushedAt string
if err := scanner.Scan(
&item.ID,
&item.TargetSystemID,
&item.TargetSystemCode,
&item.VectorAssetID,
&item.VectorCode,
&item.Status,
&requestJSON,
&responseStatus,
&item.ResponseBody,
&item.ErrorMessage,
&pushedAt,
&finishedAt,
); err != nil {
return VectorPushRecord{}, err
}
if requestJSON != "" {
_ = readMaybeJSON(requestJSON, &item.Request)
}
if item.Request == nil {
item.Request = map[string]any{}
}
if responseStatus.Valid {
value := int(responseStatus.Int64)
item.ResponseStatus = &value
}
item.PushedAt = parseTime(pushedAt)
if finishedAt.Valid {
value := parseTime(finishedAt.String)
item.FinishedAt = &value
}
return item, nil
}
+555
View File
@@ -0,0 +1,555 @@
package basemap
import (
"archive/zip"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"map-asset-gateway/api-go/internal/uid"
)
type VectorDownloadDescriptor struct {
Asset VectorAsset
FilePath string
ContentType string
FileName string
Zipped bool
PackageFiles []string
}
func (s *Store) ListVectorAssets(ctx context.Context) ([]VectorAsset, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
FROM vector_assets
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []VectorAsset
for rows.Next() {
item, err := scanVectorAsset(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) GetVectorAssetByCode(ctx context.Context, code string) (VectorAsset, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
FROM vector_assets
WHERE code = ?
`, normalizeBasemapCode(code))
item, err := scanVectorAsset(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return VectorAsset{}, fmt.Errorf("vector asset %q not found", code)
}
return VectorAsset{}, err
}
return item, nil
}
func (s *Store) GetVectorAssetByRef(ctx context.Context, ref string) (VectorAsset, error) {
cleaned, err := cleanVectorAssetRef(ref)
if err != nil {
return VectorAsset{}, err
}
items, err := s.ListVectorAssets(ctx)
if err != nil {
return VectorAsset{}, err
}
for _, item := range items {
if vectorAssetMatchesRef(item, cleaned) {
return item, nil
}
}
return VectorAsset{}, fmt.Errorf("vector asset %q not found", ref)
}
func (s *Store) RunVectorScan(ctx context.Context) (map[string]any, error) {
root := strings.TrimSpace(s.cfg.VectorScanRoot)
if root == "" {
return nil, nil
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
items, err := scanVectorRoot(root)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT code FROM vector_assets`)
if err != nil {
return nil, err
}
existing := map[string]struct{}{}
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
rows.Close()
return nil, err
}
existing[code] = struct{}{}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
found := map[string]struct{}{}
added := 0
updated := 0
now := nowUTC()
for _, item := range items {
found[item.Code] = struct{}{}
if _, ok := existing[item.Code]; ok {
updated++
} else {
added++
}
if err := s.upsertVectorAssetTx(ctx, tx, item, now); err != nil {
return nil, err
}
}
removed := 0
for code := range existing {
if _, ok := found[code]; ok {
continue
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vector_assets WHERE code = ?`, code); err != nil {
return nil, err
}
removed++
}
if err := tx.Commit(); err != nil {
return nil, err
}
return map[string]any{
"scan_root": root,
"scanned_count": len(items),
"added_count": added,
"updated_count": updated,
"removed_count": removed,
}, nil
}
type scannedVector struct {
Code string
Name string
Format string
Status string
Description string
FilePath string
FileName string
FileSize int64
Checksum string
Metadata map[string]any
}
func scanVectorRoot(root string) ([]scannedVector, error) {
shapefiles := map[string]string{}
var items []scannedVector
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
switch ext {
case ".geojson", ".json", ".zip":
item, err := buildScannedVector(root, path, ext)
if err != nil {
return err
}
items = append(items, item)
case ".shp":
base := strings.TrimSuffix(path, filepath.Ext(path))
shapefiles[base] = path
}
return nil
})
if err != nil {
return nil, err
}
for _, base := range sortedKeysStringMap(shapefiles) {
path := shapefiles[base]
item, err := buildScannedVector(root, path, ".shp")
if err != nil {
return nil, err
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Code < items[j].Code
})
return items, nil
}
func sortedKeysStringMap(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func buildScannedVector(root, path, ext string) (scannedVector, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return scannedVector{}, err
}
info, err := os.Stat(absPath)
if err != nil {
return scannedVector{}, err
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return scannedVector{}, err
}
code := vectorCodeFromRel(rel, ext)
name := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
format := vectorFormatFromExt(ext)
fileName := filepath.Base(absPath)
fileSize := info.Size()
checksum, err := hashVector(absPath, ext)
if err != nil {
return scannedVector{}, err
}
if format == "shapefile" {
fileName = name + ".zip"
}
return scannedVector{
Code: code,
Name: name,
Format: format,
Status: "ready",
FilePath: absPath,
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
Metadata: map[string]any{
"relative_path": filepath.ToSlash(rel),
},
}, nil
}
func vectorCodeFromRel(rel, ext string) string {
rel = filepath.ToSlash(rel)
rel = strings.TrimSuffix(rel, strings.ToLower(filepath.Ext(rel)))
rel = strings.TrimSuffix(rel, filepath.Ext(rel))
rel = strings.ReplaceAll(rel, "/", "-")
code := normalizeBasemapCode(rel)
if code != "" {
return code
}
sum := sha256.Sum256([]byte(rel))
return "vector-" + hex.EncodeToString(sum[:6])
}
func vectorFormatFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".geojson", ".json":
return "geojson"
case ".shp":
return "shapefile"
case ".zip":
return "shpzip"
default:
return strings.TrimPrefix(strings.ToLower(ext), ".")
}
}
func hashVector(path, ext string) (string, error) {
sum := sha256.New()
if strings.EqualFold(ext, ".shp") {
for _, sibling := range shapefilePackageFiles(path) {
info, err := os.Stat(sibling)
if err != nil || info.IsDir() {
continue
}
if _, err := io.WriteString(sum, filepath.Base(sibling)); err != nil {
return "", err
}
if err := hashFileInto(sum, sibling); err != nil {
return "", err
}
}
return hex.EncodeToString(sum.Sum(nil)), nil
}
if err := hashFileInto(sum, path); err != nil {
return "", err
}
return hex.EncodeToString(sum.Sum(nil)), nil
}
func hashFileInto(writer io.Writer, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
}
func shapefilePackageFiles(path string) []string {
base := strings.TrimSuffix(path, filepath.Ext(path))
candidates := []string{".shp", ".dbf", ".shx", ".prj", ".cpg", ".qix", ".sbn", ".sbx"}
files := make([]string, 0, len(candidates))
for _, ext := range candidates {
file := base + ext
if _, err := os.Stat(file); err == nil {
files = append(files, file)
}
}
sort.Strings(files)
return files
}
func (s *Store) upsertVectorAssetTx(ctx context.Context, tx *sql.Tx, item scannedVector, now time.Time) error {
id := uid.Deterministic("vector-asset", item.Code)
_, err := tx.ExecContext(ctx, `
INSERT INTO vector_assets (
id, code, name, format, status, description, file_path, file_name, file_size, checksum, download_url, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
name = excluded.name,
format = excluded.format,
status = excluded.status,
description = excluded.description,
file_path = excluded.file_path,
file_name = excluded.file_name,
file_size = excluded.file_size,
checksum = excluded.checksum,
download_url = excluded.download_url,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
`, id, item.Code, item.Name, item.Format, item.Status, item.Description, item.FilePath, item.FileName, item.FileSize, item.Checksum, s.buildVectorDownloadURL(item.Code), writeJSON(item.Metadata), toRFC3339(now), toRFC3339(now))
return err
}
func (s *Store) buildVectorDownloadURL(code string) string {
base := trimURL(s.cfg.VectorBaseURL)
if base == "" {
base = trimURL(s.cfg.APIBaseURL)
}
return fmt.Sprintf("%s/vectors/%s/download", base, code)
}
func (auth TokenAuth) CanReadVector(code string) bool {
group, ok := auth.VectorPermissions[readPermission]
if !ok {
return false
}
_, ok = group[normalizeBasemapCode(code)]
return ok
}
func (s *Store) FilterVectors(auth TokenAuth, items []VectorAsset) []VectorAsset {
filtered := make([]VectorAsset, 0, len(items))
for _, item := range items {
if auth.CanReadVector(item.Code) {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *Store) ResolveVectorDownload(ctx context.Context, auth TokenAuth, code string) (VectorDownloadDescriptor, error) {
code = normalizeBasemapCode(code)
if !auth.CanReadVector(code) {
return VectorDownloadDescriptor{}, errors.New("token has no access to this vector asset")
}
item, err := s.GetVectorAssetByCode(ctx, code)
if err != nil {
return VectorDownloadDescriptor{}, err
}
return buildVectorDownloadDescriptor(item)
}
func (s *Store) ResolveVectorDownloadByRef(ctx context.Context, auth TokenAuth, ref string, allowedFormats ...string) (VectorDownloadDescriptor, error) {
item, err := s.GetVectorAssetByRef(ctx, ref)
if err != nil {
return VectorDownloadDescriptor{}, err
}
if !auth.CanReadVector(item.Code) {
return VectorDownloadDescriptor{}, errors.New("token has no access to this vector asset")
}
if !vectorFormatAllowed(item.Format, allowedFormats) {
return VectorDownloadDescriptor{}, fmt.Errorf("vector asset %q is not available in requested format", ref)
}
return buildVectorDownloadDescriptor(item)
}
func buildVectorDownloadDescriptor(item VectorAsset) (VectorDownloadDescriptor, error) {
switch item.Format {
case "geojson":
contentType := mime.TypeByExtension(filepath.Ext(item.FilePath))
if contentType == "" {
contentType = "application/geo+json"
}
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: contentType,
}, nil
case "shapefile":
files := shapefilePackageFiles(item.FilePath)
if len(files) == 0 {
return VectorDownloadDescriptor{}, errors.New("shapefile package is missing sidecar files")
}
return VectorDownloadDescriptor{
Asset: item,
FileName: item.FileName,
ContentType: "application/zip",
Zipped: true,
PackageFiles: files,
}, nil
case "shpzip":
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: "application/zip",
}, nil
default:
contentType := mime.TypeByExtension(filepath.Ext(item.FilePath))
if contentType == "" {
contentType = "application/octet-stream"
}
return VectorDownloadDescriptor{
Asset: item,
FilePath: item.FilePath,
FileName: item.FileName,
ContentType: contentType,
}, nil
}
}
func cleanVectorAssetRef(value string) (string, error) {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
value = strings.TrimPrefix(value, "/")
if value == "" {
return "", errors.New("vector asset path is required")
}
cleaned := path.Clean(value)
if cleaned == "." || cleaned == "/" || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", errors.New("invalid vector asset path")
}
return strings.TrimPrefix(cleaned, "./"), nil
}
func vectorAssetMatchesRef(item VectorAsset, ref string) bool {
for _, candidate := range vectorAssetRefs(item) {
if strings.EqualFold(candidate, ref) {
return true
}
}
return false
}
func vectorAssetRefs(item VectorAsset) []string {
values := []string{item.Code}
if relativePath := vectorRelativePath(item); relativePath != "" {
values = append(values, relativePath)
}
if item.FileName != "" {
values = append(values, strings.ReplaceAll(item.FileName, "\\", "/"))
}
return values
}
func vectorRelativePath(item VectorAsset) string {
if item.Metadata == nil {
return ""
}
value, ok := item.Metadata["relative_path"].(string)
if !ok {
return ""
}
cleaned, err := cleanVectorAssetRef(value)
if err != nil {
return ""
}
return cleaned
}
func vectorFormatAllowed(format string, allowed []string) bool {
if len(allowed) == 0 {
return true
}
for _, value := range allowed {
if strings.EqualFold(strings.TrimSpace(value), format) {
return true
}
}
return false
}
func WriteVectorZip(w http.ResponseWriter, descriptor VectorDownloadDescriptor) error {
archive := zip.NewWriter(w)
for _, path := range descriptor.PackageFiles {
file, err := os.Open(path)
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
file.Close()
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
file.Close()
return err
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
writer, err := archive.CreateHeader(header)
if err != nil {
file.Close()
return err
}
if _, err := io.Copy(writer, file); err != nil {
file.Close()
return err
}
file.Close()
}
return archive.Close()
}
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
}
+108
View File
@@ -0,0 +1,108 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
)
type Config struct {
Host string
Port int
APIBaseURL string
TilePublicBaseURL string
VectorPublicBaseURL string
SQLitePath string
BasemapScanRoot string
VectorScanRoot string
AutoScanOnStart bool
Env string
ServiceName string
ServiceVersion string
Commit string
}
func Load() (Config, error) {
if err := LoadEnvDefaults(); err != nil {
return Config{}, err
}
port, err := intEnv("API_PORT", 8910)
if err != nil {
return Config{}, fmt.Errorf("parse API_PORT: %w", err)
}
autoScanOnStart, err := boolEnv("AUTO_SCAN_ON_START", true)
if err != nil {
return Config{}, fmt.Errorf("parse AUTO_SCAN_ON_START: %w", err)
}
cfg := Config{
Host: stringEnv("API_HOST", "127.0.0.1"),
Port: port,
APIBaseURL: stringEnv("API_BASE_URL", ""),
TilePublicBaseURL: stringEnv("TILE_PUBLIC_BASE_URL", ""),
VectorPublicBaseURL: stringEnv("VECTOR_PUBLIC_BASE_URL", ""),
SQLitePath: stringEnv("SQLITE_PATH", filepath.Clean(filepath.Join("data", "map-asset-gateway.db"))),
BasemapScanRoot: stringEnv("BASEMAP_SCAN_ROOT", filepath.Clean(filepath.Join("data", "tiles"))),
VectorScanRoot: stringEnv("VECTOR_SCAN_ROOT", filepath.Clean(filepath.Join("data", "geojson"))),
AutoScanOnStart: autoScanOnStart,
Env: stringEnv("APP_ENV", "dev"),
ServiceName: stringEnv("SERVICE_NAME", "map-asset-gateway"),
ServiceVersion: stringEnv("SERVICE_VERSION", "0.2.0"),
Commit: stringEnv("SERVICE_COMMIT", "local"),
}
if cfg.APIBaseURL == "" {
cfg.APIBaseURL = deriveBaseURL(cfg.Host, cfg.Port)
}
if cfg.TilePublicBaseURL == "" {
cfg.TilePublicBaseURL = cfg.APIBaseURL
}
if cfg.VectorPublicBaseURL == "" {
cfg.VectorPublicBaseURL = cfg.APIBaseURL
}
return cfg, nil
}
func (c Config) ListenAddr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func deriveBaseURL(host string, port int) string {
if host == "" || host == "0.0.0.0" {
host = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d", host, port)
}
func stringEnv(key, fallback string) string {
value, ok := os.LookupEnv(key)
if ok && value != "" {
return value
}
return fallback
}
func intEnv(key string, fallback int) (int, error) {
value, ok := os.LookupEnv(key)
if ok && value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return parsed, nil
}
return fallback, nil
}
func boolEnv(key string, fallback bool) (bool, error) {
value, ok := os.LookupEnv(key)
if ok && value != "" {
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, err
}
return parsed, nil
}
return fallback, nil
}
@@ -0,0 +1,73 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
func LoadEnvDefaults() error {
if explicit := strings.TrimSpace(os.Getenv("APP_ENV_FILE")); explicit != "" {
if err := loadEnvFile(explicit); err != nil {
return fmt.Errorf("load APP_ENV_FILE %s: %w", explicit, err)
}
return nil
}
for _, candidate := range []string{
filepath.Join("config", "service.env"),
filepath.Join("..", "..", "config", "service.env"),
} {
if _, err := os.Stat(candidate); err == nil {
if err := loadEnvFile(candidate); err != nil {
return fmt.Errorf("load env file %s: %w", candidate, err)
}
return nil
}
}
return nil
}
func loadEnvFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNo := 0
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "export ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
}
key, value, found := strings.Cut(line, "=")
if !found {
return fmt.Errorf("line %d: missing '='", lineNo)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
return fmt.Errorf("line %d: empty key", lineNo)
}
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("line %d: set %s: %w", lineNo, key, err)
}
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}
@@ -0,0 +1,25 @@
package uid
import (
"crypto/sha1"
"fmt"
"strings"
)
// Deterministic returns a stable UUID-like identifier derived from the input.
func Deterministic(parts ...string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "::")))
b := sum[:16]
b[6] = (b[6] & 0x0f) | 0x50
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf(
"%08x-%04x-%04x-%04x-%012x",
b[0:4],
b[4:6],
b[6:8],
b[8:10],
b[10:16],
)
}
+25
View File
@@ -0,0 +1,25 @@
package uid
import (
"crypto/rand"
"fmt"
)
func New() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf(
"%08x-%04x-%04x-%04x-%012x",
b[0:4],
b[4:6],
b[6:8],
b[8:10],
b[10:16],
)
}