366 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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