Files
2026-09-06 17:57:58 +08:00

372 lines
14 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"time"
"unicode"
"fire-safety-ymd/internal/domain"
)
const (
statusOK = "ok"
statusNoResults = "no_results"
allTownsScopeWarning = "results_include_all_towns_in_configured_database"
townAllowlistScopeWarning = "results_limited_to_server_authorized_towns"
invalidGeometryWarning = "source_records_with_invalid_geometries_are_excluded"
// MaxPageOffset bounds pagination work and is reused by the MCP input schema.
MaxPageOffset = 10_000
)
var (
// ErrInvalidArgument indicates a caller-controlled spatial query is invalid.
ErrInvalidArgument = errors.New("invalid spatial query argument")
// ErrDataSourceUnavailable indicates a repository query could not complete safely.
ErrDataSourceUnavailable = errors.New("spatial data source unavailable")
// ErrQueryTimeout indicates the bounded repository query timed out.
ErrQueryTimeout = errors.New("spatial query timeout")
)
// SpatialRepository is the fixed read-only persistence port used by spatial use cases.
type SpatialRepository interface {
SearchPlaceCandidates(context.Context, domain.PlaceSearchQuery) (domain.Page[domain.PlaceCandidate], error)
ResolveIncidentContext(context.Context, domain.PointQuery) (domain.Page[domain.IncidentContext], error)
FindNearbyWaterSources(context.Context, domain.NearbyQuery) (domain.Page[domain.WaterSource], error)
FindCommandPostCandidates(context.Context, domain.NearbyQuery) (domain.Page[domain.CommandPostCandidate], error)
ListNearbyAccessLines(context.Context, domain.NearbyQuery) (domain.Page[domain.AccessLine], error)
GetResponsibleUnits(context.Context, domain.PointQuery) (domain.Page[domain.ResponsibleUnit], error)
FindNearbyRiskAreas(context.Context, domain.NearbyQuery) (domain.Page[domain.RiskArea], error)
}
// ResultMetadata records the provenance and bounds of a tool result.
type ResultMetadata struct {
GeneratedAt string `json:"generated_at"`
DataSources []string `json:"data_sources"`
SpatialReference string `json:"spatial_reference"`
ResultCount int `json:"result_count"`
TotalCount int64 `json:"total_count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
NextOffset *int `json:"next_offset"`
SearchRadiusMeters *float64 `json:"search_radius_meters,omitempty"`
}
// QueryResult is the common structured MCP result envelope.
type QueryResult[T any] struct {
Status string `json:"status"`
Data T `json:"data"`
Metadata ResultMetadata `json:"metadata"`
Warnings []string `json:"warnings"`
}
// SpatialService executes bounded read-only forest-fire spatial queries.
type SpatialService struct {
repository SpatialRepository
scope domain.SpatialScope
queryTimeout time.Duration
now func() time.Time
}
// NewSpatialService creates a spatial service with a trusted server-side data scope.
func NewSpatialService(repository SpatialRepository, scope domain.SpatialScope, queryTimeout time.Duration) (*SpatialService, error) {
if repository == nil {
return nil, errors.New("spatial repository is required")
}
if scope.AllTowns && len(scope.AllowedTowns) > 0 {
return nil, errors.New("all-towns scope must not include an allowlist")
}
if !scope.AllTowns && len(scope.AllowedTowns) == 0 {
return nil, errors.New("at least one trusted town is required")
}
if queryTimeout <= 0 {
return nil, errors.New("query timeout must be greater than zero")
}
return &SpatialService{
repository: repository,
scope: domain.SpatialScope{
AllTowns: scope.AllTowns,
AllowedTowns: slices.Clone(scope.AllowedTowns),
},
queryTimeout: queryTimeout,
now: time.Now,
}, nil
}
// SearchPlaceCandidates searches existing fire-safety records without treating a match as a confirmed incident point.
func (s *SpatialService) SearchPlaceCandidates(ctx context.Context, placeName string, limit, offset int) (QueryResult[[]domain.PlaceCandidate], error) {
placeName = strings.TrimSpace(placeName)
if count := len([]rune(placeName)); count < 2 || count > 100 || containsControlCharacter(placeName) {
return QueryResult[[]domain.PlaceCandidate]{}, fmt.Errorf("%w: place_name must contain 2 to 100 characters without controls", ErrInvalidArgument)
}
limit, err := validatePagination(limit, offset, 10)
if err != nil {
return QueryResult[[]domain.PlaceCandidate]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.SearchPlaceCandidates(queryCtx, domain.PlaceSearchQuery{
PlaceName: placeName,
Limit: limit,
Offset: offset,
Scope: s.scope,
})
if err != nil {
return QueryResult[[]domain.PlaceCandidate]{}, classifyRepositoryError("search place candidates", err)
}
return result(items, limit, offset, []string{
"water_source",
"storage_pool",
"fire_access_line",
"fire_check_station",
"fire_lookout",
"fire_grid",
"forest_enterprise",
"cemetery_area",
}, nil, s.now, s.scopeWarning(), []string{
"place_search_is_limited_to_existing_fire_safety_records",
"place_candidates_require_user_confirmation",
"representative_points_are_not_exact_incident_locations",
}), nil
}
// ResolveIncidentContext returns fire grids covering a WGS84 point.
func (s *SpatialService) ResolveIncidentContext(ctx context.Context, point domain.Coordinate, limit, offset int) (QueryResult[[]domain.IncidentContext], error) {
if err := validateCoordinate(point); err != nil {
return QueryResult[[]domain.IncidentContext]{}, err
}
limit, err := validatePagination(limit, offset, 20)
if err != nil {
return QueryResult[[]domain.IncidentContext]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.ResolveIncidentContext(queryCtx, domain.PointQuery{Point: point, Limit: limit, Offset: offset, Scope: s.scope})
if err != nil {
return QueryResult[[]domain.IncidentContext]{}, classifyRepositoryError("resolve incident context", err)
}
return result(items, limit, offset, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{
"grid_records_may_be_incomplete_or_stale",
}), nil
}
// FindNearbyWaterSources returns bounded water-source candidates by distance.
func (s *SpatialService) FindNearbyWaterSources(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.WaterSource], error) {
query, err := s.nearbyQuery(point, radiusMeters, 10_000, 30_000, limit, offset)
if err != nil {
return QueryResult[[]domain.WaterSource]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.FindNearbyWaterSources(queryCtx, query)
if err != nil {
return QueryResult[[]domain.WaterSource]{}, classifyRepositoryError("find nearby water sources", err)
}
return result(items, query.Limit, query.Offset, []string{
"water_source",
"storage_pool",
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
"resource_availability_not_verified",
"distance_is_spatial_proximity_not_operational_accessibility",
"source_timestamp_raw_unit_and_timezone_are_unconfirmed",
}), nil
}
// FindCommandPostCandidates returns existing nearby facilities that require field assessment.
func (s *SpatialService) FindCommandPostCandidates(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.CommandPostCandidate], error) {
query, err := s.nearbyQuery(point, radiusMeters, 10_000, 20_000, limit, offset)
if err != nil {
return QueryResult[[]domain.CommandPostCandidate]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.FindCommandPostCandidates(queryCtx, query)
if err != nil {
return QueryResult[[]domain.CommandPostCandidate]{}, classifyRepositoryError("find command post candidates", err)
}
return result(items, query.Limit, query.Offset, []string{
"fire_check_station",
"fire_lookout",
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
"candidate_only_requires_field_safety_communications_capacity_and_access_review",
"facility_availability_not_verified",
}), nil
}
// ListNearbyAccessLines returns nearby mapped fire-access lines, not calculated routes.
func (s *SpatialService) ListNearbyAccessLines(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.AccessLine], error) {
query, err := s.nearbyQuery(point, radiusMeters, 5_000, 10_000, limit, offset)
if err != nil {
return QueryResult[[]domain.AccessLine]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.ListNearbyAccessLines(queryCtx, query)
if err != nil {
return QueryResult[[]domain.AccessLine]{}, classifyRepositoryError("list nearby access lines", err)
}
return result(items, query.Limit, query.Offset, []string{"fire_access_line"}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
"candidate_lines_only_not_a_route_plan",
"passability_surface_width_slope_vehicle_limits_and_closures_are_unknown",
"source_updated_raw_has_no_confirmed_timezone",
}), nil
}
// GetResponsibleUnits returns non-personal team responsibility recorded on covering grids.
func (s *SpatialService) GetResponsibleUnits(ctx context.Context, point domain.Coordinate, limit, offset int) (QueryResult[[]domain.ResponsibleUnit], error) {
if err := validateCoordinate(point); err != nil {
return QueryResult[[]domain.ResponsibleUnit]{}, err
}
limit, err := validatePagination(limit, offset, 20)
if err != nil {
return QueryResult[[]domain.ResponsibleUnit]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.GetResponsibleUnits(queryCtx, domain.PointQuery{Point: point, Limit: limit, Offset: offset, Scope: s.scope})
if err != nil {
return QueryResult[[]domain.ResponsibleUnit]{}, classifyRepositoryError("get responsible units", err)
}
return result(items, limit, offset, []string{"fire_grid"}, nil, s.now, s.scopeWarning(), []string{
"source_does_not_store_live_team_positions",
"source_does_not_store_confirmed_assembly_sites_or_readiness",
}), nil
}
// FindNearbyRiskAreas returns nearby cemetery and forest-enterprise polygons.
func (s *SpatialService) FindNearbyRiskAreas(ctx context.Context, point domain.Coordinate, radiusMeters float64, limit, offset int) (QueryResult[[]domain.RiskArea], error) {
query, err := s.nearbyQuery(point, radiusMeters, 3_000, 10_000, limit, offset)
if err != nil {
return QueryResult[[]domain.RiskArea]{}, err
}
queryCtx, cancel := context.WithTimeout(ctx, s.queryTimeout)
defer cancel()
items, err := s.repository.FindNearbyRiskAreas(queryCtx, query)
if err != nil {
return QueryResult[[]domain.RiskArea]{}, classifyRepositoryError("find nearby risk areas", err)
}
return result(items, query.Limit, query.Offset, []string{
"cemetery_area",
"forest_enterprise",
}, &query.RadiusMeters, s.now, s.scopeWarning(), []string{
"risk_records_may_be_incomplete_or_stale",
"proximity_does_not_establish_current_hazard_severity",
}), nil
}
func (s *SpatialService) nearbyQuery(point domain.Coordinate, radiusMeters, defaultRadius, maximumRadius float64, limit, offset int) (domain.NearbyQuery, error) {
if err := validateCoordinate(point); err != nil {
return domain.NearbyQuery{}, err
}
if radiusMeters == 0 {
radiusMeters = defaultRadius
}
if radiusMeters < 100 || radiusMeters > maximumRadius || math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) {
return domain.NearbyQuery{}, fmt.Errorf("%w: radius_meters must be between 100 and %.0f", ErrInvalidArgument, maximumRadius)
}
limit, err := validatePagination(limit, offset, 10)
if err != nil {
return domain.NearbyQuery{}, err
}
return domain.NearbyQuery{
Point: point,
RadiusMeters: radiusMeters,
Limit: limit,
Offset: offset,
Scope: s.scope,
}, nil
}
func validateCoordinate(point domain.Coordinate) error {
if math.IsNaN(point.Longitude) || math.IsInf(point.Longitude, 0) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("%w: longitude must be between -180 and 180", ErrInvalidArgument)
}
if math.IsNaN(point.Latitude) || math.IsInf(point.Latitude, 0) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("%w: latitude must be between -90 and 90", ErrInvalidArgument)
}
return nil
}
func validatePagination(limit, offset, defaultLimit int) (int, error) {
if limit == 0 {
limit = defaultLimit
}
if limit < 1 || limit > 20 {
return 0, fmt.Errorf("%w: limit must be between 1 and 20", ErrInvalidArgument)
}
if offset < 0 || offset > MaxPageOffset {
return 0, fmt.Errorf("%w: offset must be between 0 and %d", ErrInvalidArgument, MaxPageOffset)
}
return limit, nil
}
func containsControlCharacter(value string) bool {
for _, character := range value {
if unicode.IsControl(character) {
return true
}
}
return false
}
func classifyRepositoryError(action string, err error) error {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return fmt.Errorf("%w: %s", ErrQueryTimeout, action)
}
return fmt.Errorf("%w: %s: %w", ErrDataSourceUnavailable, action, err)
}
func (s *SpatialService) scopeWarning() string {
if s.scope.AllTowns {
return allTownsScopeWarning
}
return townAllowlistScopeWarning
}
func result[T any](page domain.Page[T], limit, offset int, dataSources []string, radius *float64, now func() time.Time, scopeWarning string, warnings []string) QueryResult[[]T] {
if page.Items == nil {
page.Items = []T{}
}
status := statusOK
if page.TotalCount == 0 {
status = statusNoResults
}
hasMore := int64(offset+len(page.Items)) < page.TotalCount
var nextOffset *int
if hasMore {
next := offset + len(page.Items)
nextOffset = &next
}
resultWarnings := []string{scopeWarning, invalidGeometryWarning}
resultWarnings = append(resultWarnings, warnings...)
return QueryResult[[]T]{
Status: status,
Data: page.Items,
Metadata: ResultMetadata{
GeneratedAt: now().UTC().Format(time.RFC3339Nano),
DataSources: slices.Clone(dataSources),
SpatialReference: "EPSG:4326",
ResultCount: len(page.Items),
TotalCount: page.TotalCount,
Limit: limit,
Offset: offset,
HasMore: hasMore,
NextOffset: nextOffset,
SearchRadiusMeters: radius,
},
Warnings: resultWarnings,
}
}