From 6d276b1760c2c9cf18ffa3086d3b7ca8f5793bf2 Mon Sep 17 00:00:00 2001 From: yarnom Date: Fri, 5 Dec 2025 09:22:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/cmd/service-alert/main.go | 551 ++++++++++++++++++ core/frontend/src/main.ts | 2 +- core/internal/data/alerts.go | 41 ++ core/internal/data/rain.go | 14 + core/internal/data/station.go | 87 +++ db/migrations/02.sql | 7 + db/migrations/03.sql | 9 + .../04_sms_recipients_seed_20251125.sql | 28 + db/migrations/05_alerts.sql | 23 + 9 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 core/cmd/service-alert/main.go create mode 100644 core/internal/data/alerts.go create mode 100644 db/migrations/02.sql create mode 100644 db/migrations/03.sql create mode 100644 db/migrations/04_sms_recipients_seed_20251125.sql create mode 100644 db/migrations/05_alerts.sql diff --git a/core/cmd/service-alert/main.go b/core/cmd/service-alert/main.go new file mode 100644 index 0000000..ea5e792 --- /dev/null +++ b/core/cmd/service-alert/main.go @@ -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阈值 %.1fmm,issued_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) +} diff --git a/core/frontend/src/main.ts b/core/frontend/src/main.ts index 0953099..c6f441f 100644 --- a/core/frontend/src/main.ts +++ b/core/frontend/src/main.ts @@ -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; // 半角 diff --git a/core/internal/data/alerts.go b/core/internal/data/alerts.go new file mode 100644 index 0000000..9b90b77 --- /dev/null +++ b/core/internal/data/alerts.go @@ -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 +} diff --git a/core/internal/data/rain.go b/core/internal/data/rain.go index a843523..39e80d5 100644 --- a/core/internal/data/rain.go +++ b/core/internal/data/rain.go @@ -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 +} diff --git a/core/internal/data/station.go b/core/internal/data/station.go index ee0a3da..2c1b8af 100644 --- a/core/internal/data/station.go +++ b/core/internal/data/station.go @@ -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 +} diff --git a/db/migrations/02.sql b/db/migrations/02.sql new file mode 100644 index 0000000..174af58 --- /dev/null +++ b/db/migrations/02.sql @@ -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() +); + diff --git a/db/migrations/03.sql b/db/migrations/03.sql new file mode 100644 index 0000000..1248c2e --- /dev/null +++ b/db/migrations/03.sql @@ -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) +); + diff --git a/db/migrations/04_sms_recipients_seed_20251125.sql b/db/migrations/04_sms_recipients_seed_20251125.sql new file mode 100644 index 0000000..4bc436d --- /dev/null +++ b/db/migrations/04_sms_recipients_seed_20251125.sql @@ -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; + diff --git a/db/migrations/05_alerts.sql b/db/migrations/05_alerts.sql new file mode 100644 index 0000000..e4f9dff --- /dev/null +++ b/db/migrations/05_alerts.sql @@ -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);