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) }