feat: 新增告警

This commit is contained in:
yarnom 2025-12-05 09:22:23 +08:00
parent 2c2d5232f8
commit 6d276b1760
9 changed files with 761 additions and 1 deletions

View File

@ -0,0 +1,551 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"math"
"sort"
"strings"
"time"
"weatherstation/core/internal/config"
"weatherstation/core/internal/data"
"weatherstation/core/internal/sms"
)
const (
providerForecast = "imdroid_mix"
alertTypeForecast = "forecast_3h_rain"
alertTypeActual30m = "actual_30m_rain"
alertTypeNeighbor30 = "actual_30m_neighbor"
levelRed = "red"
levelYellow = "yellow"
forecastRedMM = 8.0
forecastYellowMM = 4.0
actualRedMM = 4.0
actualYellowMM = 2.0
halfAngleDeg = 90.0
timeFormatShort = "2006-01-02 15:04"
defaultCheckTimeout = 20 * time.Second
)
var (
flagOnce bool
flagTest bool
flagTestStIDs string
flagTestTime string
flagWhy bool
)
func main() {
flag.BoolVar(&flagOnce, "once", false, "run checks once immediately (no scheduling)")
flag.BoolVar(&flagTest, "test", false, "force alerts regardless of thresholds (for dry-run)")
flag.StringVar(&flagTestStIDs, "station", "", "comma-separated station_id list for test mode")
flag.StringVar(&flagTestTime, "time", "", "test mode: specify end time (YYYY-MM-DD HH:MM:SS, CST)")
flag.BoolVar(&flagWhy, "why", false, "in test mode, log reasons when alert not triggered")
flag.Parse()
cfg := config.Load()
scli := mustInitSMS(cfg)
if flagOnce {
tick := time.Now()
runForecastCheck(scli, tick)
runActualCheck(scli, tick)
runNeighborActualCheck(scli, tick)
return
}
go alignAndRunHour10(func(tick time.Time) { runForecastCheck(scli, tick) })
go alignAndRunHalfHour(func(tick time.Time) { runActualCheck(scli, tick) })
go alignAndRunHalfHour(func(tick time.Time) { runNeighborActualCheck(scli, tick) })
select {}
}
func mustInitSMS(cfg config.Config) *sms.Client {
cli, err := sms.New(sms.Config{
AccessKeyID: strings.TrimSpace(cfg.SMS.AccessKeyID),
AccessKeySecret: strings.TrimSpace(cfg.SMS.AccessKeySecret),
SignName: strings.TrimSpace(cfg.SMS.SignName),
TemplateCode: strings.TrimSpace(cfg.SMS.TemplateCode),
Endpoint: strings.TrimSpace(cfg.SMS.Endpoint),
})
if err != nil {
log.Printf("sms: disabled (%v)", err)
return nil
}
return cli
}
func alignAndRunHour10(fn func(tick time.Time)) {
now := time.Now()
base := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 10, 0, 0, now.Location())
var next time.Time
if now.After(base) {
next = base.Add(time.Hour)
} else {
next = base
}
time.Sleep(time.Until(next))
for {
tick := time.Now().Truncate(time.Minute)
fn(tick)
time.Sleep(time.Hour)
}
}
func alignAndRunHalfHour(fn func(tick time.Time)) {
now := time.Now()
next := now.Truncate(30 * time.Minute).Add(30 * time.Minute)
time.Sleep(time.Until(next))
for {
tick := time.Now().Truncate(time.Minute)
fn(tick)
time.Sleep(30 * time.Minute)
}
}
func runForecastCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
stations := listFixedStations(ctx)
stations = filterStations(stations, flagTestStIDs)
if len(stations) == 0 {
log.Printf("forecast: no stations after filter")
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
now := tick.In(loc)
issued := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, loc)
next1 := issued.Add(time.Hour)
next3 := issued.Add(3 * time.Hour)
for _, st := range stations {
points, err := data.ForecastRainAtIssued(ctx, st.ID, providerForecast, issued)
if err != nil {
log.Printf("forecast: query station=%s err=%v", st.ID, err)
continue
}
var redMax, yellowMax int64
for _, p := range points {
if p.ForecastTime.Before(next1) || p.ForecastTime.After(next3) {
continue
}
v := int64(p.RainMMx1000)
if v >= 8000 {
if v > redMax {
redMax = v
}
} else if v >= 4000 {
if v > yellowMax {
yellowMax = v
}
}
}
level := ""
value := 0.0
threshold := 0.0
if redMax > 0 {
level = levelRed
value = float64(redMax) / 1000.0
threshold = forecastRedMM
} else if yellowMax > 0 {
level = levelYellow
value = float64(yellowMax) / 1000.0
threshold = forecastYellowMM
}
if level == "" {
if !flagTest {
if flagWhy {
log.Printf("forecast why: station=%s no threshold hit redMax=%d yellowMax=%d", st.ID, redMax, yellowMax)
}
continue
}
level = levelYellow
value = forecastYellowMM
threshold = forecastYellowMM
}
if flagTest {
msg := fmt.Sprintf("【测试】站点%s 强制触发未来3小时降水预警 level=%s", st.Name, levelLabel(level))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeForecast, st.ID, level, issued, msg, sql.NullString{})
} else {
sendToPhones(ctx, scli, st.Name, value, level, issued, targetPhones, msg, alertTypeForecast, st.ID)
}
continue
}
msg := fmt.Sprintf("站点%s 未来3小时单小时最大降水 %.3fmm,达到%s阈值 %.1fmmissued_at=%s", st.Name, value, levelLabel(level), threshold, issued.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeForecast, st.ID, level, issued, msg, sql.NullString{})
continue
}
sendToPhones(ctx, scli, st.Name, value, level, issued, targetPhones, msg, alertTypeForecast, st.ID)
}
}
func runActualCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
stations := listFixedStations(ctx)
stations = filterStations(stations, flagTestStIDs)
if len(stations) == 0 {
log.Printf("actual: no stations after filter")
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
end := tick.In(loc)
if flagTestTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", flagTestTime, loc); err == nil {
end = t
}
}
start := end.Add(-30 * time.Minute)
for _, st := range stations {
rain, ok, err := data.SumRainMM(ctx, st.ID, start, end)
if err != nil {
log.Printf("actual: sum station=%s err=%v", st.ID, err)
continue
}
if flagWhy {
log.Printf("actual why: station=%s window=%s~%s rain_sum=%.3f ok=%v", st.ID, start.Format(timeFormatShort), end.Format(timeFormatShort), rain, ok)
}
if !ok {
if flagWhy {
log.Printf("actual why: station=%s no rain data", st.ID)
}
continue
}
level := ""
threshold := 0.0
if rain >= actualRedMM {
level = levelRed
threshold = actualRedMM
} else if rain >= actualYellowMM {
level = levelYellow
threshold = actualYellowMM
}
if level == "" {
if flagWhy {
log.Printf("actual why: station=%s rain=%.3f below threshold", st.ID, rain)
}
if !flagTest {
continue
}
level = levelYellow
threshold = actualYellowMM
rain = actualYellowMM
}
if flagTest {
msg := fmt.Sprintf("【测试】站点%s 强制触发30分钟降水预警 level=%s", st.Name, levelLabel(level))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeActual30m, st.ID, level, end, msg, sql.NullString{})
} else {
sendToPhones(ctx, scli, st.Name, rain, level, end, targetPhones, msg, alertTypeActual30m, st.ID)
}
continue
}
msg := fmt.Sprintf("站点%s 过去30分钟降水 %.3fmm,达到%s阈值 %.1fmm,窗口 %s - %s", st.Name, rain, levelLabel(level), threshold, start.Format(timeFormatShort), end.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeActual30m, st.ID, level, end, msg, sql.NullString{})
log.Printf("actual: triggered station=%s level=%s rain=%.3f (no phones)", st.ID, level, rain)
continue
}
sendToPhones(ctx, scli, st.Name, rain, level, end, targetPhones, msg, alertTypeActual30m, st.ID)
log.Printf("actual: triggered station=%s level=%s rain=%.3f phones=%d", st.ID, level, rain, len(targetPhones))
}
}
func runNeighborActualCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
allStations := listFixedStations(ctx)
centers := filterStations(allStations, flagTestStIDs)
if len(centers) == 0 {
if flagWhy {
log.Printf("neighbor why: no stations after filter")
}
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
end := tick.In(loc)
if flagTestTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", flagTestTime, loc); err == nil {
end = t
}
}
start := end.Add(-30 * time.Minute)
for _, center := range centers {
if center.Latitude == 0 || center.Longitude == 0 {
if flagWhy {
log.Printf("neighbor why: center %s missing lat/lon", center.ID)
}
continue
}
wind, err := data.RadarWeatherNearest(center.Latitude, center.Longitude, end, 6*time.Hour)
if err != nil {
log.Printf("neighbor: wind query failed station=%s: %v", center.ID, err)
continue
}
if wind == nil || !wind.WindDirection.Valid || !wind.WindSpeed.Valid || wind.WindSpeed.Float64 <= 0.01 {
if flagWhy {
log.Printf("neighbor why: center %s no wind data", center.ID)
}
continue
}
dir := wind.WindDirection.Float64
spd := wind.WindSpeed.Float64
radius := spd * 3600
for _, nb := range allStations {
if nb.ID == center.ID {
continue
}
if nb.Latitude == 0 || nb.Longitude == 0 {
if flagWhy {
log.Printf("neighbor why: neighbor %s missing lat/lon", nb.ID)
}
continue
}
dist := haversine(center.Latitude, center.Longitude, nb.Latitude, nb.Longitude)
brg := bearingDeg(center.Latitude, center.Longitude, nb.Latitude, nb.Longitude)
diff := angDiff(brg, dir)
inSector := dist <= radius && diff <= halfAngleDeg
if !inSector {
if flagWhy {
log.Printf("neighbor why: center=%s neighbor=%s not in sector dist=%.1fm radius=%.1fm bearing=%.1f windFrom=%.1f diff=%.1f half=%.1f",
center.ID, nb.ID, dist, radius, brg, dir, diff, halfAngleDeg)
}
continue
}
rain, ok, err := data.SumRainMM(ctx, nb.ID, start, end)
if err != nil {
log.Printf("neighbor: sum rain station=%s err=%v", nb.ID, err)
continue
}
if flagWhy {
log.Printf("neighbor why: center=%s neighbor=%s window=%s~%s rain_sum=%.3f ok=%v", center.ID, nb.ID, start.Format(timeFormatShort), end.Format(timeFormatShort), rain, ok)
}
if !ok {
if flagWhy {
log.Printf("neighbor why: neighbor %s no rain data", nb.ID)
}
continue
}
level := ""
threshold := 0.0
if rain >= actualRedMM {
level = levelRed
threshold = actualRedMM
} else if rain >= actualYellowMM {
level = levelYellow
threshold = actualYellowMM
}
if level == "" {
if flagWhy {
log.Printf("neighbor why: neighbor %s rain=%.3f below threshold", nb.ID, rain)
}
continue
}
atype := alertTypeNeighbor30 + "_" + nb.ID
msg := fmt.Sprintf("站点%s 迎风扇区内站点%s 30分钟降水 %.3fmm,达到%s阈值 %.1fmm,窗口 %s - %s", center.Name, nb.Name, rain, levelLabel(level), threshold, start.Format(timeFormatShort), end.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, atype, center.ID, level, end, msg, sql.NullString{})
log.Printf("neighbor: center=%s neighbor=%s level=%s rain=%.3f (no phones)", center.ID, nb.ID, level, rain)
continue
}
sendToPhones(ctx, scli, center.Name, rain, level, end, targetPhones, msg, atype, center.ID)
log.Printf("neighbor: center=%s neighbor=%s level=%s rain=%.3f phones=%d", center.ID, nb.ID, level, rain, len(targetPhones))
}
}
}
func recordAlert(ctx context.Context, alertType, stationID, level string, issuedAt time.Time, message string, phone sql.NullString) {
_, err := data.InsertAlert(ctx, data.AlertRecord{
AlertType: alertType,
StationID: stationID,
Level: level,
IssuedAt: issuedAt,
Message: message,
SMSPhone: phone,
})
if err != nil {
log.Printf("alert insert failed station=%s type=%s level=%s: %v", stationID, alertType, level, err)
}
}
type recipients struct {
red []string
yel []string
}
func (r recipients) forLevel(level string) []string {
if level == levelRed {
return r.red
}
if level == levelYellow {
return r.yel
}
return nil
}
func loadRecipients(ctx context.Context) recipients {
list, err := data.ListEnabledSMSRecipients(ctx)
if err != nil {
log.Printf("sms: load recipients failed: %v", err)
return recipients{}
}
var res recipients
for _, r := range list {
if r.AlertLevel >= 1 {
res.red = append(res.red, r.Phone)
}
if r.AlertLevel >= 2 {
res.yel = append(res.yel, r.Phone)
}
}
return res
}
func sendToPhones(ctx context.Context, scli *sms.Client, stationName string, value float64, level string, issuedAt time.Time, phones []string, message string, alertType string, stationID string) {
if scli == nil {
return
}
name := "" + stationName + ""
content := format3(value) + " mm"
alertText := "【大礼村】暴雨"
if level == levelRed {
alertText += "红色预警"
} else {
alertText += "黄色预警"
}
for _, ph := range phones {
if err := scli.Send(ctx, name, content, alertText, "", []string{ph}); err != nil {
log.Printf("sms: send failed phone=%s station=%s level=%s: %v", ph, stationID, level, err)
continue
}
recordAlert(ctx, alertType, stationID, level, issuedAt, message, sql.NullString{String: ph, Valid: true})
log.Printf("sms: sent phone=%s station=%s level=%s", ph, stationID, level)
}
}
func format3(v float64) string {
s := fmt.Sprintf("%.3f", v)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
if s == "" {
return "0"
}
return s
}
func mustShanghai() *time.Location {
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
return loc
}
func levelLabel(level string) string {
if level == levelRed {
return "红色"
}
return "黄色"
}
func listFixedStations(ctx context.Context) []data.StationInfo {
ids := []string{"RS485-002964", "RS485-002A39", "RS485-0029CB"}
sts, err := data.ListStationsByIDs(ctx, ids)
if err != nil || len(sts) == 0 {
var out []data.StationInfo
for _, id := range ids {
out = append(out, data.StationInfo{ID: id, Name: id})
}
return out
}
// 保证顺序按 ids
order := make(map[string]int)
for i, id := range ids {
order[id] = i
}
sort.Slice(sts, func(i, j int) bool { return order[sts[i].ID] < order[sts[j].ID] })
return sts
}
func filterStations(in []data.StationInfo, filter string) []data.StationInfo {
f := strings.TrimSpace(filter)
if f == "" {
return in
}
parts := strings.Split(f, ",")
m := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
m[p] = struct{}{}
}
}
if len(m) == 0 {
return in
}
var out []data.StationInfo
for _, st := range in {
if _, ok := m[st.ID]; ok {
out = append(out, st)
}
}
return out
}
func toRad(d float64) float64 { return d * math.Pi / 180 }
func toDeg(r float64) float64 { return r * 180 / math.Pi }
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371000.0
dLat := toRad(lat2 - lat1)
dLon := toRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
φ1 := toRad(lat1)
φ2 := toRad(lat2)
Δλ := toRad(lon2 - lon1)
y := math.Sin(Δλ) * math.Cos(φ2)
x := math.Cos(φ1)*math.Sin(φ2) - math.Sin(φ1)*math.Cos(φ2)*math.Cos(Δλ)
brg := toDeg(math.Atan2(y, x))
if brg < 0 {
brg += 360
}
return brg
}
func angDiff(a, b float64) float64 {
d := math.Mod(a-b+540, 360) - 180
if d < 0 {
d = -d
}
return math.Abs(d)
}

