366 lines
11 KiB
Go
366 lines
11 KiB
Go
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)
|
||
}
|