diff --git a/app_darwin.go b/app_darwin.go new file mode 100644 index 0000000..0635cf0 --- /dev/null +++ b/app_darwin.go @@ -0,0 +1,459 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "context" + "fmt" + "go-stock/backend/data" + "go-stock/backend/db" + "go-stock/backend/logger" + "go-stock/backend/models" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/coocood/freecache" + "github.com/duke-git/lancet/v2/convertor" + "github.com/duke-git/lancet/v2/mathutil" + "github.com/duke-git/lancet/v2/slice" + "github.com/go-resty/resty/v2" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// App struct +type App struct { + ctx context.Context + cache *freecache.Cache +} + +// NewApp creates a new App application struct +func NewApp() *App { + cacheSize := 512 * 1024 + cache := freecache.NewCache(cacheSize) + return &App{ + cache: cache, + } +} + +// startup is called at application startup +func (a *App) startup(ctx context.Context) { + logger.SugaredLogger.Infof("Version:%s", Version) + // Perform your setup here + a.ctx = ctx + + // TODO 创建系统托盘 + +} + +func checkUpdate(a *App) { + releaseVersion := &models.GitHubReleaseVersion{} + _, err := resty.New().R(). + SetResult(releaseVersion). + Get("https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest") + if err != nil { + logger.SugaredLogger.Errorf("get github release version error:%s", err.Error()) + return + } + logger.SugaredLogger.Infof("releaseVersion:%+v", releaseVersion.TagName) + if releaseVersion.TagName != Version { + go runtime.EventsEmit(a.ctx, "updateVersion", releaseVersion) + } +} + +// domReady is called after front-end resources have been loaded +func (a *App) domReady(ctx context.Context) { + // Add your action here + //定时更新数据 + go func() { + config := data.NewSettingsApi(&data.Settings{}).GetConfig() + interval := config.RefreshInterval + if interval <= 0 { + interval = 1 + } + ticker := time.NewTicker(time.Second * time.Duration(interval)) + defer ticker.Stop() + for range ticker.C { + if isTradingTime(time.Now()) { + MonitorStockPrices(a) + } + } + }() + + go func() { + ticker := time.NewTicker(time.Second * time.Duration(60)) + defer ticker.Stop() + for range ticker.C { + telegraph := refreshTelegraphList() + if telegraph != nil { + go runtime.EventsEmit(a.ctx, "telegraph", telegraph) + } + } + + }() + go runtime.EventsEmit(a.ctx, "telegraph", refreshTelegraphList()) + go MonitorStockPrices(a) + + //检查新版本 + go func() { + checkUpdate(a) + }() +} + +func refreshTelegraphList() *[]string { + url := "https://www.cls.cn/telegraph" + response, err := resty.New().R(). + SetHeader("Referer", "https://www.cls.cn/"). + SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.60"). + Get(fmt.Sprintf(url)) + if err != nil { + return &[]string{} + } + //logger.SugaredLogger.Info(string(response.Body())) + document, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body()))) + if err != nil { + return &[]string{} + } + var telegraph []string + document.Find("div.telegraph-content-box").Each(func(i int, selection *goquery.Selection) { + //logger.SugaredLogger.Info(selection.Text()) + telegraph = append(telegraph, selection.Text()) + }) + return &telegraph +} + +// isTradingDay 判断是否是交易日 +func isTradingDay(date time.Time) bool { + weekday := date.Weekday() + // 判断是否是周末 + if weekday == time.Saturday || weekday == time.Sunday { + return false + } + // 这里可以添加具体的节假日判断逻辑 + // 例如:判断是否是春节、国庆节等 + return true +} + +// isTradingTime 判断是否是交易时间 +func isTradingTime(date time.Time) bool { + if !isTradingDay(date) { + return false + } + + hour, minute, _ := date.Clock() + + // 判断是否在9:15到11:30之间 + if (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) { + return true + } + + // 判断是否在13:00到15:00之间 + if (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) { + return true + } + + return false +} + +func MonitorStockPrices(a *App) { + dest := &[]data.FollowedStock{} + db.Dao.Model(&data.FollowedStock{}).Find(dest) + total := float64(0) + //for _, follow := range *dest { + // stockData := getStockInfo(follow) + // total += stockData.ProfitAmountToday + // price, _ := convertor.ToFloat(stockData.Price) + // if stockData.PrePrice != price { + // go runtime.EventsEmit(a.ctx, "stock_price", stockData) + // } + //} + + stockInfos := GetStockInfos(*dest...) + for _, stockInfo := range *stockInfos { + total += stockInfo.ProfitAmountToday + price, _ := convertor.ToFloat(stockInfo.Price) + if stockInfo.PrePrice != price { + go runtime.EventsEmit(a.ctx, "stock_price", stockInfo) + } + } + if total != 0 { + // title := "go-stock " + time.Now().Format(time.DateTime) + fmt.Sprintf(" %.2f¥", total) + // systray.SetTooltip(title) + } + + go runtime.EventsEmit(a.ctx, "realtime_profit", fmt.Sprintf(" %.2f", total)) + //runtime.WindowSetTitle(a.ctx, title) + +} +func GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo { + stockCodes := make([]string, 0) + for _, follow := range follows { + stockCodes = append(stockCodes, follow.StockCode) + } + stockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...) + if err != nil { + logger.SugaredLogger.Errorf("get stock code real time data error:%s", err.Error()) + return nil + } + stockInfos := make([]data.StockInfo, 0) + for _, info := range *stockData { + v, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool { + return follow.StockCode == info.Code + }) + if ok { + addStockFollowData(v, &info) + stockInfos = append(stockInfos, info) + } + } + return &stockInfos +} +func getStockInfo(follow data.FollowedStock) *data.StockInfo { + stockCode := follow.StockCode + stockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode) + if err != nil || len(*stockDatas) == 0 { + return &data.StockInfo{} + } + stockData := (*stockDatas)[0] + addStockFollowData(follow, &stockData) + return &stockData +} + +func addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) { + stockData.PrePrice = follow.Price //上次当前价格 + stockData.Sort = follow.Sort + stockData.CostPrice = follow.CostPrice //成本价 + stockData.CostVolume = follow.Volume //成本量 + stockData.AlarmChangePercent = follow.AlarmChangePercent + stockData.AlarmPrice = follow.AlarmPrice + + //当前价格 + price, _ := convertor.ToFloat(stockData.Price) + //当前价格为0 时 使用卖一价格作为当前价格 + if price == 0 { + price, _ = convertor.ToFloat(stockData.A1P) + } + //当前价格依然为0 时 使用买一报价作为当前价格 + if price == 0 { + price, _ = convertor.ToFloat(stockData.B1P) + } + + //昨日收盘价 + preClosePrice, _ := convertor.ToFloat(stockData.PreClose) + + //当前价格依然为0 时 使用昨日收盘价为当前价格 + if price == 0 { + price = preClosePrice + } + + //今日最高价 + highPrice, _ := convertor.ToFloat(stockData.High) + if highPrice == 0 { + highPrice, _ = convertor.ToFloat(stockData.Open) + } + + //今日最低价 + lowPrice, _ := convertor.ToFloat(stockData.Low) + if lowPrice == 0 { + lowPrice, _ = convertor.ToFloat(stockData.Open) + } + //开盘价 + //openPrice, _ := convertor.ToFloat(stockData.Open) + + if price > 0 { + stockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2) + stockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3) + } + if highPrice > 0 { + stockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3) + } + if lowPrice > 0 { + stockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3) + } + if follow.CostPrice > 0 && follow.Volume > 0 { + if price > 0 { + stockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3) + stockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2) + stockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2) + } else { + //未开盘时当前价格为昨日收盘价 + stockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3) + stockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2) + stockData.ProfitAmountToday = mathutil.RoundToFloat((preClosePrice-preClosePrice)*float64(follow.Volume), 2) + } + + } + + //logger.SugaredLogger.Debugf("stockData:%+v", stockData) + if follow.Price != price && price > 0 { + go db.Dao.Model(follow).Where("stock_code = ?", follow.StockCode).Updates(map[string]interface{}{ + "price": price, + }) + } +} + +// beforeClose is called when the application is about to quit, +// either by clicking the window close button or calling runtime.Quit. +// Returning true will cause the application to continue, false will continue shutdown as normal. +func (a *App) beforeClose(ctx context.Context) (prevent bool) { + + dialog, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{ + Type: runtime.QuestionDialog, + Title: "go-stock", + Message: "确定关闭吗?", + Buttons: []string{"确定"}, + Icon: icon, + CancelButton: "取消", + }) + + if err != nil { + logger.SugaredLogger.Errorf("dialog error:%s", err.Error()) + return false + } + logger.SugaredLogger.Debugf("dialog:%s", dialog) + if dialog == "No" { + return true + } + return false +} + +// shutdown is called at application termination +func (a *App) shutdown(ctx context.Context) { + // Perform your teardown here + // systray.Quit() +} + +// Greet returns a greeting for the given name +func (a *App) Greet(stockCode string) *data.StockInfo { + //stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode) + + follow := &data.FollowedStock{ + StockCode: stockCode, + } + db.Dao.Model(follow).Where("stock_code = ?", stockCode).First(follow) + stockInfo := getStockInfo(*follow) + return stockInfo +} + +func (a *App) Follow(stockCode string) string { + return data.NewStockDataApi().Follow(stockCode) +} + +func (a *App) UnFollow(stockCode string) string { + return data.NewStockDataApi().UnFollow(stockCode) +} + +func (a *App) GetFollowList() []data.FollowedStock { + return data.NewStockDataApi().GetFollowList() +} + +func (a *App) GetStockList(key string) []data.StockBasic { + return data.NewStockDataApi().GetStockList(key) +} + +func (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string { + return data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode) +} + +func (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string { + return data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode) +} +func (a *App) SetStockSort(sort int64, stockCode string) { + data.NewStockDataApi().SetStockSort(sort, stockCode) +} +func (a *App) SendDingDingMessage(message string, stockCode string) string { + ttl, _ := a.cache.TTL([]byte(stockCode)) + logger.SugaredLogger.Infof("stockCode %s ttl:%d", stockCode, ttl) + if ttl > 0 { + return "" + } + err := a.cache.Set([]byte(stockCode), []byte("1"), 60*5) + if err != nil { + logger.SugaredLogger.Errorf("set cache error:%s", err.Error()) + return "" + } + return data.NewDingDingAPI().SendDingDingMessage(message) +} + +// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警 +func (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string { + ttl, _ := a.cache.TTL([]byte(stockCode)) + logger.SugaredLogger.Infof("stockCode %s ttl:%d", stockCode, ttl) + if ttl > 0 { + return "" + } + err := a.cache.Set([]byte(stockCode), []byte("1"), getMsgTypeTTL(msgType)) + if err != nil { + logger.SugaredLogger.Errorf("set cache error:%s", err.Error()) + return "" + } + stockInfo := &data.StockInfo{} + db.Dao.Model(stockInfo).Where("code = ?", stockCode).First(stockInfo) + go data.NewAlertWindowsApi("go-stock消息通知", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), "").SendNotification() + return data.NewDingDingAPI().SendDingDingMessage(message) +} + +func (a *App) NewChat(stock string) string { + return data.NewDeepSeekOpenAi().NewChat(stock) +} + +func (a *App) NewChatStream(stock, stockCode string) { + msgs := data.NewDeepSeekOpenAi().NewChatStream(stock, stockCode) + for msg := range msgs { + runtime.EventsEmit(a.ctx, "newChatStream", msg) + } + runtime.EventsEmit(a.ctx, "newChatStream", "DONE") +} + +func GenNotificationMsg(stockInfo *data.StockInfo) string { + Price, err := convertor.ToFloat(stockInfo.Price) + if err != nil { + Price = 0 + } + PreClose, err := convertor.ToFloat(stockInfo.PreClose) + if err != nil { + PreClose = 0 + } + var RF float64 + if PreClose > 0 { + RF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2) + } + + return "[" + stockInfo.Name + "] " + stockInfo.Price + " " + convertor.ToString(RF) + "% " + stockInfo.Date + " " + stockInfo.Time +} + +// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) +func getMsgTypeTTL(msgType int) int { + switch msgType { + case 1: + return 60 * 5 + case 2: + return 60 * 30 + case 3: + return 60 * 30 + default: + return 60 * 5 + } +} + +func getMsgTypeName(msgType int) string { + switch msgType { + case 1: + return "涨跌报警" + case 2: + return "股价报警" + case 3: + return "成本价报警" + default: + return "未知类型" + } +} + +func (a *App) UpdateConfig(settings *data.Settings) string { + logger.SugaredLogger.Infof("UpdateConfig:%+v", settings) + return data.NewSettingsApi(settings).UpdateConfig() +} + +func (a *App) GetConfig() *data.Settings { + return data.NewSettingsApi(&data.Settings{}).GetConfig() +} diff --git a/backend/data/alert_darwin_api.go b/backend/data/alert_darwin_api.go new file mode 100644 index 0000000..fc226a7 --- /dev/null +++ b/backend/data/alert_darwin_api.go @@ -0,0 +1,52 @@ +//go:build darwin +// +build darwin + +package data + +import ( + "fmt" + "go-stock/backend/logger" + + "os/exec" +) + +// AlertWindowsApi @Author 2lovecode +// @Date 2025/02/06 17:50 +// @Desc +// ----------------------------------------------------------------------------------- +type AlertWindowsApi struct { + AppID string + // 窗口标题 + Title string + // 窗口内容 + Content string + // 窗口图标 + Icon string +} + +func NewAlertWindowsApi(AppID string, Title string, Content string, Icon string) *AlertWindowsApi { + return &AlertWindowsApi{ + AppID: AppID, + Title: Title, + Content: Content, + Icon: Icon, + } +} + +func (a AlertWindowsApi) SendNotification() bool { + if getConfig().LocalPushEnable == false { + logger.SugaredLogger.Error("本地推送未开启") + return false + } + + script := fmt.Sprintf(`display notification "%s" with title "%s"`, a.Content, a.Title) + + cmd := exec.Command("osascript", "-e", script) + err := cmd.Run() + if err != nil { + logger.SugaredLogger.Error(err) + return false + } + + return true +} diff --git a/backend/data/alert_darwin_api_test.go b/backend/data/alert_darwin_api_test.go new file mode 100644 index 0000000..159d4b4 --- /dev/null +++ b/backend/data/alert_darwin_api_test.go @@ -0,0 +1,32 @@ +//go:build darwin +// +build darwin + +package data + +import ( + "go-stock/backend/logger" + "testing" + + "github.com/go-toast/toast" +) + +// @Author 2lovecode +// @Date 2025/02/06 17:50 +// @Desc +// ----------------------------------------------------------------------------------- + +func TestAlert(t *testing.T) { + notification := toast.Notification{ + AppID: "go-stock", + Title: "Hello, World!", + Message: "This is a toast notification.", + Icon: "../../build/appicon.png", + Duration: "short", + Audio: toast.Default, + } + err := notification.Push() + if err != nil { + logger.SugaredLogger.Error(err) + return + } +}