View File

@ -422,7 +422,7 @@ export class AppComponent implements OnInit, AfterViewInit {
} catch {}
if (windDir != null && windSpd != null && windSpd > 0.01) {
const bearingTo = (windDir + 180) % 360; // 去向
const bearingTo = windDir;
const hours = 3;
const radius = windSpd * 3600 * hours; // m
const half = 25; // 半角

View File

@ -0,0 +1,41 @@
package data
import (
"context"
"database/sql"
"time"
)
type AlertRecord struct {
AlertType string
StationID string
Level string
IssuedAt time.Time
Message string
SMSPhone sql.NullString
}
// InsertAlert writes one alert row; returns inserted id or 0 when skipped by conflict.
func InsertAlert(ctx context.Context, ar AlertRecord) (int64, error) {
const q = `
INSERT INTO alerts (alert_type, station_id, level, issued_at, message, sms_phone)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT DO NOTHING
RETURNING id`
var id int64
err := DB().QueryRowContext(ctx, q,
ar.AlertType,
ar.StationID,
ar.Level,
ar.IssuedAt,
ar.Message,
ar.SMSPhone,
).Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
return id, nil
}

View File

@ -19,3 +19,17 @@ func FetchActualHourlyRain(ctx context.Context, stationID string, start, end tim
}
return float64(sum.Int64) / 1000.0, true, nil
}
// SumRainMM sums rain_10m_mm_x1000 over [start,end) and returns mm.
func SumRainMM(ctx context.Context, stationID string, start, end time.Time) (float64, bool, error) {
const q = `SELECT SUM(rain_10m_mm_x1000) FROM rs485_weather_10min WHERE station_id=$1 AND bucket_start >= $2 AND bucket_start < $3`
var sum sql.NullInt64
err := DB().QueryRowContext(ctx, q, stationID, start, end).Scan(&sum)
if err != nil {
return 0, false, err
}
if !sum.Valid {
return 0, false, nil
}
return float64(sum.Int64) / 1000.0, true, nil
}

