初始化第一版
This commit is contained in:
commit
8a6c31c14d
83 files changed
+14302
No files matched your search
@@ -0,0 +1,969 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fire-safety-ymd/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const mcpSpatialSRID = 4326
|
||||
|
||||
// PostGISOptions configures the read-only PostgreSQL/PostGIS connection pool.
|
||||
type PostGISOptions struct {
|
||||
DSN string
|
||||
MaxConns int32
|
||||
ConnectTimeout time.Duration
|
||||
QueryTimeout time.Duration
|
||||
}
|
||||
|
||||
// TableAudit is a safe schema and geometry summary that contains no business rows.
|
||||
type TableAudit struct {
|
||||
Table string `json:"table"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
NullGeometryCount int64 `json:"null_geometry_count"`
|
||||
EmptyGeometryCount int64 `json:"empty_geometry_count"`
|
||||
InvalidCount int64 `json:"invalid_geometry_count"`
|
||||
OutOfBoundsCount int64 `json:"wgs84_out_of_bounds_count"`
|
||||
GeometryTypes []string `json:"geometry_types"`
|
||||
SRIDs []int `json:"srids"`
|
||||
HasGISTIndex bool `json:"has_gist_geometry_index"`
|
||||
}
|
||||
|
||||
// ReadinessReport contains non-sensitive PostGIS readiness information.
|
||||
type ReadinessReport struct {
|
||||
PostGISVersion string `json:"postgis_version"`
|
||||
Tables []TableAudit `json:"tables"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// PostGIS implements the fixed read-only spatial repository over pgxpool.
|
||||
type PostGIS struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type safeConnectionError struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *safeConnectionError) Error() string { return e.message }
|
||||
func (e *safeConnectionError) Unwrap() error { return e.cause }
|
||||
|
||||
// OpenPostGIS opens and verifies a read-only PostgreSQL connection pool.
|
||||
func OpenPostGIS(ctx context.Context, options PostGISOptions) (*PostGIS, error) {
|
||||
if strings.TrimSpace(options.DSN) == "" || options.MaxConns <= 0 || options.ConnectTimeout <= 0 || options.QueryTimeout <= 0 {
|
||||
return nil, errors.New("invalid PostGIS options")
|
||||
}
|
||||
poolConfig, err := pgxpool.ParseConfig(options.DSN)
|
||||
if err != nil {
|
||||
return nil, &safeConnectionError{message: "parse PostGIS configuration", cause: err}
|
||||
}
|
||||
poolConfig.MaxConns = options.MaxConns
|
||||
poolConfig.ConnConfig.ConnectTimeout = options.ConnectTimeout
|
||||
poolConfig.ConnConfig.RuntimeParams["application_name"] = "fire-safety-ymd-mcp"
|
||||
poolConfig.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
|
||||
queryTimeoutMilliseconds := options.QueryTimeout.Milliseconds()
|
||||
if queryTimeoutMilliseconds < 1 {
|
||||
queryTimeoutMilliseconds = 1
|
||||
}
|
||||
poolConfig.ConnConfig.RuntimeParams["statement_timeout"] = strconv.FormatInt(queryTimeoutMilliseconds, 10)
|
||||
poolConfig.ConnConfig.RuntimeParams["lock_timeout"] = strconv.FormatInt(queryTimeoutMilliseconds, 10)
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, &safeConnectionError{message: "create PostGIS connection pool", cause: err}
|
||||
}
|
||||
connectCtx, cancel := context.WithTimeout(ctx, options.ConnectTimeout)
|
||||
defer cancel()
|
||||
if err := pool.Ping(connectCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, &safeConnectionError{message: "connect to PostGIS", cause: err}
|
||||
}
|
||||
return &PostGIS{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases all PostgreSQL connections.
|
||||
func (p *PostGIS) Close() {
|
||||
if p != nil && p.pool != nil {
|
||||
p.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Audit inspects PostGIS and the allowlisted source tables without returning record values.
|
||||
func (p *PostGIS) Audit(ctx context.Context) (ReadinessReport, error) {
|
||||
var report ReadinessReport
|
||||
if err := p.pool.QueryRow(ctx, `SELECT postgis_version()`).Scan(&report.PostGISVersion); err != nil {
|
||||
return ReadinessReport{}, fmt.Errorf("query PostGIS version: %w", err)
|
||||
}
|
||||
|
||||
for _, spec := range spatialTableSpecs {
|
||||
audit, err := p.auditTable(ctx, spec.name)
|
||||
if err != nil {
|
||||
return ReadinessReport{}, fmt.Errorf("audit allowlisted table %s: %w", spec.name, err)
|
||||
}
|
||||
report.Tables = append(report.Tables, audit)
|
||||
report.Warnings = append(report.Warnings, tableAuditWarnings(audit)...)
|
||||
}
|
||||
if report.Warnings == nil {
|
||||
report.Warnings = []string{}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ValidateForMCP rejects unsafe spatial metadata while allowing queries to exclude invalid records.
|
||||
func (p *PostGIS) ValidateForMCP(ctx context.Context, expectedSRID int) (ReadinessReport, error) {
|
||||
report, err := p.Audit(ctx)
|
||||
if err != nil {
|
||||
return ReadinessReport{}, err
|
||||
}
|
||||
if err := validateReadiness(report, expectedSRID); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func validateReadiness(report ReadinessReport, expectedSRID int) error {
|
||||
if expectedSRID != mcpSpatialSRID {
|
||||
return fmt.Errorf("unsupported MCP spatial SRID: %d", expectedSRID)
|
||||
}
|
||||
if len(report.Tables) != len(spatialTableSpecs) {
|
||||
return fmt.Errorf("readiness report contains %d tables, want %d", len(report.Tables), len(spatialTableSpecs))
|
||||
}
|
||||
for index, audit := range report.Tables {
|
||||
spec := spatialTableSpecs[index]
|
||||
if audit.Table != spec.name {
|
||||
return fmt.Errorf("readiness report table %d is %s, want %s", index, audit.Table, spec.name)
|
||||
}
|
||||
if audit.OutOfBoundsCount > 0 {
|
||||
return fmt.Errorf("table %s contains coordinates outside WGS84 bounds", audit.Table)
|
||||
}
|
||||
for _, srid := range audit.SRIDs {
|
||||
if srid != expectedSRID {
|
||||
return fmt.Errorf("table %s contains unexpected SRID %d", audit.Table, srid)
|
||||
}
|
||||
}
|
||||
for _, geometryType := range audit.GeometryTypes {
|
||||
if !slices.Contains(spec.geometryTypes, geometryType) {
|
||||
return fmt.Errorf("table %s contains unexpected geometry type %s", audit.Table, geometryType)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableAuditWarnings(audit TableAudit) []string {
|
||||
warnings := make([]string, 0, 5)
|
||||
if audit.RowCount == 0 {
|
||||
warnings = append(warnings, audit.Table+":empty_table")
|
||||
}
|
||||
if audit.NullGeometryCount > 0 {
|
||||
warnings = append(warnings, audit.Table+":null_geometries_excluded")
|
||||
}
|
||||
if audit.EmptyGeometryCount > 0 {
|
||||
warnings = append(warnings, audit.Table+":empty_geometries_excluded")
|
||||
}
|
||||
if audit.InvalidCount > 0 {
|
||||
warnings = append(warnings, audit.Table+":invalid_geometries_excluded")
|
||||
}
|
||||
if !audit.HasGISTIndex {
|
||||
warnings = append(warnings, audit.Table+":missing_gist_geometry_index")
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func (p *PostGIS) auditTable(ctx context.Context, table string) (TableAudit, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
count(*)::bigint,
|
||||
count(*) FILTER (WHERE geom IS NULL)::bigint,
|
||||
count(*) FILTER (WHERE geom IS NOT NULL AND ST_IsEmpty(geom))::bigint,
|
||||
count(*) FILTER (WHERE geom IS NOT NULL AND NOT ST_IsValid(geom))::bigint,
|
||||
count(*) FILTER (
|
||||
WHERE geom IS NOT NULL
|
||||
AND NOT ST_IsEmpty(geom)
|
||||
AND (
|
||||
ST_XMin(Box3D(geom)) < -180 OR ST_XMax(Box3D(geom)) > 180
|
||||
OR ST_YMin(Box3D(geom)) < -90 OR ST_YMax(Box3D(geom)) > 90
|
||||
)
|
||||
)::bigint,
|
||||
COALESCE(string_agg(DISTINCT GeometryType(geom), ',' ORDER BY GeometryType(geom)) FILTER (WHERE geom IS NOT NULL), ''),
|
||||
COALESCE(string_agg(DISTINCT ST_SRID(geom)::text, ',' ORDER BY ST_SRID(geom)::text) FILTER (WHERE geom IS NOT NULL), ''),
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = $1
|
||||
AND indexdef ILIKE '%%USING gist%%'
|
||||
AND indexdef ILIKE '%%geom%%'
|
||||
)
|
||||
FROM public.%s`, quoteIdentifier(table))
|
||||
|
||||
var audit TableAudit
|
||||
var geometryTypes string
|
||||
var srids string
|
||||
audit.Table = table
|
||||
if err := p.pool.QueryRow(ctx, query, table).Scan(
|
||||
&audit.RowCount,
|
||||
&audit.NullGeometryCount,
|
||||
&audit.EmptyGeometryCount,
|
||||
&audit.InvalidCount,
|
||||
&audit.OutOfBoundsCount,
|
||||
&geometryTypes,
|
||||
&srids,
|
||||
&audit.HasGISTIndex,
|
||||
); err != nil {
|
||||
return TableAudit{}, err
|
||||
}
|
||||
audit.GeometryTypes = splitNonEmpty(geometryTypes)
|
||||
parsedSRIDs, err := parseSRIDs(srids)
|
||||
if err != nil {
|
||||
return TableAudit{}, err
|
||||
}
|
||||
audit.SRIDs = parsedSRIDs
|
||||
return audit, nil
|
||||
}
|
||||
|
||||
// SearchPlaceCandidates returns bounded name matches from the fixed fire-safety source tables.
|
||||
func (p *PostGIS) SearchPlaceCandidates(ctx context.Context, query domain.PlaceSearchQuery) ([]domain.PlaceCandidate, error) {
|
||||
rows, err := p.pool.Query(ctx, placeCandidateSearchSQL,
|
||||
query.PlaceName,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.PlaceCandidate, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var item domain.PlaceCandidate
|
||||
if err := rows.Scan(
|
||||
&item.PlaceType,
|
||||
&item.SourceRecordID,
|
||||
&item.Name,
|
||||
&item.Town,
|
||||
&item.Village,
|
||||
&item.MatchedField,
|
||||
&item.MatchedText,
|
||||
&item.MatchKind,
|
||||
&item.Location.Longitude,
|
||||
&item.Location.Latitude,
|
||||
&item.LocationKind,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveIncidentContext returns fire grids that cover the supplied point.
|
||||
func (p *PostGIS) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.IncidentContext, error) {
|
||||
rows, err := p.pool.Query(ctx, resolveIncidentContextSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.IncidentContext, 0)
|
||||
for rows.Next() {
|
||||
var item domain.IncidentContext
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// FindNearbyWaterSources returns water-source and storage-pool candidates.
|
||||
func (p *PostGIS) FindNearbyWaterSources(ctx context.Context, query domain.NearbyQuery) ([]domain.WaterSource, error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyWaterSourcesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.WaterSource, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var item domain.WaterSource
|
||||
var name, village, resourceType, reportedStatus pgtype.Text
|
||||
var capacity pgtype.Float8
|
||||
var sourceTimestamp pgtype.Int8
|
||||
if err := rows.Scan(
|
||||
&item.Category,
|
||||
&item.SourceRecordID,
|
||||
&name,
|
||||
&item.Town,
|
||||
&village,
|
||||
&item.Location.Longitude,
|
||||
&item.Location.Latitude,
|
||||
&item.DistanceMeters,
|
||||
&capacity,
|
||||
&resourceType,
|
||||
&reportedStatus,
|
||||
&sourceTimestamp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
item.CapacityCubicMeters = optionalFloat64(capacity)
|
||||
item.ResourceType = optionalText(resourceType)
|
||||
item.ReportedStatus = optionalText(reportedStatus)
|
||||
item.SourceTimestampRaw = optionalInt64(sourceTimestamp)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// FindCommandPostCandidates returns nearby check stations and lookout posts.
|
||||
func (p *PostGIS) FindCommandPostCandidates(ctx context.Context, query domain.NearbyQuery) ([]domain.CommandPostCandidate, error) {
|
||||
rows, err := p.pool.Query(ctx, commandPostCandidatesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.CommandPostCandidate, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var item domain.CommandPostCandidate
|
||||
var name, village, reportedStatus, managementUnit pgtype.Text
|
||||
if err := rows.Scan(
|
||||
&item.FacilityType,
|
||||
&item.SourceRecordID,
|
||||
&name,
|
||||
&item.Town,
|
||||
&village,
|
||||
&item.Location.Longitude,
|
||||
&item.Location.Latitude,
|
||||
&item.DistanceMeters,
|
||||
&reportedStatus,
|
||||
&managementUnit,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
item.ReportedStatus = optionalText(reportedStatus)
|
||||
item.ManagementUnit = optionalText(managementUnit)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ListNearbyAccessLines returns nearby fire access lines and closest access points.
|
||||
func (p *PostGIS) ListNearbyAccessLines(ctx context.Context, query domain.NearbyQuery) ([]domain.AccessLine, error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyAccessLinesSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.AccessLine, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var item domain.AccessLine
|
||||
var name, sourceUpdated pgtype.Text
|
||||
var length pgtype.Float8
|
||||
if err := rows.Scan(
|
||||
&item.SourceRecordID,
|
||||
&item.Town,
|
||||
&name,
|
||||
&item.DistanceMeters,
|
||||
&item.NearestPoint.Longitude,
|
||||
&item.NearestPoint.Latitude,
|
||||
&length,
|
||||
&sourceUpdated,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.LengthMeters = optionalFloat64(length)
|
||||
item.SourceUpdatedRaw = optionalText(sourceUpdated)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetResponsibleUnits returns non-personal responsibility details from covering grids.
|
||||
func (p *PostGIS) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, scope domain.SpatialScope) ([]domain.ResponsibleUnit, error) {
|
||||
rows, err := p.pool.Query(ctx, responsibleUnitsSQL, point.Longitude, point.Latitude, scope.AllTowns, scope.AllowedTowns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.ResponsibleUnit, 0)
|
||||
for rows.Next() {
|
||||
var item domain.ResponsibleUnit
|
||||
if err := rows.Scan(&item.GridID, &item.Town, &item.AreaLabel, &item.FireTeam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons.
|
||||
func (p *PostGIS) FindNearbyRiskAreas(ctx context.Context, query domain.NearbyQuery) ([]domain.RiskArea, error) {
|
||||
rows, err := p.pool.Query(ctx, nearbyRiskAreasSQL,
|
||||
query.Point.Longitude,
|
||||
query.Point.Latitude,
|
||||
query.Scope.AllTowns,
|
||||
query.Scope.AllowedTowns,
|
||||
query.RadiusMeters,
|
||||
query.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.RiskArea, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var item domain.RiskArea
|
||||
var name, village, direction pgtype.Text
|
||||
if err := rows.Scan(
|
||||
&item.RiskType,
|
||||
&item.SourceRecordID,
|
||||
&name,
|
||||
&item.Town,
|
||||
&village,
|
||||
&direction,
|
||||
&item.CoversPoint,
|
||||
&item.DistanceMeters,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Name = optionalText(name)
|
||||
item.Village = optionalText(village)
|
||||
item.Direction = optionalText(direction)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
type spatialTableSpec struct {
|
||||
name string
|
||||
geometryTypes []string
|
||||
}
|
||||
|
||||
var spatialTableSpecs = []spatialTableSpec{
|
||||
{name: "st_2_mpslfh_t_slfh_syd", geometryTypes: []string{"POINT"}},
|
||||
{name: "st_2_xianyouxushuichiguan", geometryTypes: []string{"POINT"}},
|
||||
{name: "st_2_xianyoufanghuotongdao", geometryTypes: []string{"LINESTRING", "MULTILINESTRING"}},
|
||||
{name: "st_2_fanghuojianchazhan", geometryTypes: []string{"POINT"}},
|
||||
{name: "st_2_fanghuoliaowangshao", geometryTypes: []string{"POINT"}},
|
||||
{name: "st_2_fanghuowangge", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
||||
{name: "st_2_linqugongkuangqiye", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
||||
{name: "st_2_mudifenqu_mian", geometryTypes: []string{"POLYGON", "MULTIPOLYGON"}},
|
||||
}
|
||||
|
||||
func quoteIdentifier(identifier string) string {
|
||||
return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func splitNonEmpty(value string) []string {
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
|
||||
func parseSRIDs(value string) ([]int, error) {
|
||||
parts := splitNonEmpty(value)
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
srid, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse geometry SRID: %w", err)
|
||||
}
|
||||
result = append(result, srid)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func optionalText(value pgtype.Text) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return value.String
|
||||
}
|
||||
|
||||
func optionalFloat64(value pgtype.Float8) *float64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
result := value.Float64
|
||||
return &result
|
||||
}
|
||||
|
||||
func optionalInt64(value pgtype.Int8) *int64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
result := value.Int64
|
||||
return &result
|
||||
}
|
||||
|
||||
const placeCandidateSearchSQL = `
|
||||
WITH candidates AS (
|
||||
SELECT
|
||||
'water_source'::text AS place_type,
|
||||
w.objectid::text AS source_record_id,
|
||||
NULLIF(w.name, '') AS name,
|
||||
COALESCE(w.zj, '') AS town,
|
||||
NULL::text AS village,
|
||||
ST_Force2D(w.geom) AS location,
|
||||
'recorded_point'::text AS location_kind
|
||||
FROM public.st_2_mpslfh_t_slfh_syd AS w
|
||||
WHERE ($2::boolean OR w.zj = ANY($3::text[]))
|
||||
AND w.geom IS NOT NULL
|
||||
AND ST_SRID(w.geom) = 4326
|
||||
AND GeometryType(w.geom) = 'POINT'
|
||||
AND ST_IsValid(w.geom)
|
||||
AND NOT ST_IsEmpty(w.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'storage_pool'::text,
|
||||
r.gid::text,
|
||||
NULLIF(r.community, ''),
|
||||
COALESCE(r.auth, ''),
|
||||
NULLIF(r.community, ''),
|
||||
ST_Force2D(r.geom),
|
||||
'recorded_point'::text
|
||||
FROM public.st_2_xianyouxushuichiguan AS r
|
||||
WHERE ($2::boolean OR r.auth = ANY($3::text[]))
|
||||
AND r.geom IS NOT NULL
|
||||
AND ST_SRID(r.geom) = 4326
|
||||
AND GeometryType(r.geom) = 'POINT'
|
||||
AND ST_IsValid(r.geom)
|
||||
AND NOT ST_IsEmpty(r.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'fire_access_line'::text,
|
||||
a.gid::text,
|
||||
NULLIF(a.name, ''),
|
||||
COALESCE(a.auth, ''),
|
||||
NULL::text,
|
||||
ST_StartPoint(
|
||||
CASE
|
||||
WHEN GeometryType(a.geom) = 'LINESTRING' THEN ST_Force2D(a.geom)
|
||||
ELSE ST_GeometryN(ST_Force2D(a.geom), 1)
|
||||
END
|
||||
),
|
||||
'representative_point'::text
|
||||
FROM public.st_2_xianyoufanghuotongdao AS a
|
||||
WHERE ($2::boolean OR a.auth = ANY($3::text[]))
|
||||
AND a.geom IS NOT NULL
|
||||
AND ST_SRID(a.geom) = 4326
|
||||
AND GeometryType(a.geom) IN ('LINESTRING', 'MULTILINESTRING')
|
||||
AND ST_IsValid(a.geom)
|
||||
AND NOT ST_IsEmpty(a.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'fire_check_station'::text,
|
||||
s.gid::text,
|
||||
NULLIF(s.jczmc, ''),
|
||||
COALESCE(s.zjmc, ''),
|
||||
NULLIF(s.cmc, ''),
|
||||
ST_Force2D(s.geom),
|
||||
'recorded_point'::text
|
||||
FROM public.st_2_fanghuojianchazhan AS s
|
||||
WHERE ($2::boolean OR s.zjmc = ANY($3::text[]))
|
||||
AND s.geom IS NOT NULL
|
||||
AND ST_SRID(s.geom) = 4326
|
||||
AND GeometryType(s.geom) = 'POINT'
|
||||
AND ST_IsValid(s.geom)
|
||||
AND NOT ST_IsEmpty(s.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'fire_lookout'::text,
|
||||
l.gid::text,
|
||||
NULLIF(l.lwsmc, ''),
|
||||
COALESCE(l.zjmc, ''),
|
||||
NULLIF(l.cmc, ''),
|
||||
ST_Force2D(l.geom),
|
||||
'recorded_point'::text
|
||||
FROM public.st_2_fanghuoliaowangshao AS l
|
||||
WHERE ($2::boolean OR l.zjmc = ANY($3::text[]))
|
||||
AND l.geom IS NOT NULL
|
||||
AND ST_SRID(l.geom) = 4326
|
||||
AND GeometryType(l.geom) = 'POINT'
|
||||
AND ST_IsValid(l.geom)
|
||||
AND NOT ST_IsEmpty(l.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'fire_grid'::text,
|
||||
g.gid::text,
|
||||
NULLIF(g.name, ''),
|
||||
COALESCE(g.auth, ''),
|
||||
NULL::text,
|
||||
ST_PointOnSurface(ST_Force2D(g.geom)),
|
||||
'representative_point'::text
|
||||
FROM public.st_2_fanghuowangge AS g
|
||||
WHERE ($2::boolean OR g.auth = ANY($3::text[]))
|
||||
AND g.geom IS NOT NULL
|
||||
AND ST_SRID(g.geom) = 4326
|
||||
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(g.geom)
|
||||
AND NOT ST_IsEmpty(g.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'forest_enterprise'::text,
|
||||
e.gid::text,
|
||||
NULLIF(e.lx, ''),
|
||||
COALESCE(e.auth, ''),
|
||||
NULLIF(e.c, ''),
|
||||
ST_PointOnSurface(ST_Force2D(e.geom)),
|
||||
'representative_point'::text
|
||||
FROM public.st_2_linqugongkuangqiye AS e
|
||||
WHERE ($2::boolean OR e.auth = ANY($3::text[]))
|
||||
AND e.geom IS NOT NULL
|
||||
AND ST_SRID(e.geom) = 4326
|
||||
AND GeometryType(e.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(e.geom)
|
||||
AND NOT ST_IsEmpty(e.geom)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'cemetery_area'::text,
|
||||
c.gid::text,
|
||||
NULLIF(c.name, ''),
|
||||
COALESCE(c.zj, ''),
|
||||
NULLIF(c.cmc, ''),
|
||||
ST_PointOnSurface(ST_Force2D(c.geom)),
|
||||
'representative_point'::text
|
||||
FROM public.st_2_mudifenqu_mian AS c
|
||||
WHERE ($2::boolean OR c.zj = ANY($3::text[]))
|
||||
AND c.geom IS NOT NULL
|
||||
AND ST_SRID(c.geom) = 4326
|
||||
AND GeometryType(c.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(c.geom)
|
||||
AND NOT ST_IsEmpty(c.geom)
|
||||
), matched AS (
|
||||
SELECT
|
||||
candidates.*,
|
||||
CASE
|
||||
WHEN lower(COALESCE(name, '')) = lower($1::text) THEN 'name'
|
||||
WHEN lower(COALESCE(village, '')) = lower($1::text) THEN 'village'
|
||||
WHEN lower(COALESCE(town, '')) = lower($1::text) THEN 'town'
|
||||
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) = 1 THEN 'name'
|
||||
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) = 1 THEN 'village'
|
||||
WHEN strpos(lower(COALESCE(town, '')), lower($1::text)) = 1 THEN 'town'
|
||||
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) > 0 THEN 'name'
|
||||
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) > 0 THEN 'village'
|
||||
ELSE 'town'
|
||||
END AS matched_field,
|
||||
CASE
|
||||
WHEN lower(COALESCE(name, '')) = lower($1::text) THEN 0
|
||||
WHEN lower(COALESCE(village, '')) = lower($1::text) THEN 1
|
||||
WHEN lower(COALESCE(town, '')) = lower($1::text) THEN 2
|
||||
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) = 1 THEN 3
|
||||
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) = 1 THEN 4
|
||||
WHEN strpos(lower(COALESCE(town, '')), lower($1::text)) = 1 THEN 5
|
||||
WHEN strpos(lower(COALESCE(name, '')), lower($1::text)) > 0 THEN 6
|
||||
WHEN strpos(lower(COALESCE(village, '')), lower($1::text)) > 0 THEN 7
|
||||
ELSE 8
|
||||
END AS match_rank
|
||||
FROM candidates
|
||||
WHERE strpos(lower(COALESCE(name, '')), lower($1::text)) > 0
|
||||
OR strpos(lower(COALESCE(village, '')), lower($1::text)) > 0
|
||||
OR strpos(lower(COALESCE(town, '')), lower($1::text)) > 0
|
||||
)
|
||||
SELECT
|
||||
place_type,
|
||||
source_record_id,
|
||||
LEFT(COALESCE(name, ''), 255),
|
||||
LEFT(COALESCE(town, ''), 100),
|
||||
LEFT(COALESCE(village, ''), 255),
|
||||
matched_field,
|
||||
LEFT(CASE matched_field WHEN 'name' THEN name WHEN 'village' THEN village ELSE town END, 255),
|
||||
CASE WHEN match_rank <= 2 THEN 'exact' ELSE 'partial' END,
|
||||
round(ST_X(location)::numeric, 6)::float8,
|
||||
round(ST_Y(location)::numeric, 6)::float8,
|
||||
location_kind
|
||||
FROM matched
|
||||
ORDER BY match_rank, town, village, name, place_type, source_record_id
|
||||
LIMIT $4`
|
||||
|
||||
const resolveIncidentContextSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255)
|
||||
FROM public.st_2_fanghuowangge AS g
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
AND g.geom IS NOT NULL
|
||||
AND ST_SRID(g.geom) = 4326
|
||||
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(g.geom)
|
||||
AND NOT ST_IsEmpty(g.geom)
|
||||
AND ST_Covers(g.geom, i.geom)
|
||||
ORDER BY ST_Area(g.geom::geography), g.gid
|
||||
LIMIT 20`
|
||||
|
||||
const nearbyWaterSourcesSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
), candidates AS (
|
||||
SELECT
|
||||
'water_source'::text AS category,
|
||||
w.objectid::text AS source_record_id,
|
||||
LEFT(NULLIF(w.name, ''), 255) AS name,
|
||||
LEFT(COALESCE(w.zj, ''), 100) AS town,
|
||||
NULL::text AS village,
|
||||
ST_X(w.geom) AS longitude,
|
||||
ST_Y(w.geom) AS latitude,
|
||||
ST_Distance(w.geom::geography, i.geom::geography) AS distance_meters,
|
||||
w.xsrl::float8 AS capacity_cubic_meters,
|
||||
LEFT(NULLIF(w.lx, ''), 100) AS resource_type,
|
||||
LEFT(NULLIF(w.syzt, ''), 100) AS reported_status,
|
||||
w.hc_datetime AS source_timestamp_raw
|
||||
FROM public.st_2_mpslfh_t_slfh_syd AS w
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR w.zj = ANY($4::text[]))
|
||||
AND w.geom IS NOT NULL
|
||||
AND ST_SRID(w.geom) = 4326
|
||||
AND GeometryType(w.geom) = 'POINT'
|
||||
AND ST_IsValid(w.geom)
|
||||
AND NOT ST_IsEmpty(w.geom)
|
||||
AND w.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(w.geom::geography, i.geom::geography, $5)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'storage_pool'::text,
|
||||
r.gid::text,
|
||||
LEFT(NULLIF(r.community, ''), 255),
|
||||
LEFT(COALESCE(r.auth, ''), 100),
|
||||
LEFT(NULLIF(r.community, ''), 255),
|
||||
ST_X(r.geom),
|
||||
ST_Y(r.geom),
|
||||
ST_Distance(r.geom::geography, i.geom::geography),
|
||||
r.lfs::float8,
|
||||
LEFT(NULLIF(r.lx, ''), 100),
|
||||
NULL::text,
|
||||
NULL::bigint
|
||||
FROM public.st_2_xianyouxushuichiguan AS r
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR r.auth = ANY($4::text[]))
|
||||
AND r.geom IS NOT NULL
|
||||
AND ST_SRID(r.geom) = 4326
|
||||
AND GeometryType(r.geom) = 'POINT'
|
||||
AND ST_IsValid(r.geom)
|
||||
AND NOT ST_IsEmpty(r.geom)
|
||||
AND r.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(r.geom::geography, i.geom::geography, $5)
|
||||
)
|
||||
SELECT category, source_record_id, name, town, village,
|
||||
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
||||
round(distance_meters::numeric, 1)::float8, capacity_cubic_meters, resource_type, reported_status,
|
||||
source_timestamp_raw
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, category, source_record_id
|
||||
LIMIT $6`
|
||||
|
||||
const commandPostCandidatesSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
), candidates AS (
|
||||
SELECT
|
||||
'fire_check_station'::text AS facility_type,
|
||||
s.gid::text AS source_record_id,
|
||||
LEFT(NULLIF(s.jczmc, ''), 255) AS name,
|
||||
LEFT(COALESCE(s.zjmc, ''), 100) AS town,
|
||||
LEFT(NULLIF(s.cmc, ''), 255) AS village,
|
||||
ST_X(s.geom) AS longitude,
|
||||
ST_Y(s.geom) AS latitude,
|
||||
ST_Distance(s.geom::geography, i.geom::geography) AS distance_meters,
|
||||
LEFT(NULLIF(s.syzt, ''), 100) AS reported_status,
|
||||
LEFT(NULLIF(s.gldw, ''), 255) AS management_unit
|
||||
FROM public.st_2_fanghuojianchazhan AS s
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR s.zjmc = ANY($4::text[]))
|
||||
AND s.geom IS NOT NULL
|
||||
AND ST_SRID(s.geom) = 4326
|
||||
AND GeometryType(s.geom) = 'POINT'
|
||||
AND ST_IsValid(s.geom)
|
||||
AND NOT ST_IsEmpty(s.geom)
|
||||
AND s.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(s.geom::geography, i.geom::geography, $5)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'fire_lookout'::text,
|
||||
l.gid::text,
|
||||
LEFT(NULLIF(l.lwsmc, ''), 255),
|
||||
LEFT(COALESCE(l.zjmc, ''), 100),
|
||||
LEFT(NULLIF(l.cmc, ''), 255),
|
||||
ST_X(l.geom),
|
||||
ST_Y(l.geom),
|
||||
ST_Distance(l.geom::geography, i.geom::geography),
|
||||
LEFT(NULLIF(l.syzt, ''), 100),
|
||||
NULL::text
|
||||
FROM public.st_2_fanghuoliaowangshao AS l
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR l.zjmc = ANY($4::text[]))
|
||||
AND l.geom IS NOT NULL
|
||||
AND ST_SRID(l.geom) = 4326
|
||||
AND GeometryType(l.geom) = 'POINT'
|
||||
AND ST_IsValid(l.geom)
|
||||
AND NOT ST_IsEmpty(l.geom)
|
||||
AND l.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(l.geom::geography, i.geom::geography, $5)
|
||||
)
|
||||
SELECT facility_type, source_record_id, name, town, village,
|
||||
round(longitude::numeric, 6)::float8, round(latitude::numeric, 6)::float8,
|
||||
round(distance_meters::numeric, 1)::float8, reported_status, management_unit
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, facility_type, source_record_id
|
||||
LIMIT $6`
|
||||
|
||||
const nearbyAccessLinesSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
), candidates AS (
|
||||
SELECT
|
||||
a.gid::text AS source_record_id,
|
||||
LEFT(COALESCE(a.auth, ''), 100) AS town,
|
||||
LEFT(NULLIF(a.name, ''), 255) AS name,
|
||||
ST_Distance(a.geom::geography, i.geom::geography) AS distance_meters,
|
||||
ST_ClosestPoint(a.geom, i.geom) AS nearest_point,
|
||||
ST_Length(a.geom::geography) AS length_meters,
|
||||
a.sttime::text AS source_updated_raw
|
||||
FROM public.st_2_xianyoufanghuotongdao AS a
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR a.auth = ANY($4::text[]))
|
||||
AND a.geom IS NOT NULL
|
||||
AND ST_SRID(a.geom) = 4326
|
||||
AND GeometryType(a.geom) IN ('LINESTRING', 'MULTILINESTRING')
|
||||
AND ST_IsValid(a.geom)
|
||||
AND NOT ST_IsEmpty(a.geom)
|
||||
AND a.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(a.geom::geography, i.geom::geography, $5)
|
||||
)
|
||||
SELECT source_record_id, town, name, round(distance_meters::numeric, 1)::float8,
|
||||
round(ST_X(nearest_point)::numeric, 6)::float8,
|
||||
round(ST_Y(nearest_point)::numeric, 6)::float8,
|
||||
length_meters, source_updated_raw
|
||||
FROM candidates
|
||||
ORDER BY distance_meters, source_record_id
|
||||
LIMIT $6`
|
||||
|
||||
const responsibleUnitsSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
)
|
||||
SELECT g.gid::text, LEFT(COALESCE(g.auth, ''), 100), LEFT(COALESCE(g.name, ''), 255), LEFT(COALESCE(g.fhzd, ''), 255)
|
||||
FROM public.st_2_fanghuowangge AS g
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR g.auth = ANY($4::text[]))
|
||||
AND g.geom IS NOT NULL
|
||||
AND ST_SRID(g.geom) = 4326
|
||||
AND GeometryType(g.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(g.geom)
|
||||
AND NOT ST_IsEmpty(g.geom)
|
||||
AND ST_Covers(g.geom, i.geom)
|
||||
ORDER BY ST_Area(g.geom::geography), g.gid
|
||||
LIMIT 20`
|
||||
|
||||
const nearbyRiskAreasSQL = `
|
||||
WITH incident AS (
|
||||
SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS geom
|
||||
), candidates AS (
|
||||
SELECT
|
||||
'cemetery_area'::text AS risk_type,
|
||||
c.gid::text AS source_record_id,
|
||||
LEFT(NULLIF(c.name, ''), 255) AS name,
|
||||
LEFT(COALESCE(c.zj, ''), 100) AS town,
|
||||
LEFT(NULLIF(c.cmc, ''), 255) AS village,
|
||||
LEFT(NULLIF(c.fw, ''), 255) AS direction,
|
||||
ST_Covers(c.geom, i.geom) AS covers_point,
|
||||
ST_Distance(c.geom::geography, i.geom::geography) AS distance_meters
|
||||
FROM public.st_2_mudifenqu_mian AS c
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR c.zj = ANY($4::text[]))
|
||||
AND c.geom IS NOT NULL
|
||||
AND ST_SRID(c.geom) = 4326
|
||||
AND GeometryType(c.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(c.geom)
|
||||
AND NOT ST_IsEmpty(c.geom)
|
||||
AND c.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(c.geom::geography, i.geom::geography, $5)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'forest_enterprise'::text,
|
||||
e.gid::text,
|
||||
LEFT(NULLIF(e.lx, ''), 255),
|
||||
LEFT(COALESCE(e.auth, ''), 100),
|
||||
LEFT(NULLIF(e.c, ''), 255),
|
||||
LEFT(NULLIF(e.fw, ''), 255),
|
||||
ST_Covers(e.geom, i.geom),
|
||||
ST_Distance(e.geom::geography, i.geom::geography)
|
||||
FROM public.st_2_linqugongkuangqiye AS e
|
||||
CROSS JOIN incident AS i
|
||||
WHERE ($3::boolean OR e.auth = ANY($4::text[]))
|
||||
AND e.geom IS NOT NULL
|
||||
AND ST_SRID(e.geom) = 4326
|
||||
AND GeometryType(e.geom) IN ('POLYGON', 'MULTIPOLYGON')
|
||||
AND ST_IsValid(e.geom)
|
||||
AND NOT ST_IsEmpty(e.geom)
|
||||
AND e.geom && ST_Expand(i.geom, $5 / GREATEST(1000.0, 111320.0 * abs(cos(radians($2)))))
|
||||
AND ST_DWithin(e.geom::geography, i.geom::geography, $5)
|
||||
)
|
||||
SELECT risk_type, source_record_id, name, town, village, direction, covers_point,
|
||||
round(distance_meters::numeric, 1)::float8
|
||||
FROM candidates
|
||||
ORDER BY covers_point DESC, distance_meters, risk_type, source_record_id
|
||||
LIMIT $6`
|
||||
Reference in new issue
Block a user