Files
fire-safety-ymd/internal/repository/postgis.go
T
2026-09-05 15:46:37 +08:00

970 lines
31 KiB
Go

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`