View File

@ -3,6 +3,8 @@ package data
import (
"context"
"database/sql"
"fmt"
"strings"
)
// GetStationName returns stations.name by station_id; empty string if not found/null.
@ -21,3 +23,88 @@ func GetStationName(ctx context.Context, stationID string) (string, error) {
}
return "", nil
}
// StationInfo contains minimal fields for alert checks.
type StationInfo struct {
ID string
Name string
Location string
Latitude float64
Longitude float64
Altitude float64
}
// ListEligibleStations returns WH65LP stations with required non-empty fields.
func ListEligibleStations(ctx context.Context) ([]StationInfo, error) {
const q = `
SELECT
station_id,
COALESCE(NULLIF(BTRIM(name), ''), station_id) AS name,
location,
latitude::float8,
longitude::float8,
altitude::float8
FROM stations
WHERE
device_type = 'WH65LP' AND
name IS NOT NULL AND BTRIM(name) <> '' AND
location IS NOT NULL AND BTRIM(location) <> '' AND
latitude IS NOT NULL AND latitude <> 0 AND
longitude IS NOT NULL AND longitude <> 0 AND
altitude IS NOT NULL AND altitude <> 0`
rows, err := DB().QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StationInfo
for rows.Next() {
var st StationInfo
if err := rows.Scan(&st.ID, &st.Name, &st.Location, &st.Latitude, &st.Longitude, &st.Altitude); err != nil {
continue
}
out = append(out, st)
}
return out, nil
}
// ListStationsByIDs returns stations by a given id list (ignores missing ones).
func ListStationsByIDs(ctx context.Context, ids []string) ([]StationInfo, error) {
if len(ids) == 0 {
return nil, nil
}
var placeholders []string
args := make([]interface{}, 0, len(ids))
for i, id := range ids {
placeholders = append(placeholders, fmt.Sprintf("$%d", i+1))
args = append(args, id)
}
q := fmt.Sprintf(`
SELECT
station_id,
COALESCE(NULLIF(BTRIM(name), ''), station_id) AS name,
location,
latitude::float8,
longitude::float8,
altitude::float8
FROM stations
WHERE station_id IN (%s)`, strings.Join(placeholders, ","))
rows, err := DB().QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StationInfo
for rows.Next() {
var st StationInfo
if err := rows.Scan(&st.ID, &st.Name, &st.Location, &st.Latitude, &st.Longitude, &st.Altitude); err != nil {
continue
}
out = append(out, st)
}
return out, nil
}

