feat: 新增告警
This commit is contained in:
parent
2c2d5232f8
commit
6d276b1760
551
core/cmd/service-alert/main.go
Normal file
551
core/cmd/service-alert/main.go
Normal 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阈值 %.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)
|
||||||
|
}
|
||||||
@ -422,7 +422,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (windDir != null && windSpd != null && windSpd > 0.01) {
|
if (windDir != null && windSpd != null && windSpd > 0.01) {
|
||||||
const bearingTo = (windDir + 180) % 360; // 去向
|
const bearingTo = windDir;
|
||||||
const hours = 3;
|
const hours = 3;
|
||||||
const radius = windSpd * 3600 * hours; // m
|
const radius = windSpd * 3600 * hours; // m
|
||||||
const half = 25; // 半角
|
const half = 25; // 半角
|
||||||
|
|||||||
41
core/internal/data/alerts.go
Normal file
41
core/internal/data/alerts.go
Normal 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
|
||||||
|
}
|
||||||
@ -19,3 +19,17 @@ func FetchActualHourlyRain(ctx context.Context, stationID string, start, end tim
|
|||||||
}
|
}
|
||||||
return float64(sum.Int64) / 1000.0, true, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package data
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetStationName returns stations.name by station_id; empty string if not found/null.
|
// 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
|
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
7
db/migrations/02.sql
Normal 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
9
db/migrations/03.sql
Normal 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)
|
||||||
|
);
|
||||||
|
|
||||||
28
db/migrations/04_sms_recipients_seed_20251125.sql
Normal file
28
db/migrations/04_sms_recipients_seed_20251125.sql
Normal 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;
|
||||||
|
|
||||||
23
db/migrations/05_alerts.sql
Normal file
23
db/migrations/05_alerts.sql
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user