Initial import of map-asset-gateway
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
||||
# Local machine configuration
|
||||
config/service.env
|
||||
|
||||
# Runtime data and generated assets
|
||||
data/
|
||||
logs/
|
||||
temp/
|
||||
.tmp/
|
||||
|
||||
# Go local caches
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gotmp/
|
||||
.gopath/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Build and test artifacts
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
*.err
|
||||
*.log
|
||||
*.pid
|
||||
coverage.out
|
||||
|
||||
# OS / editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,399 @@
|
||||
# map-asset-gateway
|
||||
|
||||
`map-asset-gateway` 是一个面向现有底图瓦片和矢量文件的目录管理与分发服务。
|
||||
|
||||
它不负责生成瓦片,不负责 GIS 生产流程,也不负责旧版遥感结果处理流水线。它只做这几件事:
|
||||
|
||||
- 扫描已有的底图瓦片目录
|
||||
- 扫描已有的 GeoJSON / Shapefile 文件
|
||||
- 将元数据写入 SQLite
|
||||
- 为底图和矢量资源签发访问 token
|
||||
- 按 token 提供 catalog、瓦片访问和矢量下载
|
||||
- 将底图或矢量描述推送给下游系统
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Go
|
||||
- SQLite
|
||||
- Go 标准库 `net/http`
|
||||
|
||||
## 适用场景
|
||||
|
||||
适合已经有离线瓦片目录、GeoJSON、SHP 数据,希望补一层统一管理、授权和分发接口的场景。
|
||||
|
||||
不适合下面这些需求:
|
||||
|
||||
- 在线切片或重建瓦片
|
||||
- 栅格处理、预览图生成、图例生成
|
||||
- PostgreSQL / PostGIS 任务流水线
|
||||
- Python worker 驱动的结果管理系统
|
||||
|
||||
## 运行架构
|
||||
|
||||
服务启动时会按下面的顺序运行:
|
||||
|
||||
1. 读取 `config/service.env` 或环境变量
|
||||
2. 打开 SQLite 并自动建表
|
||||
3. 注册默认扫描源
|
||||
4. 如果 `AUTO_SCAN_ON_START=true`,自动扫描底图和矢量目录
|
||||
5. 启动 HTTP API 和管理页
|
||||
|
||||
这是一个单进程服务,没有额外 worker,没有外部数据库依赖。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├─ services/api-go/ Go 服务源码
|
||||
├─ config/ 环境变量配置
|
||||
├─ scripts/ 启动脚本
|
||||
├─ docs/ 接入文档
|
||||
├─ data/ 本地数据目录,不建议提交到 Git
|
||||
├─ logs/ 本地运行日志,不建议提交到 Git
|
||||
└─ temp/ 本地临时文件,不建议提交到 Git
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `services/api-go` 是当前项目主体。
|
||||
- 历史遗留 demo 已从仓库中移除,避免和当前服务边界混淆。
|
||||
- `data/`、`logs/`、`temp/`、`.gocache/`、`.gomodcache/`、`.gotmp/`、`.gopath/` 都属于本地运行或构建产物。
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Go 1.26 或兼容版本
|
||||
- 可写的本地目录用于放 SQLite、扫描根目录和 Go 缓存
|
||||
- Windows 环境下可直接使用仓库自带批处理脚本
|
||||
|
||||
### 配置
|
||||
|
||||
复制示例配置:
|
||||
|
||||
```text
|
||||
config/service.env.example -> config/service.env
|
||||
```
|
||||
|
||||
关键配置项:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `API_HOST` | 监听地址 |
|
||||
| `API_PORT` | 监听端口 |
|
||||
| `API_BASE_URL` | API 对外基地址 |
|
||||
| `TILE_PUBLIC_BASE_URL` | 底图下载地址前缀 |
|
||||
| `VECTOR_PUBLIC_BASE_URL` | 矢量下载地址前缀 |
|
||||
| `SQLITE_PATH` | SQLite 文件路径 |
|
||||
| `BASEMAP_SCAN_ROOT` | 底图扫描根目录 |
|
||||
| `VECTOR_SCAN_ROOT` | 矢量扫描根目录 |
|
||||
| `AUTO_SCAN_ON_START` | 启动时是否自动扫描 |
|
||||
|
||||
默认示例见 [config/service.env.example](./config/service.env.example)。
|
||||
|
||||
### 启动
|
||||
|
||||
Windows 下推荐:
|
||||
|
||||
```powershell
|
||||
.\start-all.bat
|
||||
```
|
||||
|
||||
或者直接运行 API:
|
||||
|
||||
```powershell
|
||||
.\scripts\run-api.bat
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```powershell
|
||||
.\stop-all.bat
|
||||
```
|
||||
|
||||
启动后可访问:
|
||||
|
||||
- 管理页: `http://127.0.0.1:8910/admin/`
|
||||
- 健康检查: `http://127.0.0.1:8910/health`
|
||||
- 就绪检查: `http://127.0.0.1:8910/ready`
|
||||
|
||||
## 数据组织与扫描规则
|
||||
|
||||
### 底图目录
|
||||
|
||||
服务支持两种底图目录形式。
|
||||
|
||||
### 1. 带 `manifest.json` 的目录
|
||||
|
||||
```text
|
||||
data/tiles/gaode-image/
|
||||
manifest.json
|
||||
tiles/
|
||||
0/0/0.png
|
||||
1/0/0.png
|
||||
```
|
||||
|
||||
示例 `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "gaode-image",
|
||||
"name": "Gaode Image",
|
||||
"type": "xyz",
|
||||
"version": "2026-04",
|
||||
"status": "ready",
|
||||
"tile_format": "png",
|
||||
"tile_scheme": "xyz",
|
||||
"min_zoom": 0,
|
||||
"max_zoom": 18,
|
||||
"root_path": "tiles",
|
||||
"is_default": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 直接是 `z/x/y.ext` 的裸目录
|
||||
|
||||
```text
|
||||
data/tiles/google-image/
|
||||
0/0/0.webp
|
||||
1/0/0.webp
|
||||
```
|
||||
|
||||
这类目录会自动导入为:
|
||||
|
||||
- `code = 目录名归一化后的值`
|
||||
- `version = current`
|
||||
- `type = xyz`
|
||||
|
||||
说明:
|
||||
|
||||
- `google-image`、`google_image`、`google.image` 最终都会被归一化成类似 `google-image` 的内部 code。
|
||||
- 默认版本路由 `/tiles/{code}/{z}/{x}/{y}.ext` 会解析到该底图当前默认版本。
|
||||
|
||||
### 矢量目录
|
||||
|
||||
`VECTOR_SCAN_ROOT` 会被递归扫描。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
data/geojson/admin-boundary.geojson
|
||||
data/geojson/roads.zip
|
||||
data/geojson/landuse/parcel.shp
|
||||
data/geojson/landuse/parcel.dbf
|
||||
data/geojson/landuse/parcel.shx
|
||||
```
|
||||
|
||||
扫描规则:
|
||||
|
||||
- `.geojson` 和 `.json` 会被识别为 `geojson`
|
||||
- `.zip` 会被识别为 `shpzip`
|
||||
- `.shp` 会被识别为 `shapefile`
|
||||
- `shapefile` 下载时会按同名 sidecar 文件动态打包成 zip
|
||||
|
||||
## 访问控制
|
||||
|
||||
服务使用 token 做资源访问控制。
|
||||
|
||||
一个 token 可以授权:
|
||||
|
||||
- 一个或多个底图
|
||||
- 一个或多个矢量资源
|
||||
|
||||
支持的 token 传递方式:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-Access-Token: <token>`
|
||||
- 查询参数 `token=`
|
||||
- 查询参数 `access_token=`
|
||||
|
||||
## 典型使用流程
|
||||
|
||||
### 1. 启动服务并扫描目录
|
||||
|
||||
如果开启了 `AUTO_SCAN_ON_START=true`,服务会在启动时自动扫描。
|
||||
|
||||
也可以通过管理接口手动触发:
|
||||
|
||||
- `POST /api/v1/admin/scan-sources/{code}/run`
|
||||
- `POST /api/v1/admin/vectors/rescan`
|
||||
|
||||
### 2. 创建 token
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
POST /api/v1/admin/tokens
|
||||
{
|
||||
"name": "insar-v2",
|
||||
"basemap_codes": ["google-image"],
|
||||
"vector_codes": ["全国行政区.geojson", "landuse/parcel.shp"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 访问 catalog 或资源
|
||||
|
||||
```text
|
||||
GET /api/v1/catalog/basemaps?token=bmp_xxx
|
||||
GET /api/v1/catalog/vectors?token=bmp_xxx
|
||||
GET /tiles/google-image/10/843/388.webp?token=bmp_xxx
|
||||
GET /tiles/google-image/current/10/843/388.webp?token=bmp_xxx
|
||||
GET /geojson/全国行政区.geojson?token=bmp_xxx
|
||||
GET /shp/landuse/parcel.shp?token=bmp_xxx
|
||||
GET /vectors/landuse-parcel/download?token=bmp_xxx
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `/tiles/{code}/{z}/{x}/{y}.ext` 走默认版本兼容路由
|
||||
- `/tiles/{code}/{version}/{z}/{x}/{y}.ext` 显式指定版本
|
||||
- `/geojson/{...}` 支持按原始相对路径、文件名或内部 code 解析
|
||||
- `/shp/{...}` 会把 `.shp + .dbf + .shx ...` 打成 zip 返回
|
||||
|
||||
### 4. 推送给下游系统
|
||||
|
||||
先注册目标系统:
|
||||
|
||||
- `GET /api/v1/admin/targets`
|
||||
- `POST /api/v1/admin/targets`
|
||||
|
||||
再触发推送:
|
||||
|
||||
- `POST /api/v1/admin/pushes`
|
||||
- `POST /api/v1/admin/vector-pushes`
|
||||
|
||||
推送内容是底图版本或矢量资源的描述信息,不是资源文件本体上传。
|
||||
|
||||
## API 概览
|
||||
|
||||
### 系统接口
|
||||
|
||||
- `GET /health`
|
||||
- `GET /ready`
|
||||
- `GET /api/v1/system`
|
||||
|
||||
### 管理接口
|
||||
|
||||
- `GET /admin/`
|
||||
- `GET /api/v1/admin/dashboard`
|
||||
- `GET /api/v1/admin/basemaps`
|
||||
- `GET /api/v1/admin/vectors`
|
||||
- `GET /api/v1/admin/scan-sources`
|
||||
- `POST /api/v1/admin/scan-sources`
|
||||
- `POST /api/v1/admin/scan-sources/{code}/run`
|
||||
- `POST /api/v1/admin/vectors/rescan`
|
||||
- `POST /api/v1/admin/basemaps/{code}/default-version`
|
||||
- `GET /api/v1/admin/targets`
|
||||
- `POST /api/v1/admin/targets`
|
||||
- `GET /api/v1/admin/tokens`
|
||||
- `POST /api/v1/admin/tokens`
|
||||
- `POST /api/v1/admin/tokens/{id}/disable`
|
||||
- `GET /api/v1/admin/push-records`
|
||||
- `POST /api/v1/admin/pushes`
|
||||
- `GET /api/v1/admin/vector-push-records`
|
||||
- `POST /api/v1/admin/vector-pushes`
|
||||
|
||||
### Catalog 与下载接口
|
||||
|
||||
- `GET /api/v1/catalog/basemaps`
|
||||
- `GET /api/v1/catalog/basemaps/{code}`
|
||||
- `GET /api/v1/catalog/vectors`
|
||||
- `GET /api/v1/catalog/vectors/{code}`
|
||||
- `GET /tiles/{code}/{z}/{x}/{y}.ext`
|
||||
- `GET /tiles/{code}/{version}/{z}/{x}/{y}.ext`
|
||||
- `GET /geojson/{relative/path/to/file.geojson}`
|
||||
- `GET /shp/{relative/path/to/file.shp}`
|
||||
- `GET /vectors/{code}/download`
|
||||
|
||||
## 数据库表
|
||||
|
||||
- `basemaps`
|
||||
- `basemap_versions`
|
||||
- `scan_sources`
|
||||
- `scan_runs`
|
||||
- `target_systems`
|
||||
- `service_tokens`
|
||||
- `token_grants`
|
||||
- `push_records`
|
||||
- `vector_assets`
|
||||
- `vector_token_grants`
|
||||
- `vector_push_records`
|
||||
|
||||
## 提交到 Git 前的约定
|
||||
|
||||
这个仓库里有很多目录本质上是本地运行数据或缓存,不建议提交到版本库。
|
||||
|
||||
默认不建议提交:
|
||||
|
||||
- `data/`
|
||||
- `logs/`
|
||||
- `temp/`
|
||||
- `.tmp/`
|
||||
- `.gocache/`
|
||||
- `.gomodcache/`
|
||||
- `.gotmp/`
|
||||
- `.gopath/`
|
||||
- `config/service.env`
|
||||
|
||||
原因:
|
||||
|
||||
- `data/` 里通常是真实底图、真实 GeoJSON/SHP、以及本地 SQLite 数据库
|
||||
- `logs/` 和 `temp/` 是运行期产物
|
||||
- `.gocache/`、`.gomodcache/`、`.gotmp/`、`.gopath/` 是本地 Go 构建缓存
|
||||
- `config/service.env` 往往包含机器本地路径
|
||||
|
||||
如果需要给新同事一个可运行的仓库,建议提交:
|
||||
|
||||
- 源码
|
||||
- `config/service.env.example`
|
||||
- 文档
|
||||
- 一个最小化的目录约定说明
|
||||
|
||||
而不是直接提交大体积瓦片、数据库和本地缓存。
|
||||
|
||||
## 开发与测试
|
||||
|
||||
在仓库内使用本地 Go 缓存目录跑测试:
|
||||
|
||||
```powershell
|
||||
$env:GOCACHE='C:\path\to\map-asset-gateway\.gocache'
|
||||
$env:GOMODCACHE='C:\path\to\map-asset-gateway\.gomodcache'
|
||||
$env:GOTMPDIR='C:\path\to\map-asset-gateway\.gotmp'
|
||||
$env:GOPATH='C:\path\to\map-asset-gateway\.gopath'
|
||||
$env:GOPROXY='https://goproxy.cn,direct'
|
||||
$env:GOSUMDB='off'
|
||||
go test ./...
|
||||
```
|
||||
|
||||
当前测试主要覆盖:
|
||||
|
||||
- 底图扫描
|
||||
- 矢量扫描
|
||||
- token 鉴权
|
||||
- 默认版本瓦片路由
|
||||
- GeoJSON / SHP 兼容访问路径
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [config/service.env.example](./config/service.env.example)
|
||||
- [docs/INSAR_MANAGEMENT_SYSTEM_V2.md](./docs/INSAR_MANAGEMENT_SYSTEM_V2.md)
|
||||
|
||||
## 项目边界
|
||||
|
||||
再次强调,这个项目当前的边界是:
|
||||
|
||||
- 扫描
|
||||
- 建目录
|
||||
- 发 token
|
||||
- 鉴权
|
||||
- 下载
|
||||
- 推送资源描述
|
||||
|
||||
不包括:
|
||||
|
||||
- Python worker
|
||||
- PostgreSQL
|
||||
- PostGIS
|
||||
- 在线切片
|
||||
- 栅格处理任务
|
||||
- 结果资产管理流水线
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copy this file to config/service.env and adjust the machine-specific paths.
|
||||
|
||||
APP_ENV=dev
|
||||
|
||||
SERVICE_NAME=map-asset-gateway
|
||||
SERVICE_VERSION=0.2.0
|
||||
SERVICE_COMMIT=local
|
||||
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=8910
|
||||
API_BASE_URL=http://127.0.0.1:8910
|
||||
TILE_PUBLIC_BASE_URL=http://127.0.0.1:8910
|
||||
VECTOR_PUBLIC_BASE_URL=http://127.0.0.1:8910
|
||||
|
||||
SQLITE_PATH=C:\path\to\map-asset-gateway\data\map-asset-gateway.db
|
||||
BASEMAP_SCAN_ROOT=C:\path\to\map-asset-gateway\data\tiles
|
||||
VECTOR_SCAN_ROOT=C:\path\to\map-asset-gateway\data\geojson
|
||||
AUTO_SCAN_ON_START=true
|
||||
|
||||
GOCACHE=C:\path\to\map-asset-gateway\.gocache
|
||||
GOMODCACHE=C:\path\to\map-asset-gateway\.gomodcache
|
||||
GOTMPDIR=C:\path\to\map-asset-gateway\.gotmp
|
||||
GOPATH=C:\path\to\map-asset-gateway\.gopath
|
||||
GOPROXY=https://goproxy.cn,direct
|
||||
GOSUMDB=off
|
||||
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
set "ENV_FILE=%~1"
|
||||
if "%ENV_FILE%"=="" set "ENV_FILE=%~dp0..\config\service.env"
|
||||
if not exist "%ENV_FILE%" exit /b 0
|
||||
|
||||
for /f "usebackq eol=# tokens=1,* delims==" %%A in ("%ENV_FILE%") do (
|
||||
if not "%%~A"=="" call :set_if_missing "%%~A" "%%~B"
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:set_if_missing
|
||||
call set "__current=%%%~1%%"
|
||||
if defined __current goto :clear_and_exit
|
||||
set "%~1=%~2"
|
||||
|
||||
:clear_and_exit
|
||||
set "__current="
|
||||
exit /b 0
|
||||
@@ -0,0 +1,41 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul 2>&1
|
||||
title Map Asset Gateway
|
||||
|
||||
set "ROOT=%~dp0..\\"
|
||||
call "%ROOT%scripts\load-env.bat" "%ROOT%config\service.env"
|
||||
|
||||
if "%GOCACHE%"=="" set "GOCACHE=%ROOT%.gocache"
|
||||
if "%GOMODCACHE%"=="" set "GOMODCACHE=%ROOT%.gomodcache"
|
||||
if "%GOTMPDIR%"=="" set "GOTMPDIR=%ROOT%.gotmp"
|
||||
if "%GOPATH%"=="" set "GOPATH=%ROOT%.gopath"
|
||||
if "%GOPROXY%"=="" set "GOPROXY=https://goproxy.cn,direct"
|
||||
if "%GOSUMDB%"=="" set "GOSUMDB=off"
|
||||
if "%API_HOST%"=="" set "API_HOST=127.0.0.1"
|
||||
if "%API_PORT%"=="" set "API_PORT=8910"
|
||||
if "%API_BASE_URL%"=="" set "API_BASE_URL=http://127.0.0.1:%API_PORT%"
|
||||
if "%TILE_PUBLIC_BASE_URL%"=="" set "TILE_PUBLIC_BASE_URL=%API_BASE_URL%"
|
||||
if "%VECTOR_PUBLIC_BASE_URL%"=="" set "VECTOR_PUBLIC_BASE_URL=%API_BASE_URL%"
|
||||
if "%SQLITE_PATH%"=="" set "SQLITE_PATH=%ROOT%data\map-asset-gateway.db"
|
||||
if "%BASEMAP_SCAN_ROOT%"=="" set "BASEMAP_SCAN_ROOT=%ROOT%data\tiles"
|
||||
if "%VECTOR_SCAN_ROOT%"=="" set "VECTOR_SCAN_ROOT=%ROOT%data\geojson"
|
||||
|
||||
echo ========================================
|
||||
echo Map Asset Gateway
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Env file: %ROOT%config\service.env
|
||||
echo Listen: %API_BASE_URL%
|
||||
echo Tile base: %TILE_PUBLIC_BASE_URL%
|
||||
echo Vector base: %VECTOR_PUBLIC_BASE_URL%
|
||||
echo SQLite: %SQLITE_PATH%
|
||||
echo Scan root: %BASEMAP_SCAN_ROOT%
|
||||
echo Vector root: %VECTOR_SCAN_ROOT%
|
||||
echo.
|
||||
|
||||
pushd "%ROOT%services\api-go"
|
||||
go run .\cmd\api
|
||||
popd
|
||||
|
||||
if "%NO_PAUSE%"=="" pause
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"map-asset-gateway/api-go/internal/basemap"
|
||||
"map-asset-gateway/api-go/internal/basemapapi"
|
||||
"map-asset-gateway/api-go/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
store, err := basemap.OpenStore(basemap.StoreConfig{
|
||||
SQLitePath: cfg.SQLitePath,
|
||||
ScanRoot: cfg.BasemapScanRoot,
|
||||
VectorScanRoot: cfg.VectorScanRoot,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
TileBaseURL: cfg.TilePublicBaseURL,
|
||||
VectorBaseURL: cfg.VectorPublicBaseURL,
|
||||
AutoScanOnStart: cfg.AutoScanOnStart,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("open sqlite store: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := store.Close(); err != nil {
|
||||
log.Printf("close store: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
systemInfo := basemap.SystemInfo{
|
||||
ServiceName: cfg.ServiceName,
|
||||
ServiceVersion: cfg.ServiceVersion,
|
||||
Commit: cfg.Commit,
|
||||
Env: cfg.Env,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
TileBaseURL: cfg.TilePublicBaseURL,
|
||||
VectorBaseURL: cfg.VectorPublicBaseURL,
|
||||
SQLitePath: cfg.SQLitePath,
|
||||
ScanRoot: cfg.BasemapScanRoot,
|
||||
VectorScanRoot: cfg.VectorScanRoot,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if cfg.AutoScanOnStart && strings.TrimSpace(cfg.BasemapScanRoot) != "" {
|
||||
if _, err := store.RunScan(context.Background(), "local-basemaps"); err != nil {
|
||||
log.Printf("startup scan skipped: %v", err)
|
||||
} else {
|
||||
log.Printf("startup scan completed for %s", cfg.BasemapScanRoot)
|
||||
}
|
||||
}
|
||||
if cfg.AutoScanOnStart && strings.TrimSpace(cfg.VectorScanRoot) != "" {
|
||||
if _, err := store.RunVectorScan(context.Background()); err != nil {
|
||||
log.Printf("startup vector scan skipped: %v", err)
|
||||
} else {
|
||||
log.Printf("startup vector scan completed for %s", cfg.VectorScanRoot)
|
||||
}
|
||||
}
|
||||
|
||||
server, err := basemapapi.New(store, systemInfo)
|
||||
if err != nil {
|
||||
log.Fatalf("init api server: %v", err)
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.ListenAddr(),
|
||||
Handler: server.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
cleanupPID, err := writePIDFile(strings.TrimSpace(os.Getenv("RS_API_PID_FILE")))
|
||||
if err != nil {
|
||||
log.Fatalf("write pid file: %v", err)
|
||||
}
|
||||
if cleanupPID != nil {
|
||||
defer cleanupPID()
|
||||
}
|
||||
|
||||
log.Printf("map asset gateway listening on http://%s", cfg.ListenAddr())
|
||||
log.Printf("api base url: %s", cfg.APIBaseURL)
|
||||
log.Printf("tile public base url: %s", cfg.TilePublicBaseURL)
|
||||
log.Printf("vector public base url: %s", cfg.VectorPublicBaseURL)
|
||||
log.Printf("sqlite path: %s", cfg.SQLitePath)
|
||||
log.Printf("scan root: %s", cfg.BasemapScanRoot)
|
||||
log.Printf("vector scan root: %s", cfg.VectorScanRoot)
|
||||
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen and serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePIDFile(path string) (func(), error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pidText := strconv.Itoa(os.Getpid())
|
||||
if err := os.WriteFile(path, []byte(pidText+"\n"), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(string(content)) == pidText {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
return cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
module map-asset-gateway/api-go
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
modernc.org/sqlite v1.39.1
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -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 = ©Version
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(copyItem.Versions) == 0 {
|
||||
continue
|
||||
}
|
||||
if copyItem.Default == nil {
|
||||
copyVersion := copyItem.Versions[0]
|
||||
copyItem.Default = ©Version
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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 = ©Value
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul 2>&1
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
call "%ROOT%scripts\load-env.bat" "%ROOT%config\service.env"
|
||||
|
||||
set "LAUNCHER_DIR=%ROOT%temp\launcher"
|
||||
if not exist "%LAUNCHER_DIR%" mkdir "%LAUNCHER_DIR%" >nul 2>&1
|
||||
set "RS_API_PID_FILE=%LAUNCHER_DIR%\api.pid"
|
||||
del /q "%RS_API_PID_FILE%" >nul 2>&1
|
||||
|
||||
if "%API_HOST%"=="" set "API_HOST=127.0.0.1"
|
||||
if "%API_PORT%"=="" set "API_PORT=8910"
|
||||
if "%API_BASE_URL%"=="" set "API_BASE_URL=http://127.0.0.1:%API_PORT%"
|
||||
|
||||
echo ========================================
|
||||
echo Map Asset Gateway Launcher
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Env file: %ROOT%config\service.env
|
||||
echo Starting Go service in a separate window...
|
||||
echo.
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'cmd.exe' -ArgumentList '/k','call ""%ROOT%scripts\run-api.bat""' -WorkingDirectory '%ROOT%' | Out-Null"
|
||||
|
||||
echo ========================================
|
||||
echo Management Entry
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Admin Console : %API_BASE_URL%/admin/
|
||||
echo API Health : %API_BASE_URL%/health
|
||||
echo API Ready : %API_BASE_URL%/ready
|
||||
echo Stop All : %ROOT%stop-all.bat
|
||||
echo PID Files : %LAUNCHER_DIR%
|
||||
echo.
|
||||
|
||||
if "%NO_PAUSE%"=="" pause
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul 2>&1
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
if exist "%ROOT%scripts\load-env.bat" call "%ROOT%scripts\load-env.bat" "%ROOT%config\service.env"
|
||||
set "LAUNCHER_DIR=%ROOT%temp\launcher"
|
||||
if "%API_PORT%"=="" set "API_PORT=8910"
|
||||
|
||||
echo ========================================
|
||||
echo Map Asset Gateway Stopper
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
call :stop_pid_file "Map Asset Gateway" "%LAUNCHER_DIR%\api.pid"
|
||||
if not "%STOP_RESULT%"=="stopped" call :stop_port "Map Asset Gateway" "%API_PORT%"
|
||||
call :stop_window_title "Map Asset Gateway"
|
||||
|
||||
echo.
|
||||
echo Finished.
|
||||
if "%NO_PAUSE%"=="" pause
|
||||
exit /b 0
|
||||
|
||||
:stop_pid_file
|
||||
set "STOP_RESULT=missing"
|
||||
set "SERVICE_NAME=%~1"
|
||||
set "PID_FILE=%~2"
|
||||
if not exist "%PID_FILE%" (
|
||||
echo [SKIP] %SERVICE_NAME% not running
|
||||
set "SERVICE_NAME="
|
||||
set "PID_FILE="
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
set "TARGET_PID="
|
||||
for /f "usebackq delims=" %%I in ("%PID_FILE%") do (
|
||||
set "TARGET_PID=%%I"
|
||||
goto :pid_loaded
|
||||
)
|
||||
:pid_loaded
|
||||
if not defined TARGET_PID (
|
||||
echo [SKIP] %SERVICE_NAME% not running
|
||||
del /q "%PID_FILE%" >nul 2>&1
|
||||
set "SERVICE_NAME="
|
||||
set "PID_FILE="
|
||||
set "TARGET_PID="
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
call :kill_pid "%TARGET_PID%"
|
||||
if not "%KILL_OK%"=="1" (
|
||||
echo [SKIP] %SERVICE_NAME% not running
|
||||
) else (
|
||||
echo [OK] Stopped %SERVICE_NAME% PID=%TARGET_PID%
|
||||
set "STOP_RESULT=stopped"
|
||||
)
|
||||
|
||||
del /q "%PID_FILE%" >nul 2>&1
|
||||
set "SERVICE_NAME="
|
||||
set "PID_FILE="
|
||||
set "TARGET_PID="
|
||||
exit /b 0
|
||||
|
||||
:stop_port
|
||||
set "SERVICE_NAME=%~1"
|
||||
set "TARGET_PORT=%~2"
|
||||
set "FOUND_PID="
|
||||
for /f "tokens=5" %%I in ('netstat -ano -p TCP ^| findstr /R /C:":%TARGET_PORT% .*LISTENING"') do set "FOUND_PID=%%I"
|
||||
if not defined FOUND_PID (
|
||||
set "SERVICE_NAME="
|
||||
set "TARGET_PORT="
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
call :kill_pid "%FOUND_PID%"
|
||||
if "%KILL_OK%"=="1" (
|
||||
echo [OK] Stopped %SERVICE_NAME% by port %TARGET_PORT% PID=%FOUND_PID%
|
||||
set "STOP_RESULT=stopped"
|
||||
)
|
||||
set "SERVICE_NAME="
|
||||
set "TARGET_PORT="
|
||||
set "FOUND_PID="
|
||||
exit /b 0
|
||||
|
||||
:stop_window_title
|
||||
taskkill /FI "WINDOWTITLE eq %~1*" /T /F >nul 2>&1
|
||||
exit /b 0
|
||||
|
||||
:kill_pid
|
||||
set "KILL_OK=0"
|
||||
set "KILL_TARGET_PID=%~1"
|
||||
if "%KILL_TARGET_PID%"=="" exit /b 0
|
||||
taskkill /PID %KILL_TARGET_PID% /T /F >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Stop-Process -Id %KILL_TARGET_PID% -Force -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||
if not errorlevel 1 set "KILL_OK=1"
|
||||
) else (
|
||||
set "KILL_OK=1"
|
||||
)
|
||||
set "KILL_TARGET_PID="
|
||||
exit /b 0
|
||||
Reference in New Issue
Block a user