7
db/migrations/02.sql Normal file
View File

@ -0,0 +1,7 @@
-- 02.sql: create simple users table
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password TEXT NOT NULL, -- bcrypt hash
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

9
db/migrations/03.sql Normal file
View File

@ -0,0 +1,9 @@
-- 03.sql: recipients for SMS alerts
-- Table to store phone numbers, enabled flag, and alert level (1=red only, 2=yellow+red)
-- PostgreSQL has no native unsigned int; use integer with CHECK constraints.
CREATE TABLE IF NOT EXISTS sms_recipients (
phone TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
alert_level INTEGER NOT NULL DEFAULT 2 CHECK (alert_level >= 1)
);

View File

@ -0,0 +1,28 @@
-- 04_sms_recipients_seed_20251125.sql: add/update specific SMS recipients
-- Sets enabled = FALSE and alert_level = 1 for listed phone numbers.
-- Idempotent via ON CONFLICT on primary key (phone).
INSERT INTO sms_recipients (phone, enabled, alert_level) VALUES
('13114458208', FALSE, 1),
('13986807953', FALSE, 1),
('13207210509', FALSE, 1),
('13886680872', FALSE, 1),
('13477172662', FALSE, 1),
('13177094329', FALSE, 1),
('13165617999', FALSE, 1),
('13217179901', FALSE, 1),
('18571017120', FALSE, 1),
('18674205345', FALSE, 1),
('18871769640', FALSE, 1),
('15587930225', FALSE, 1),
('13545715958', FALSE, 1),
('15629386907', FALSE, 1),
('15971633321', FALSE, 1),
('15671074991', FALSE, 1),
('18727254175', FALSE, 1),
('13477108587', FALSE, 1),
('15897521649', FALSE, 1)
ON CONFLICT (phone) DO UPDATE SET
enabled = EXCLUDED.enabled,
alert_level = EXCLUDED.alert_level;

View File

@ -0,0 +1,23 @@
-- 05_alerts.sql: table for alerts/warnings
CREATE TABLE IF NOT EXISTS alerts (
id BIGSERIAL PRIMARY KEY,
alert_type TEXT NOT NULL,
station_id VARCHAR(50) NOT NULL,
level VARCHAR(10) NOT NULL CHECK (level IN ('yellow', 'red')),
issued_at TIMESTAMPTZ NOT NULL,
message TEXT,
sms_phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One record per alert event (no SMS stored)
CREATE UNIQUE INDEX IF NOT EXISTS alerts_uniq_event
ON alerts (alert_type, station_id, issued_at, level)
WHERE sms_phone IS NULL;
-- One record per phone recipient
CREATE UNIQUE INDEX IF NOT EXISTS alerts_uniq_phone
ON alerts (alert_type, station_id, issued_at, level, sms_phone)
WHERE sms_phone IS NOT NULL;
CREATE INDEX IF NOT EXISTS alerts_station_idx ON alerts (station_id);