package main import ( "context" "flag" "fmt" "log" "strings" "time" "weatherstation/core/internal/config" "weatherstation/core/internal/data" "weatherstation/core/internal/sms" ) func main() { // Usage: // CORE_SMS_AK, CORE_SMS_SK, CORE_SMS_SIGN, CORE_SMS_TPL, optional CORE_SMS_ENDPOINT // go run ./core/cmd/sms-send --to 17308264374 --msg "Hello Yarnom" --name device-ids --time 2025-01-01 12:00 // go run ./core/cmd/sms-send -> hourly check mode (first 10 minutes of each hour) var to, msg, name, tm string var once bool var testMode bool var testLevel int // test2: manual station+rain, auto decide level and send var test2 bool var station string var rain float64 flag.StringVar(&to, "to", "", "comma-separated phone numbers") flag.StringVar(&msg, "msg", "", "message content (for ${content}, recommend numeric value)") flag.StringVar(&name, "name", "", "device IDs/name field for template") flag.StringVar(&tm, "time", "", "time field for template (unused if empty)") var alert string flag.StringVar(&alert, "alert", "", "alert text for ${alert}") flag.BoolVar(&once, "once", false, "run one check immediately (auto mode)") flag.BoolVar(&testMode, "test", false, "run in test mode (ignore thresholds)") flag.IntVar(&testLevel, "level", 1, "test target alert level (1=大雨-only, 2=中雨+大雨)") flag.BoolVar(&test2, "test2", false, "manual test by station+rain; decide yellow/red and send to recipients by alert level") flag.StringVar(&station, "station", "", "station name for template ${name}") flag.Float64Var(&rain, "rain", 0, "rainfall in mm (single hour)") flag.Parse() cfg := config.Load() scli, 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.Fatal(err) } // Manual send mode when --to and --msg are provided if to != "" && msg != "" { if tm == "" { tm = "" } if name == "" { name = "" } // Manual mode: allow --alert (recommended for new template) phones := strings.Split(to, ",") if err := scli.Send(context.Background(), name, msg, alert, tm, phones); err != nil { log.Fatal(err) } log.Println("sms: sent OK") return } // Test mode: ignore thresholds, send to recipients of given level, append (测试) if testMode { runTestCheck(scli, testLevel) return } // Test2 mode: user-provided station name and rain (mm); do not read forecast DB if test2 { runTest2(scli, station, rain) return } // Auto mode: 每小时的第一个10分钟启动 checkFn := func(tick time.Time) { runHourlyCheck(scli, tick) } if once { checkFn(time.Now()) return } alignAndRunHour10(checkFn) } // alignAndRunHour10 runs fn at the first 10 minutes of each hour. 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(1 * time.Hour) } else { next = base } time.Sleep(time.Until(next)) for { tick := time.Now().Truncate(time.Minute) fn(tick) time.Sleep(1 * time.Hour) } } func runHourlyCheck(scli *sms.Client, tick time.Time) { // 固定 provider 和站点 provider := "imdroid_mix" stationIDs := []string{"RS485-0029CB", "RS485-002A39", "RS485-002964"} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // 读取收件人(enabled) recipients, err := data.ListEnabledSMSRecipients(ctx) if err != nil { log.Printf("sms: load recipients failed: %v", err) return } if len(recipients) == 0 { log.Printf("sms: no enabled recipients, skip") return } // alert_level: 1=大雨 only, 2=中雨+大雨 var heavyPhones, moderatePhones []string for _, r := range recipients { if r.AlertLevel >= 1 { heavyPhones = append(heavyPhones, r.Phone) } if r.AlertLevel >= 2 { moderatePhones = append(moderatePhones, r.Phone) } } if len(heavyPhones) == 0 && len(moderatePhones) == 0 { log.Printf("sms: no recipients by level, skip") return } // 以 CST 解析 issued_at 整点 loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } now := tick.In(loc) issued := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, loc) // 三小时窗口:hour+1, hour+2, hour+3 next1 := issued.Add(1 * time.Hour) next3 := issued.Add(3 * time.Hour) // 遍历站点,计算未来三小时单小时阈值(红>黄) for _, sid := range stationIDs { points, err := data.ForecastRainAtIssued(ctx, sid, provider, issued) if err != nil { log.Printf("sms: forecast query failed station=%s: %v", sid, err) continue } stName, err := data.GetStationName(ctx, sid) if err != nil { stName = "" } if strings.TrimSpace(stName) == "" { stName = sid } var redMaxX1000 int64 var yellowMaxX1000 int64 for _, p := range points { if !p.ForecastTime.Before(next1) && !p.ForecastTime.After(next3) { v := int64(p.RainMMx1000) if v >= 8000 { if v > redMaxX1000 { redMaxX1000 = v } } else if v >= 4000 { if v > yellowMaxX1000 { yellowMaxX1000 = v } } } } // 判定阈值(单小时):任一>=8红色;否则任一[4,8)黄色;否则不发 if redMaxX1000 > 0 { if len(heavyPhones) > 0 { // 模板参数格式:time: "YYYY-MM-DD HH:MM," name: ":<站点名称>," content: " mm(大雨)" name := ":" + stName + "," // 新模板字段:content=数值, alert=固定文案, time可为空(此处仍传带逗号的时间字符串以兼容) content := format3(float64(redMaxX1000)/1000.0) + " mm" alert := "【大礼村】暴雨红色预警" tm := "" // ${time} 不用了 if err := scli.Send(ctx, name, content, alert, tm, heavyPhones); err != nil { log.Printf("sms: send heavy failed station=%s: %v", sid, err) } else { log.Printf("sms: sent HEAVY (红色) station=%s max=%.3fmm to=%d", sid, float64(redMaxX1000)/1000.0, len(heavyPhones)) } } } else if yellowMaxX1000 > 0 { if len(moderatePhones) > 0 { name := ":" + stName + "," content := format3(float64(yellowMaxX1000)/1000.0) + " mm" alert := "【大礼村】暴雨黄色预警" tm := "" if err := scli.Send(ctx, name, content, alert, tm, moderatePhones); err != nil { log.Printf("sms: send moderate failed station=%s: %v", sid, err) } else { log.Printf("sms: sent MODERATE (黄色) station=%s max=%.3fmm to=%d", sid, float64(yellowMaxX1000)/1000.0, len(moderatePhones)) } } } else { log.Printf("sms: no alert station=%s", sid) } } } // content now only carries numeric rain value; helpers removed. 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 } // runTestCheck sends messages regardless of thresholds to recipients at given alert level. func runTestCheck(scli *sms.Client, level int) { provider := "imdroid_mix" stationIDs := []string{"RS485-0029CB", "RS485-002A39", "RS485-002964"} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Load recipients (enabled) and filter by exact level recipients, err := data.ListEnabledSMSRecipients(ctx) if err != nil { log.Printf("sms test: load recipients failed: %v", err) return } var phones []string for _, r := range recipients { if r.AlertLevel == level { phones = append(phones, r.Phone) } } if len(phones) == 0 { log.Printf("sms test: no recipients at level=%d", level) return } // 时间与窗口 loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } now := time.Now().In(loc) issued := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, loc) next1 := issued.Add(1 * time.Hour) next3 := issued.Add(3 * time.Hour) // Iterate stations for _, sid := range stationIDs { points, err := data.ForecastRainAtIssued(ctx, sid, provider, issued) if err != nil { log.Printf("sms test: forecast query failed station=%s: %v", sid, err) continue } stName, err := data.GetStationName(ctx, sid) if err != nil { stName = "" } if strings.TrimSpace(stName) == "" { stName = sid } var sumX1000 int64 for _, p := range points { if !p.ForecastTime.Before(next1) && !p.ForecastTime.After(next3) { sumX1000 += int64(p.RainMMx1000) } } name := ":" + stName + "," // Test mode: content=数值; alert=红色预警 + (测试) content := format3(float64(sumX1000)/1000.0) + " mm" alert := "【大礼村】暴雨红色预警(测试)" tm := "" if err := scli.Send(ctx, name, content, alert, tm, phones); err != nil { log.Printf("sms test: send failed station=%s: %v", sid, err) } else { log.Printf("sms test: sent station=%s sum=%.3fmm level=%d to=%d", sid, float64(sumX1000)/1000.0, level, len(phones)) } } } // runTest2 evaluates the provided rainfall and sends to recipients by alert level: // - red (>=8mm): send to level>=1 (both 1 and 2) // - yellow ([4,8)mm): send to level>=2 only // No DB read for forecast; only loads recipients list. func runTest2(scli *sms.Client, station string, rain float64) { if strings.TrimSpace(station) == "" { log.Printf("sms test2: station name required; use --station") return } if rain < 0 { log.Printf("sms test2: rain must be >= 0") return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() recipients, err := data.ListEnabledSMSRecipients(ctx) if err != nil { log.Printf("sms test2: load recipients failed: %v", err) return } var heavyPhones, moderatePhones []string for _, r := range recipients { if r.AlertLevel >= 1 { heavyPhones = append(heavyPhones, r.Phone) } if r.AlertLevel >= 2 { moderatePhones = append(moderatePhones, r.Phone) } } if len(heavyPhones) == 0 && len(moderatePhones) == 0 { log.Printf("sms test2: no recipients, skip") return } // Decide level by rain (mm) name := ":" + strings.TrimSpace(station) + "," content := format3(rain) + " mm" if rain >= 8.0 { if len(heavyPhones) == 0 { log.Printf("sms test2: red alert but no level>=1 recipients") return } alert := "【大礼村】暴雨红色预警" if err := scli.Send(ctx, name, content, alert, "", heavyPhones); err != nil { log.Printf("sms test2: send RED failed: %v", err) } else { log.Printf("sms test2: sent RED station=%s rain=%.3fmm to=%d", station, rain, len(heavyPhones)) } return } if rain >= 4.0 { if len(moderatePhones) == 0 { log.Printf("sms test2: yellow alert but no level>=2 recipients") return } alert := "【大礼村】暴雨黄色预警" if err := scli.Send(ctx, name, content, alert, "", moderatePhones); err != nil { log.Printf("sms test2: send YELLOW failed: %v", err) } else { log.Printf("sms test2: sent YELLOW station=%s rain=%.3fmm to=%d", station, rain, len(moderatePhones)) } return } log.Printf("sms test2: rain %.3fmm below yellow threshold, no alert", rain) }