最近在开发一个促销码系统时,我遇到了一个有趣的挑战:如何确保用户只有在特定地理范围内才能使用促销码。这让我深入研究了PostGIS和Ecto的结合使用,最终构建出一个既强大又优雅的解决方案。
这个系统需要满足几个核心需求:
传统做法是直接编写原生SQL查询,但这会牺牲Elixir生态提供的类型安全和查询组合能力。通过将PostGIS的强大地理空间功能与Ecto的灵活性相结合,我找到了一条两全其美的路径。
PostGIS是PostgreSQL的空间数据库扩展,提供了丰富的地理空间数据处理功能。而Ecto是Elixir生态中的数据库包装器和查询生成器。它们的组合带来了独特优势:
整个系统采用分层架构:
code复制应用层(Phoenix/Plug)
↓
业务逻辑层(促销码验证、地理计算)
↓
数据访问层(Ecto+PostGIS)
↓
数据库层(PostgreSQL+PostGIS)
关键设计决策:
首先需要在mix.exs中添加必要的依赖:
elixir复制defp deps do
[
{:ecto_sql, "~> 3.10"},
{:postgrex, "~> 0.17"},
{:geo, "~> 3.5"}, # Elixir地理数据处理
{:geo_postgis, "~> 3.4"} # PostGIS集成
]
end
注意:geo和geo_postgis的版本需要保持兼容。建议查看官方文档确认最新推荐版本。
为了让Ecto理解PostGIS的自定义类型,需要创建类型模块:
elixir复制# lib/my_app/postgrex_types.ex
Postgrex.Types.define(
MyApp.PostgrexTypes,
[Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(),
[]
)
然后在config/config.exs中配置仓库使用这些类型:
elixir复制config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "my_app_dev",
hostname: "localhost",
types: MyApp.PostgrexTypes # 启用PostGIS支持
创建迁移文件来启用PostGIS扩展:
elixir复制defmodule MyApp.Repo.Migrations.EnablePostgis do
use Ecto.Migration
def up do
execute "CREATE EXTENSION IF NOT EXISTS postgis"
end
def down do
execute "DROP EXTENSION IF EXISTS postgis"
end
end
运行迁移:
bash复制mix ecto.migrate
核心的促销码模型需要存储地理信息:
elixir复制defmodule MyApp.PromoCode do
use Ecto.Schema
import Ecto.Changeset
schema "promo_codes" do
field :code, :string
field :description, :string
field :is_active, :boolean, default: true
field :amount, :decimal
field :event_location, Geo.PostGIS.Geometry # 地理坐标点
field :radius_km, :decimal # 有效半径(千米)
field :expires_at, :utc_datetime
field :starts_at, :utc_datetime, default: &DateTime.utc_now/0
timestamps(type: :utc_datetime)
end
def changeset(promo_code, attrs) do
promo_code
|> cast(attrs, [:code, :description, :amount, :event_location, :radius_km, :expires_at])
|> validate_required([:code, :event_location, :radius_km])
|> validate_number(:radius_km, greater_than: 0)
|> validate_number(:amount, greater_than_or_equal_to: 0)
|> unique_constraint(:code)
end
def active(query \\ __MODULE__) do
now = DateTime.utc_now()
from p in query,
where: p.is_active == true and
p.starts_at <= ^now and
p.expires_at > ^now
end
end
对应的数据库迁移需要创建空间数据类型字段:
elixir复制defmodule MyApp.Repo.Migrations.CreatePromoCodes do
use Ecto.Migration
def up do
create table(:promo_codes) do
add :code, :string, null: false
add :description, :text
add :is_active, :boolean, default: true
add :amount, :decimal, precision: 10, scale: 2
add :event_location, :geometry, null: false # PostGIS几何类型
add :radius_km, :decimal, precision: 8, scale: 3
add :expires_at, :utc_datetime
add :starts_at, :utc_datetime, default: fragment("NOW()")
timestamps(type: :utc_datetime)
end
create unique_index(:promo_codes, [:code])
create index(:promo_codes, [:is_active, :starts_at, :expires_at])
# 空间索引对地理查询性能至关重要
create index(:promo_codes, [:event_location], using: :gist)
end
def down do
drop table(:promo_codes)
end
end
核心验证逻辑使用PostGIS的ST_DWithin函数:
elixir复制defmodule MyApp.PromoCodeValidator do
import Ecto.Query
alias MyApp.{Repo, PromoCode}
@doc """
检查坐标是否在促销码有效区域内
返回 {:ok, promo_code} 如果有效,{:error, reason} 如果无效
"""
def validate_location(%{"lat" => lat, "lng" => lng}, code)
when is_number(lat) and is_number(lng) do
# 创建PostGIS点(注意:PostGIS使用{经度, 纬度}顺序)
point = %Geo.Point{coordinates: {lng, lat}, srid: 4326}
query =
from p in PromoCode.active(),
where: p.code == ^code and
fragment("ST_DWithin(?, ?, ? * 1000)", p.event_location, ^point, p.radius_km),
select: p
case Repo.one(query) do
%PromoCode{} = promo -> {:ok, promo}
nil -> {:error, :invalid_location_or_code}
end
end
def validate_location(_, _), do: {:error, :invalid_coordinates}
end
关键点说明:
%Geo.Point结构体表示地理点,SRID 4326表示WGS84坐标系统ST_DWithin函数检查两点距离是否在指定范围内* 1000将千米转换为米(PostGIS函数默认使用米为单位)对于需要同时验证多个位置(如取货点和目的地)的场景,可以使用Elixir的并发特性:
elixir复制def validate_ride(%{"code" => code, "pickup" => pickup, "dropoff" => dropoff}) do
# 并发执行验证
pickup_task = Task.async(fn -> validate_location(pickup, code) end)
dropoff_task = Task.async(fn -> validate_location(dropoff, code) end)
case {Task.await(pickup_task), Task.await(dropoff_task)} do
{{:ok, promo}, {:ok, promo}} ->
{:ok, %{message: "两个位置都有效", promo: promo}}
{{:error, _}, {:error, _}} ->
{:error, "取货点和目的地都不在促销区域内"}
{{:error, _}, {:ok, _}} ->
{:error, "取货点不在促销区域内"}
{{:ok, _}, {:error, _}} ->
{:error, "目的地不在促销区域内"}
end
end
除了圆形区域,还可以支持多边形等复杂形状:
elixir复制# 在schema中添加多边形字段
field :coverage_area, Geo.PostGIS.Geometry
def within_polygon?(coordinates, polygon) do
point = %Geo.Point{coordinates: {coordinates["lng"], coordinates["lat"]}, srid: 4326}
from(p in PromoCode,
where: fragment("ST_Within(?, ?)", ^point, p.coverage_area)
)
|> Repo.exists?()
end
正确的索引对地理查询性能至关重要:
elixir复制# 在迁移文件中添加空间索引
create index(:promo_codes, [:event_location], using: :gist)
对于复杂查询模式,可以创建复合索引:
elixir复制# 常用查询条件的复合索引
create index(:promo_codes, [:is_active, :starts_at, :expires_at])
# 覆盖索引优化特定查询
create index(:promo_codes, [:code],
where: "is_active = true AND expires_at > NOW()")
elixir复制from(p in PromoCode, select: %{code: p.code, amount: p.amount})
elixir复制# 好 - 使用ST_DWithin可以利用索引
fragment("ST_DWithin(?, ?, ?)", p.event_location, ^point, ^radius)
# 不好 - ST_Distance需要计算所有点的距离
fragment("ST_Distance(?, ?) < ?", p.event_location, ^point, ^radius)
测试地理验证逻辑:
elixir复制defmodule MyApp.PromoCodeValidatorTest do
use MyApp.DataCase
alias MyApp.PromoCodeValidator
describe "validate_location/2" do
test "验证有效位置" do
# 在(28.7041, 77.1025)位置插入半径为5km的促销码
promo = insert(:promo_code,
event_location: %Geo.Point{coordinates: {77.1025, 28.7041}, srid: 4326},
radius_km: Decimal.new("5.0")
)
# 测试3km外的位置(应该有效)
nearby_location = %{"lat" => 28.7310, "lng" => 77.1205}
assert {:ok, %PromoCode{}} =
PromoCodeValidator.validate_location(nearby_location, promo.code)
# 测试10km外的位置(应该无效)
far_location = %{"lat" => 28.8041, "lng" => 77.2025}
assert {:error, :invalid_location_or_code} =
PromoCodeValidator.validate_location(far_location, promo.code)
end
end
end
建议监控以下指标:
坐标顺序问题:
注意:PostGIS使用(经度, 纬度)顺序,而很多API返回(纬度, 经度)
单位混淆:
索引未生效:
这种技术组合可以应用于多种位置感知场景:
在实际开发中,我总结了以下几点经验:
类型安全优先:始终使用Ecto的changeset验证输入数据
合理使用fragments:虽然fragments强大,但不要过度使用。保持业务逻辑在Elixir中
空间索引是必须的:没有索引的地理查询性能极差
考虑地球曲率:对于大范围计算,使用地理函数(如ST_Distance_Sphere)而非平面函数
测试不同地理场景:特别是靠近国际日期变更线、极地区域等特殊情况
这套方案成功支撑了我们每天数十万次的位置验证请求,平均响应时间保持在50ms以内。最大的收获是认识到现代工具链的强大 - 我们既可以利用专业数据库的空间计算能力,又能保持应用代码的优雅和可维护性。