1 line
3.3 MiB
Plaintext
1 line
3.3 MiB
Plaintext
{"repo": "arvinlovegood/go-stock", "files": {"README.md": {"76b952311e44ad3544366337e14136f21d0e20ea": {"t": 1785596175.807316, "n": 14331, "h": "c4fb8e6be1b43696", "c": "# go-stock : 基于大语言模型的AI赋能股票分析工具\n## \n\n[](https://github.com/ArvinLovegood/go-stock)\n[](https://gitee.com/arvinlovegood_admin/go-stock)\n\n[//]: # ([](https://gitcode.com/ArvinLovegood/go-stock))\n\n[//]: # (## 💖 赞助商 (Sponsors))\n\n[//]: # (### 🌟[Anspire开放平台](https://open.anspire.cn/?share_code=OMSTE9LW),一Key同时启用全球热门大模型和联网搜索,无需科学上网,含免费额度)\n\n[//]: # ([](https://open.anspire.cn/?share_code=OMSTE9LW))\n\n[//]: # ()\n[//]: # (<table>)\n\n[//]: # (<tr>)\n\n[//]: # (<td width=\"180\">)\n\n[//]: # (<a href=\"https://open.anspire.cn/?share_code=OMSTE9LW\">)\n\n[//]: # (<img src=\"./build/screenshot/anspire-logo.png\" alt=\"Anspire\" width=\"150\">)\n\n[//]: # (</a>)\n\n[//]: # (</td>)\n\n[//]: # (<td>)\n\n[//]: # (感谢 <a href=\"https://open.anspire.cn/?share_code=OMSTE9LW\">Anspire开放平台</a> 赞助了本项目!安思派AI服务平台(Anspire Open)是面向智能体时代的领先的基础设施提供商,现已上线AI联网搜索、AI模型服务、OpenClaw部署、无忧养虾服务。新用户立赠2500点,可体验平台下的任意产品。</td>)\n\n[//]: # (</tr>)\n\n[//]: # (<tr>)\n\n[//]: # (<td width=\"180\">)\n\n[//]: # (<a href=\"https://www.compshare.cn/?ytag=GPU_YY_YX_git_go-stock\">)\n\n[//]: # (<img src=\"./build/screenshot/uclound.png\" alt=\"UCloud\" width=\"150\">)\n\n[//]: # (</a>)\n\n[//]: # (</td>)\n\n[//]: # (<td>)\n\n[//]: # (感谢 <a href=\"https://www.compshare.cn/?ytag=GPU_YY_YX_git_go-stock\">优云智算</a> 赞助了本项目!优云智算是UCloud旗下AI云平台,主打包月、按次的高性价比 国模Agent Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过此链接注册的用户,可得免费5元平台体验金! )\n\n[//]: # (</td>)\n\n[//]: # (</tr>)\n\n[//]: # (</table>)\n\n### 🌟公众号\n\n\n### 📈 交流群\n\n[//]: # (- QQ交流群2:[点击链接加入群聊【go-stock交流群2】:892666282](https://qm.qq.com/q/5mYiy6Yxh0))\n- QQ交流群:[点击链接加入群聊【go-stock交流群】:491605333(定期清理,随缘入群)](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=0YQ8qD3exahsD4YLNhzQTWe5ssstWC89&authKey=usOMMRFtIQDC%2FYcatHYapcxQbJ7PwXPHK9OypTXWzNjAq%2FRVvQu9bj2lRgb%2BSZ3p&noverify=0&group_code=491605333)\n- 硅基流动(siliconflow),注册即送2000万Tokens,[注册链接](https://cloud.siliconflow.cn/i/foufCerk)\n\n\n### ✨ 简介\n- 本项目基于Wails和NaiveUI开发,结合AI大模型构建的股票分析工具。\n- 目前已支持A股,港股,美股,未来计划加入基金,ETF等支持。\n- 支持市场整体/个股情绪分析,K线技术指标分析等功能。\n- 本项目仅供娱乐,不喜勿喷,AI分析股票结果仅供学习研究,投资有风险,请谨慎使用。\n- 开发环境主要基于Windows10+,其他平台未测试或功能受限。\n\n### 📃 使用手册\n[go-stock使用手册](docs/go-stock使用手册.md)\n\n### 📦 立即体验\n[//]: # (- 安装版:[go-stock-amd64-installer.exe](https://github.com/ArvinLovegood/go-stock/releases))\n- 绿色版:[go-stock-windows-amd64.exe](https://github.com/ArvinLovegood/go-stock/releases)\n- MACOS绿色版:[go-stock-darwin-universal](https://github.com/ArvinLovegood/go-stock/releases)\n\n[//]: # (- MACOS安装版:[go-stock-darwin-universal.pkg](https://github.com/ArvinLovegood/go-stock/releases))\n\n\n### 💬 支持大模型/平台\n| 模型 | 状态 | 备注 |\n| --- | --- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| [OpenAI](https://platform.openai.com/) | ✅ | 可接入任何 OpenAI 接口格式模型 |\n| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |\n| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |\n| [AnythingLLM](https://anythingllm.com/) | ✅ | 本地知识库 |\n| [DeepSeek](https://www.deepseek.com/) | ✅ | deepseek-reasoner,deepseek-chat |\n| [大模型聚合平台](https://cloud.siliconflow.cn/i/foufCerk) | ✅ | 如:[硅基流动](https://cloud.siliconflow.cn/i/foufCerk),[火山方舟](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=IJSE43PZ) |\n\n### <span style=\"color: #568DF4;\">各位亲爱的朋友们,如果您对这个项目感兴趣,请先给我一个<i style=\"color: #EA2626;\">star</i>吧,谢谢!</span>💕\n[//]: # (- 优云智算(by UCloud):万卡规模4090免费用10小时,新人注册另增50万tokens,海量热门源项目镜像一键部署,[注册链接](https://www.compshare.cn/image-community?ytag=GPU_YY-gh_gostock))\n- 火山方舟:新用户每个模型注册即送50万tokens,[注册链接](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=IJSE43PZ)\n\n[//]: # (- Tushare大数据开放社区,免费提供各类金融数据,助力行业和量化研究(注意:Tushare只需要120积分即可,注册完成个人资料补充即可得120积分!!!),[注册链接](https://tushare.pro/register?reg=701944))\n- 软件快速迭代开发中,请大家优先测试和使用最新发布的版本。\n- 欢迎大家提出宝贵的建议,欢迎提issue,PR。当然更欢迎[赞助我](#都划到这了如果我的项目对您有帮助请赞助我吧)。💕\n\n\n### 支持开源💕计划\n| 赞助计划\t | 赞助等级\t | 权益说明 |\n|:--------------------------------|----------------|:-------------------------------------------------------|\n| 每月 0 RMB\t | vip0\t | 🌟 全部功能,软件自动更新(从GitHub下载),自行解决github平台网络问题。 |\n| 每月赞助 18.8 RMB<br>每年赞助 120 RMB\t\t | vip1\t | 💕 全部功能,软件自动更新(从CDN下载),更新快速便捷。AI配置指导,提示词参考等 |\n| 每月赞助 28.8 RMB<br>每年赞助 240 RMB\t\t | vip2\t | 💕 vip1全部功能,启动时自动同步最近24小时市场资讯(包括外媒简讯),go-stock Ai助手等 |\n| 每月赞助 X RMB\t\t\t | vipX\t | 🧩 更多计划,视go-stock开源项目发展情况而定...(承接GitHub项目README广告推广💖) |\n\n## 🧩 重大功能开发计划\n| 功能说明 | 状态 | 备注 |\n|-----------------|----|----------------------------------------------------------------------------------------------------------|\n| DeepAgents 模式 | ✅ | 内置任务规划、子 Agent 委派、文件系统与 StreamingShell 集成,Agent 可读取源码运行脚本(2026.08) |\n| 技能管理(Skill) | ✅ | 基于 eino ADK skill 中间件的渐进式提示词注入,支持导入 ZIP 技能包、在线编辑、CodeMirror 6 代码高亮(2026.08) |\n| 对话摘要中间件 | ✅ | 集成 eino ADK summarization,超阈值自动摘要历史,避免上下文溢出(2026.08) |\n| 提示词模板 API 提供给 AI 调用 | ✅ | OpenAI 工具处理器 + Eino ADK 工具包装器,Agent 可查询使用提示词模板(2026.08) |\n| 股票分析知识库 | 🚧 | 未来计划 (研究中心已有体现) |\n| Ai智能选股 | ✅ | Ai智能选股功能(市场行情-》AI总结/AI智能体功能) |\n| ETF支持 | 🚧 | ETF数据支持 (目前可以查看净值和估值) |\n| 美股支持 | ✅ | 美股数据支持 |\n| 港股支持 | ✅ | 港股数据支持 |\n| 多轮对话 | ✅ | AI分析后可继续对话提问 |\n| 自定义AI分析提问模板 | ✅ | 可配置的提问模板 [v2025.2.12.7-alpha](https://github.com/ArvinLovegood/go-stock/releases/tag/v2025.2.12.7-alpha) |\n| 不再强制依赖Chrome浏览器 | ✅ | 默认使用edge浏览器抓取新闻资讯 |\n\n## 👀 更新日志\n### 2026.08.01 DeepAgents 模式重大升级:集成文件系统、StreamingShell 与技能管理\n- 引入文件系统 Backend 与 StreamingShell,Agent 可读取项目源码、运行构建/测试命令\n- 子进程控制台窗口自动隐藏(CREATE_NO_WINDOW),Shell 输出流式返回,便于调试\n- 修复 Shell 执行 4 个 bug:Context 泄漏、死锁、重复 Wait()、缺少执行结果摘要\n### 2026.08.01 新增技能管理(Skill)功能:基于 eino ADK skill 中间件的渐进式提示词注入\n- 技能以文件系统目录形式存储(skills/<技能名>/SKILL.md),支持脚本与参考文档\n- 技能管理界面:导入 ZIP 技能包、在线编辑所有文件、新建/删除文件、删除技能\n- 集成 CodeMirror 6 代码编辑器,支持 Python/YAML/JSON 语法高亮\n- 仅在 DeepAgents 模式下生效,AI 按需发现并加载匹配技能,节省上下文 token\n### 2026.08.01 AI Agent 增强:集成摘要中间件与提示词模板 API\n- 集成 eino ADK summarization 中间件,对话 token 超阈值时自动摘要历史(120k token / 80 消息触发),原始对话转存 logs/agent_transcript.md\n- 提示词模板 API 接口提供给 AI 调用(OpenAI 工具处理器 + Eino ADK 工具包装器),Agent 可查询使用提示词模板\n- 浮动 AI 助手缓存执行参数(模型/系统提示词/用户提示词/思考模式/记忆模式/Agent 模式),避免用户每次重选\n### 2026.05.20 多周期K线图:新增SATS自感知趋势指标+13个TV指标集成\n### 2026.05.15 新增基金K线和基金前十大重仓持仓数据功能\n### 2026.04.12 引入AgentMode类型支持React和PlanExecute两种代理模式\n### 2026.04.11 引入MCP工具调用支持支持\n### 2026.04.04 添加节假日工具支持\n### 2026.04.03 添加节股票异动数据(A股当日)\n### 2026.03.10 新增AI助手功能\n### 2026.02.08 完善AI推荐股票功能和数据查询功能\n### 2026.01.25 添加AI分析报告和ai推荐股票的历史数据查看功能\n### 2025.12.16 新增AI思考模式与热门选股策略功能\n### 2025.11.21 新增带频率权重的情感分析功能\n### 2025.10.30 添加AI智能体功能开关(默认关闭,因为使用体验不理想),移除页面水印\n### 2025.09.27 添加机构/券商的研究报告AI工具函数\n### 2025.08.09 添加AI智能体聊天功能\n### 2025.07.08 实现软件自动更新功能\n### 2025.07.07 卡片添加迷你分时图\n### 2025.07.05 MacOs支持\n### 2025.07.01 AI分析集成工具函数,AI分析将更加智能\n### 2025.06.30 添加指标选股功能\n### 2025.06.27 添加财经日历和重大事件时间轴功能\n### 2025.06.25 添加热门股票、事件和话题功能\n### 2025.06.18 更新内置股票基础数据,软件内实时市场资讯信息提醒,添加行业研究功能\n### 2025.06.15 添加公司公告信息搜索/查看功能\n### 2025.06.15 添加个股研报到弹出菜单\n### 2025.06.13 添加个股研报功能\n### 2025.06.12 添加龙虎榜功能,新增行业排名分类\n### 2025.05.30 优化股票分时图显示\n### 2025.05.20 修复财联社电报获取问题\n### 2025.05.16 优化资金趋势图表组件\n### 2025.05.15 重构应用加载和数据初始化逻辑,添加股票资金趋势功能,资金趋势图表增加主力当日净流入数据并优化展示效果\n### 2025.05.14 添加个股资金流向功能,排行榜增加股票行情K线图弹窗\n### 2025.05.13 添加行业排名功能\n### 2025.05.09 添加A股盘口数据解析和展示功能\n### 2025.05.07 优化分时图的展示\n### 2025.04.29 补全港股/美股基础数据,优化港股股价延迟问题,优化初始化逻辑\n### 2025.04.25 市场资讯支持AI分析和总结:让AI帮你读市场!\n### 2025.04.24 新增市场行情模块:即时掌握全球市场行情资讯/动态,从此再也不用偷摸去各大财经网站啦。go-stock一键帮你搞定!\n### 2025.04.22 优化K线图展示,支持拉伸放大,看得更舒服啦!\n### 2025.04.21 港股,美股K线数据获取优化\n### 2025.04.01 优化部分设置选项,避免重启软件\n### 2025.03.31 优化数据爬取\n### 2025.03.30 AI自动定时分析功能\n### 2025.03.29 多提示词模板管理,AI分析时支持选择不同提示词模板\n### 2025.03.28 AI分析结果保存为markdown文件时,支持保存位置目录选择\n### 2025.03.15 自定义爬虫使用的浏览器路径配置\n### 2025.03.14 优化编译构建,大幅减少编译后的程序文件大小\n### 2025.03.09 基金估值和净值监控查看\n### 2025.03.06 项目社区分享功能\n### 2025.02.28 美股数据支持\n### 2025.02.23 弹幕功能,盯盘不再孤单,无聊划个水!😎\n### 2025.02.22 港股数据支持(目前有延迟)\n\n### 2025.02.16 AI分析后可继续对话提问\n- [v2025.2.16.1-alpha](https://github.com/ArvinLovegood/go-stock/releases/tag/v2025.2.16.1-alpha)\n\n### 2025.02.12 可配置的提问模板\n- [v2025.2.12.7-alpha](https://github.com/ArvinLovegood/go-stock/releases/tag/v2025.2.12.7-alpha)\n\n\n## 🦄 重大更新\n### BIG NEWS !!! 重大更新!!!\n- 2026.03.10 新增AI助手功能\n\n- 2025.11.21 新增带频率权重的情感分析功能\n\n- 2025.04.25 市场资讯支持AI分析和总结:让AI帮你读市场!\n\n- 2025.04.24 新增市场行情模块:即时掌握全球市场行情资讯/动态,从此再也不用偷摸去各大财经网站啦。go-stock一键帮你搞定!\n\n\n- \n- 2025.01.17 新增AI大模型分析股票功能\n \n## 📸 功能截图\n\n### 设置\n\n### 成本设置\n\n### 日K\n\n### 分时\n\n### 钉钉报警通知\n\n### AI分析股票\n\n### 版本信息提示\n\n\n## 💕 感谢以下项目\n- [NaiveUI](https://www.naiveui.com/)\n- [Wails](https://wails.io/)\n- [Vue](https://vuejs.org/)\n- [Vite](https://vitejs.dev/)\n- [Tushare](https://tushare.pro/register?reg=701944)\n\n\n\n## ⭐ Star History\n<a href=\"https://www.star-history.com/?repos=ArvinLovegood%2Fgo-stock&type=date&legend=top-left\">\n <picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/chart?repos=ArvinLovegood/go-stock&type=date&theme=dark&legend=top-left&sealed_token=v0N4_yMF24lv7OpfWCO3O1wo4Uwy0VjIsJ5qwwsIwEF4A56MdI6nHkqhEzkKymHUvmjlD78zvdwTfg2D0JMcQNt-_KyxVlg7QWPCgln7DXSB7yr2lyYp5g\" />\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/chart?repos=ArvinLovegood/go-stock&type=date&legend=top-left&sealed_token=v0N4_yMF24lv7OpfWCO3O1wo4Uwy0VjIsJ5qwwsIwEF4A56MdI6nHkqhEzkKymHUvmjlD78zvdwTfg2D0JMcQNt-_KyxVlg7QWPCgln7DXSB7yr2lyYp5g\" />\n <img alt=\"Star History Chart\" src=\"https://api.star-history.com/chart?repos=ArvinLovegood/go-stock&type=date&legend=top-left&sealed_token=v0N4_yMF24lv7OpfWCO3O1wo4Uwy0VjIsJ5qwwsIwEF4A56MdI6nHkqhEzkKymHUvmjlD78zvdwTfg2D0JMcQNt-_KyxVlg7QWPCgln7DXSB7yr2lyYp5g\" />\n </picture>\n</a>\n\n## 🤖 状态\n\n\n## 🐳 关于技术支持申明\n- 本软件基于开源技术构建,使用Wails、NaiveUI、Vue、AI大模型等开源项目。 技术上如有问题,可以先向对应的开源社区请求帮助。\n- 开源不易,本人精力和时间有限,如需一对一技术支持,请先赞助。联系QQ(备注 技术支持):506808970\n\n[//]: # (<img src=\"./build/wx.jpg\" width=\"301px\" height=\"402px\" alt=\"ArvinLovegood\">)\n\n\n| 技术支持方式 | 赞助(元) | \n|:--------------------------------|:-----:|\n| 加 QQ:506808970 | 100/次 |\n| 长期技术支持(不限次数,新功能优先体验等) | 5000 | \n\n\n\n## License\n[GNU GPLv3](LICENSE)\n\n"}}, "app.go": {"15dfa2d71df025c5ef628ebeea5a5aecf9254884": {"t": 1785746084.0099173, "n": 119285, "h": "4a9614b4f689477e", "c": "package main\n\nimport (\n\t\"archive/zip\"\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"github.com/stretchr/testify/assert/yaml\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 每日操作计划盘中预警监控定时器\n\t\tidPlan, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorDailyOperationPlan(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorDailyOperationPlan error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorDailyOperationPlan\", idPlan)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0 AND is_del = 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 统计始终基于全部历史记录,确保总盈亏与当日盈亏真实准确,不受列表筛选条件影响\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\n// GetIndexTline 获取指数分时数据(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetIndexTline(date string) *data.IndexTlineResult {\n\tres, err := data.NewClsMarketApi().GetIndexTline(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexTline error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\n// GetSectorAnchors 获取板块异动时间点(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetSectorAnchors(date string) []data.SectorAnchor {\n\tres, err := data.NewClsMarketApi().GetSectorAnchors(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetSectorAnchors error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetMarketEmotion() *data.MarketEmotion {\n\tres, err := data.NewClsMarketApi().GetMarketEmotion()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetMarketEmotion error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetIndexQuotes() []data.IndexQuoteItem {\n\tres, err := data.NewClsMarketApi().GetIndexQuotes()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexQuotes error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\n// FilesystemSkillInfo 文件系统技能信息(从 SKILL.md 解析)\ntype FilesystemSkillInfo struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tDirName string `json:\"dirName\"`\n}\n\n// skillsDir 返回文件系统技能目录路径(与 agent.deepAgentRootDir 保持一致)\nfunc skillsDir() string {\n\twd, err := os.Getwd()\n\tif err != nil || wd == \"\" {\n\t\twd = \".\"\n\t}\n\treturn filepath.Join(wd, \"skills\")\n}\n\n// ImportSkillPackage\n//\n//\t@Description: 导入技能包(zip 格式)到本地 skills 目录。用户可从其他网站下载 skill 包后导入。\n//\t@receiver a\n//\t@return string 导入结果消息\nfunc (a *App) ImportSkillPackage() string {\n\tzipPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"选择技能包(ZIP)\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"ZIP 压缩包\", Pattern: \"*.zip\"},\n\t\t},\n\t})\n\tif err != nil || zipPath == \"\" {\n\t\treturn \"未选择文件\"\n\t}\n\n\t// 读取 zip 文件\n\treader, err := zip.OpenReader(zipPath)\n\tif err != nil {\n\t\treturn \"打开 ZIP 文件失败: \" + err.Error()\n\t}\n\tdefer reader.Close()\n\n\t// 验证包含 SKILL.md,并确定技能目录名\n\tvar skillDirName string\n\thasSkillMd := false\n\tfor _, f := range reader.File {\n\t\t// 防止 zip slip 路径穿越\n\t\tif strings.Contains(f.Name, \"..\") {\n\t\t\treturn \"压缩包包含非法路径: \" + f.Name\n\t\t}\n\t\tbase := filepath.Base(f.Name)\n\t\tif base == \"SKILL.md\" && !f.FileInfo().IsDir() {\n\t\t\thasSkillMd = true\n\t\t\t// 如果 SKILL.md 在子目录中,用该子目录名作为技能名\n\t\t\tdir := filepath.Dir(f.Name)\n\t\t\tif dir == \".\" || dir == \"\" {\n\t\t\t\t// SKILL.md 在根目录,用 zip 文件名作为技能名\n\t\t\t\tskillDirName = strings.TrimSuffix(filepath.Base(zipPath), \".zip\")\n\t\t\t} else {\n\t\t\t\t// 取第一级目录名\n\t\t\t\tskillDirName = strings.SplitN(filepath.ToSlash(dir), \"/\", 2)[0]\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\tif !hasSkillMd {\n\t\treturn \"压缩包中未找到 SKILL.md 文件,不是有效的技能包\"\n\t}\n\n\t// 清理技能目录名(去除非法字符)\n\tskillDirName = sanitizeSkillDirName(skillDirName)\n\tif skillDirName == \"\" {\n\t\tskillDirName = \"imported-skill\"\n\t}\n\n\ttargetDir := filepath.Join(skillsDir(), skillDirName)\n\n\t// 如果目录已存在,先删除(覆盖导入)\n\tif _, err := os.Stat(targetDir); err == nil {\n\t\tos.RemoveAll(targetDir)\n\t}\n\tif err := os.MkdirAll(targetDir, 0o755); err != nil {\n\t\treturn \"创建技能目录失败: \" + err.Error()\n\t}\n\n\t// 解压所有文件\n\tconst maxFileSize = 10 * 1024 * 1024 // 单文件 10MB 上限\n\tvar totalSize int64\n\tconst maxTotalSize = 100 * 1024 * 1024 // 总计 100MB 上限\n\tfor _, f := range reader.File {\n\t\tif f.FileInfo().IsDir() {\n\t\t\tfullPath := filepath.Join(targetDir, f.Name)\n\t\t\tos.MkdirAll(fullPath, 0o755)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 限制文件大小\n\t\tif f.UncompressedSize64 > maxFileSize {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"文件过大(超过10MB): \" + f.Name\n\t\t}\n\t\ttotalSize += int64(f.UncompressedSize64)\n\t\tif totalSize > maxTotalSize {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"压缩包总大小超过 100MB 限制\"\n\t\t}\n\n\t\trc, err := f.Open()\n\t\tif err != nil {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"解压失败: \" + err.Error()\n\t\t}\n\n\t\tfullPath := filepath.Join(targetDir, f.Name)\n\t\t// 确保父目录存在\n\t\tos.MkdirAll(filepath.Dir(fullPath), 0o755)\n\n\t\toutFile, err := os.Create(fullPath)\n\t\tif err != nil {\n\t\t\trc.Close()\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"创建文件失败: \" + err.Error()\n\t\t}\n\n\t\t_, err = io.Copy(outFile, rc)\n\t\trc.Close()\n\t\toutFile.Close()\n\t\tif err != nil {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"写入文件失败: \" + err.Error()\n\t\t}\n\t}\n\n\tlogger.SugaredLogger.Infof(\"技能包导入成功: %s -> %s\", skillDirName, targetDir)\n\treturn \"技能 '\" + skillDirName + \"' 导入成功\"\n}\n\n// ListFilesystemSkills\n//\n//\t@Description: 列出本地 skills 目录下的所有文件系统技能\n//\t@receiver a\n//\t@return []FilesystemSkillInfo\nfunc (a *App) ListFilesystemSkills() []FilesystemSkillInfo {\n\tdir := skillsDir()\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []FilesystemSkillInfo{}\n\t}\n\n\tvar result []FilesystemSkillInfo\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tskillMdPath := filepath.Join(dir, entry.Name(), \"SKILL.md\")\n\t\tdata, err := os.ReadFile(skillMdPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tinfo := parseSkillFrontmatter(string(data))\n\t\tinfo.DirName = entry.Name()\n\t\tresult = append(result, info)\n\t}\n\treturn result\n}\n\n// DeleteFilesystemSkill\n//\n//\t@Description: 删除本地 skills 目录下的指定技能\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@return string\nfunc (a *App) DeleteFilesystemSkill(dirName string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\ttarget := filepath.Join(skillsDir(), dirName)\n\tif _, err := os.Stat(target); err != nil {\n\t\treturn \"技能目录不存在: \" + dirName\n\t}\n\tif err := os.RemoveAll(target); err != nil {\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"技能 '\" + dirName + \"' 已删除\"\n}\n\n// SkillFileInfo 技能目录中的文件信息\ntype SkillFileInfo struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir\"`\n\tSize int64 `json:\"size\"`\n\tModTime string `json:\"modTime\"`\n}\n\n// ListSkillFiles\n//\n//\t@Description: 递归列出指定技能目录下的所有文件\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@return []SkillFileInfo\nfunc (a *App) ListSkillFiles(dirName string) []SkillFileInfo {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn []SkillFileInfo{}\n\t}\n\tskillPath := filepath.Join(skillsDir(), dirName)\n\tif _, err := os.Stat(skillPath); err != nil {\n\t\treturn []SkillFileInfo{}\n\t}\n\n\tvar result []SkillFileInfo\n\trelBase := filepath.Join(skillsDir(), dirName)\n\t_ = filepath.Walk(skillPath, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\trelPath, _ := filepath.Rel(relBase, path)\n\t\trelPath = filepath.ToSlash(relPath)\n\t\tif relPath == \".\" {\n\t\t\treturn nil\n\t\t}\n\t\tresult = append(result, SkillFileInfo{\n\t\t\tName: info.Name(),\n\t\t\tPath: relPath,\n\t\t\tIsDir: info.IsDir(),\n\t\t\tSize: info.Size(),\n\t\t\tModTime: info.ModTime().Format(\"2006-01-02 15:04:05\"),\n\t\t})\n\t\treturn nil\n\t})\n\treturn result\n}\n\n// ReadSkillFile\n//\n//\t@Description: 读取技能目录中指定文件的内容\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@return string 文件内容(读取失败返回空字符串)\nfunc (a *App) ReadSkillFile(dirName, filePath string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\t// 校验路径仍在技能目录内\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"\"\n\t}\n\tdata, err := os.ReadFile(fullPath)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(data)\n}\n\n// WriteSkillFile\n//\n//\t@Description: 写入技能目录中指定文件的内容\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@param content 文件内容\n//\t@return string 操作结果\nfunc (a *App) WriteSkillFile(dirName, filePath, content string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\t// 校验路径仍在技能目录内\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"非法文件路径\"\n\t}\n\t// 确保父目录存在\n\tos.MkdirAll(filepath.Dir(fullPath), 0o755)\n\tif err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {\n\t\treturn \"写入失败: \" + err.Error()\n\t}\n\treturn \"保存成功\"\n}\n\n// DeleteSkillFile\n//\n//\t@Description: 删除技能目录中指定文件\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@return string 操作结果\nfunc (a *App) DeleteSkillFile(dirName, filePath string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"非法文件路径\"\n\t}\n\tif err := os.RemoveAll(fullPath); err != nil {\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\n// parseSkillFrontmatter 从 SKILL.md 内容中解析 frontmatter 元数据\nfunc parseSkillFrontmatter(content string) FilesystemSkillInfo {\n\tinfo := FilesystemSkillInfo{}\n\tconst delimiter = \"---\"\n\tcontent = strings.TrimSpace(content)\n\tif !strings.HasPrefix(content, delimiter) {\n\t\treturn info\n\t}\n\trest := content[len(delimiter):]\n\tendIdx := strings.Index(rest, \"\\n\"+delimiter)\n\tif endIdx == -1 {\n\t\treturn info\n\t}\n\tfrontmatter := strings.TrimSpace(rest[:endIdx])\n\n\t// 解析 YAML frontmatter\n\tvar fm struct {\n\t\tName string `yaml:\"name\"`\n\t\tDescription string `yaml:\"description\"`\n\t}\n\tif err := yaml.Unmarshal([]byte(frontmatter), &fm); err == nil {\n\t\tinfo.Name = fm.Name\n\t\tinfo.Description = fm.Description\n\t}\n\treturn info\n}\n\n// sanitizeSkillDirName 清理技能目录名,只保留安全字符\nfunc sanitizeSkillDirName(name string) string {\n\tname = strings.TrimSpace(name)\n\tname = strings.ReplaceAll(name, \"..\", \"\")\n\tname = strings.ReplaceAll(name, \"/\", \"\")\n\tname = strings.ReplaceAll(name, \"\\\\\", \"\")\n\tname = strings.ReplaceAll(name, \":\", \"\")\n\tname = strings.ReplaceAll(name, \";\", \"\")\n\tname = strings.ReplaceAll(name, \"|\", \"\")\n\tname = strings.ReplaceAll(name, \"?\", \"\")\n\tname = strings.ReplaceAll(name, \"*\", \"\")\n\tname = strings.ReplaceAll(name, \"\\\"\", \"\")\n\tname = strings.ReplaceAll(name, \"<\", \"\")\n\tname = strings.ReplaceAll(name, \">\", \"\")\n\treturn strings.TrimSpace(name)\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "297a3f248639fd02250cbf03f054bf294837a813": {"t": 1785759734.8404944, "n": 119285, "h": "4a9614b4f689477e", "c": "package main\n\nimport (\n\t\"archive/zip\"\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"github.com/stretchr/testify/assert/yaml\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 每日操作计划盘中预警监控定时器\n\t\tidPlan, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorDailyOperationPlan(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorDailyOperationPlan error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorDailyOperationPlan\", idPlan)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0 AND is_del = 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 统计始终基于全部历史记录,确保总盈亏与当日盈亏真实准确,不受列表筛选条件影响\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\n// GetIndexTline 获取指数分时数据(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetIndexTline(date string) *data.IndexTlineResult {\n\tres, err := data.NewClsMarketApi().GetIndexTline(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexTline error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\n// GetSectorAnchors 获取板块异动时间点(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetSectorAnchors(date string) []data.SectorAnchor {\n\tres, err := data.NewClsMarketApi().GetSectorAnchors(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetSectorAnchors error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetMarketEmotion() *data.MarketEmotion {\n\tres, err := data.NewClsMarketApi().GetMarketEmotion()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetMarketEmotion error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetIndexQuotes() []data.IndexQuoteItem {\n\tres, err := data.NewClsMarketApi().GetIndexQuotes()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexQuotes error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\n// FilesystemSkillInfo 文件系统技能信息(从 SKILL.md 解析)\ntype FilesystemSkillInfo struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tDirName string `json:\"dirName\"`\n}\n\n// skillsDir 返回文件系统技能目录路径(与 agent.deepAgentRootDir 保持一致)\nfunc skillsDir() string {\n\twd, err := os.Getwd()\n\tif err != nil || wd == \"\" {\n\t\twd = \".\"\n\t}\n\treturn filepath.Join(wd, \"skills\")\n}\n\n// ImportSkillPackage\n//\n//\t@Description: 导入技能包(zip 格式)到本地 skills 目录。用户可从其他网站下载 skill 包后导入。\n//\t@receiver a\n//\t@return string 导入结果消息\nfunc (a *App) ImportSkillPackage() string {\n\tzipPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"选择技能包(ZIP)\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"ZIP 压缩包\", Pattern: \"*.zip\"},\n\t\t},\n\t})\n\tif err != nil || zipPath == \"\" {\n\t\treturn \"未选择文件\"\n\t}\n\n\t// 读取 zip 文件\n\treader, err := zip.OpenReader(zipPath)\n\tif err != nil {\n\t\treturn \"打开 ZIP 文件失败: \" + err.Error()\n\t}\n\tdefer reader.Close()\n\n\t// 验证包含 SKILL.md,并确定技能目录名\n\tvar skillDirName string\n\thasSkillMd := false\n\tfor _, f := range reader.File {\n\t\t// 防止 zip slip 路径穿越\n\t\tif strings.Contains(f.Name, \"..\") {\n\t\t\treturn \"压缩包包含非法路径: \" + f.Name\n\t\t}\n\t\tbase := filepath.Base(f.Name)\n\t\tif base == \"SKILL.md\" && !f.FileInfo().IsDir() {\n\t\t\thasSkillMd = true\n\t\t\t// 如果 SKILL.md 在子目录中,用该子目录名作为技能名\n\t\t\tdir := filepath.Dir(f.Name)\n\t\t\tif dir == \".\" || dir == \"\" {\n\t\t\t\t// SKILL.md 在根目录,用 zip 文件名作为技能名\n\t\t\t\tskillDirName = strings.TrimSuffix(filepath.Base(zipPath), \".zip\")\n\t\t\t} else {\n\t\t\t\t// 取第一级目录名\n\t\t\t\tskillDirName = strings.SplitN(filepath.ToSlash(dir), \"/\", 2)[0]\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\tif !hasSkillMd {\n\t\treturn \"压缩包中未找到 SKILL.md 文件,不是有效的技能包\"\n\t}\n\n\t// 清理技能目录名(去除非法字符)\n\tskillDirName = sanitizeSkillDirName(skillDirName)\n\tif skillDirName == \"\" {\n\t\tskillDirName = \"imported-skill\"\n\t}\n\n\ttargetDir := filepath.Join(skillsDir(), skillDirName)\n\n\t// 如果目录已存在,先删除(覆盖导入)\n\tif _, err := os.Stat(targetDir); err == nil {\n\t\tos.RemoveAll(targetDir)\n\t}\n\tif err := os.MkdirAll(targetDir, 0o755); err != nil {\n\t\treturn \"创建技能目录失败: \" + err.Error()\n\t}\n\n\t// 解压所有文件\n\tconst maxFileSize = 10 * 1024 * 1024 // 单文件 10MB 上限\n\tvar totalSize int64\n\tconst maxTotalSize = 100 * 1024 * 1024 // 总计 100MB 上限\n\tfor _, f := range reader.File {\n\t\tif f.FileInfo().IsDir() {\n\t\t\tfullPath := filepath.Join(targetDir, f.Name)\n\t\t\tos.MkdirAll(fullPath, 0o755)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 限制文件大小\n\t\tif f.UncompressedSize64 > maxFileSize {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"文件过大(超过10MB): \" + f.Name\n\t\t}\n\t\ttotalSize += int64(f.UncompressedSize64)\n\t\tif totalSize > maxTotalSize {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"压缩包总大小超过 100MB 限制\"\n\t\t}\n\n\t\trc, err := f.Open()\n\t\tif err != nil {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"解压失败: \" + err.Error()\n\t\t}\n\n\t\tfullPath := filepath.Join(targetDir, f.Name)\n\t\t// 确保父目录存在\n\t\tos.MkdirAll(filepath.Dir(fullPath), 0o755)\n\n\t\toutFile, err := os.Create(fullPath)\n\t\tif err != nil {\n\t\t\trc.Close()\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"创建文件失败: \" + err.Error()\n\t\t}\n\n\t\t_, err = io.Copy(outFile, rc)\n\t\trc.Close()\n\t\toutFile.Close()\n\t\tif err != nil {\n\t\t\tos.RemoveAll(targetDir)\n\t\t\treturn \"写入文件失败: \" + err.Error()\n\t\t}\n\t}\n\n\tlogger.SugaredLogger.Infof(\"技能包导入成功: %s -> %s\", skillDirName, targetDir)\n\treturn \"技能 '\" + skillDirName + \"' 导入成功\"\n}\n\n// ListFilesystemSkills\n//\n//\t@Description: 列出本地 skills 目录下的所有文件系统技能\n//\t@receiver a\n//\t@return []FilesystemSkillInfo\nfunc (a *App) ListFilesystemSkills() []FilesystemSkillInfo {\n\tdir := skillsDir()\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []FilesystemSkillInfo{}\n\t}\n\n\tvar result []FilesystemSkillInfo\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tskillMdPath := filepath.Join(dir, entry.Name(), \"SKILL.md\")\n\t\tdata, err := os.ReadFile(skillMdPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tinfo := parseSkillFrontmatter(string(data))\n\t\tinfo.DirName = entry.Name()\n\t\tresult = append(result, info)\n\t}\n\treturn result\n}\n\n// DeleteFilesystemSkill\n//\n//\t@Description: 删除本地 skills 目录下的指定技能\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@return string\nfunc (a *App) DeleteFilesystemSkill(dirName string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\ttarget := filepath.Join(skillsDir(), dirName)\n\tif _, err := os.Stat(target); err != nil {\n\t\treturn \"技能目录不存在: \" + dirName\n\t}\n\tif err := os.RemoveAll(target); err != nil {\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"技能 '\" + dirName + \"' 已删除\"\n}\n\n// SkillFileInfo 技能目录中的文件信息\ntype SkillFileInfo struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir\"`\n\tSize int64 `json:\"size\"`\n\tModTime string `json:\"modTime\"`\n}\n\n// ListSkillFiles\n//\n//\t@Description: 递归列出指定技能目录下的所有文件\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@return []SkillFileInfo\nfunc (a *App) ListSkillFiles(dirName string) []SkillFileInfo {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn []SkillFileInfo{}\n\t}\n\tskillPath := filepath.Join(skillsDir(), dirName)\n\tif _, err := os.Stat(skillPath); err != nil {\n\t\treturn []SkillFileInfo{}\n\t}\n\n\tvar result []SkillFileInfo\n\trelBase := filepath.Join(skillsDir(), dirName)\n\t_ = filepath.Walk(skillPath, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\trelPath, _ := filepath.Rel(relBase, path)\n\t\trelPath = filepath.ToSlash(relPath)\n\t\tif relPath == \".\" {\n\t\t\treturn nil\n\t\t}\n\t\tresult = append(result, SkillFileInfo{\n\t\t\tName: info.Name(),\n\t\t\tPath: relPath,\n\t\t\tIsDir: info.IsDir(),\n\t\t\tSize: info.Size(),\n\t\t\tModTime: info.ModTime().Format(\"2006-01-02 15:04:05\"),\n\t\t})\n\t\treturn nil\n\t})\n\treturn result\n}\n\n// ReadSkillFile\n//\n//\t@Description: 读取技能目录中指定文件的内容\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@return string 文件内容(读取失败返回空字符串)\nfunc (a *App) ReadSkillFile(dirName, filePath string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\t// 校验路径仍在技能目录内\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"\"\n\t}\n\tdata, err := os.ReadFile(fullPath)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(data)\n}\n\n// WriteSkillFile\n//\n//\t@Description: 写入技能目录中指定文件的内容\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@param content 文件内容\n//\t@return string 操作结果\nfunc (a *App) WriteSkillFile(dirName, filePath, content string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\t// 校验路径仍在技能目录内\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"非法文件路径\"\n\t}\n\t// 确保父目录存在\n\tos.MkdirAll(filepath.Dir(fullPath), 0o755)\n\tif err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {\n\t\treturn \"写入失败: \" + err.Error()\n\t}\n\treturn \"保存成功\"\n}\n\n// DeleteSkillFile\n//\n//\t@Description: 删除技能目录中指定文件\n//\t@receiver a\n//\t@param dirName 技能目录名\n//\t@param filePath 文件相对路径\n//\t@return string 操作结果\nfunc (a *App) DeleteSkillFile(dirName, filePath string) string {\n\tdirName = sanitizeSkillDirName(dirName)\n\tif dirName == \"\" {\n\t\treturn \"无效的技能目录名\"\n\t}\n\tfilePath = strings.ReplaceAll(filePath, \"..\", \"\")\n\tfilePath = strings.ReplaceAll(filePath, \"\\\\\", \"/\")\n\tfullPath := filepath.Join(skillsDir(), dirName, filePath)\n\tfullPath = filepath.Clean(fullPath)\n\tskillBase := filepath.Join(skillsDir(), dirName)\n\tif !strings.HasPrefix(fullPath, skillBase+string(filepath.Separator)) && fullPath != skillBase {\n\t\treturn \"非法文件路径\"\n\t}\n\tif err := os.RemoveAll(fullPath); err != nil {\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\n// parseSkillFrontmatter 从 SKILL.md 内容中解析 frontmatter 元数据\nfunc parseSkillFrontmatter(content string) FilesystemSkillInfo {\n\tinfo := FilesystemSkillInfo{}\n\tconst delimiter = \"---\"\n\tcontent = strings.TrimSpace(content)\n\tif !strings.HasPrefix(content, delimiter) {\n\t\treturn info\n\t}\n\trest := content[len(delimiter):]\n\tendIdx := strings.Index(rest, \"\\n\"+delimiter)\n\tif endIdx == -1 {\n\t\treturn info\n\t}\n\tfrontmatter := strings.TrimSpace(rest[:endIdx])\n\n\t// 解析 YAML frontmatter\n\tvar fm struct {\n\t\tName string `yaml:\"name\"`\n\t\tDescription string `yaml:\"description\"`\n\t}\n\tif err := yaml.Unmarshal([]byte(frontmatter), &fm); err == nil {\n\t\tinfo.Name = fm.Name\n\t\tinfo.Description = fm.Description\n\t}\n\treturn info\n}\n\n// sanitizeSkillDirName 清理技能目录名,只保留安全字符\nfunc sanitizeSkillDirName(name string) string {\n\tname = strings.TrimSpace(name)\n\tname = strings.ReplaceAll(name, \"..\", \"\")\n\tname = strings.ReplaceAll(name, \"/\", \"\")\n\tname = strings.ReplaceAll(name, \"\\\\\", \"\")\n\tname = strings.ReplaceAll(name, \":\", \"\")\n\tname = strings.ReplaceAll(name, \";\", \"\")\n\tname = strings.ReplaceAll(name, \"|\", \"\")\n\tname = strings.ReplaceAll(name, \"?\", \"\")\n\tname = strings.ReplaceAll(name, \"*\", \"\")\n\tname = strings.ReplaceAll(name, \"\\\"\", \"\")\n\tname = strings.ReplaceAll(name, \"<\", \"\")\n\tname = strings.ReplaceAll(name, \">\", \"\")\n\treturn strings.TrimSpace(name)\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "fc42e149a775a4504814f8bf1f9aa1376a88a4a9": {"t": 1785759737.596036, "n": 109966, "h": "4da70595d8aabb4b", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 每日操作计划盘中预警监控定时器\n\t\tidPlan, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorDailyOperationPlan(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorDailyOperationPlan error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorDailyOperationPlan\", idPlan)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0 AND is_del = 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 统计始终基于全部历史记录,确保总盈亏与当日盈亏真实准确,不受列表筛选条件影响\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\n// GetIndexTline 获取指数分时数据(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetIndexTline(date string) *data.IndexTlineResult {\n\tres, err := data.NewClsMarketApi().GetIndexTline(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexTline error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\n// GetSectorAnchors 获取板块异动时间点(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetSectorAnchors(date string) []data.SectorAnchor {\n\tres, err := data.NewClsMarketApi().GetSectorAnchors(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetSectorAnchors error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetMarketEmotion() *data.MarketEmotion {\n\tres, err := data.NewClsMarketApi().GetMarketEmotion()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetMarketEmotion error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetIndexQuotes() []data.IndexQuoteItem {\n\tres, err := data.NewClsMarketApi().GetIndexQuotes()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexQuotes error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "be8f70035b75ea34d5e4d6dd60c33588f5c72c86": {"t": 1785759740.581717, "n": 109747, "h": "288f4a0734009d96", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 每日操作计划盘中预警监控定时器\n\t\tidPlan, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorDailyOperationPlan(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorDailyOperationPlan error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorDailyOperationPlan\", idPlan)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0 AND is_del = 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 统计始终基于全部历史记录,确保总盈亏与当日盈亏真实准确,不受列表筛选条件影响\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\n// GetIndexTline 获取指数分时数据(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetIndexTline(date string) *data.IndexTlineResult {\n\tres, err := data.NewClsMarketApi().GetIndexTline(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetIndexTline error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\n// GetSectorAnchors 获取板块异动时间点(财联社)\n// date 格式: \"2026-07-22\" 或 \"20260722\",空字符串取当日\nfunc (a *App) GetSectorAnchors(date string) []data.SectorAnchor {\n\tres, err := data.NewClsMarketApi().GetSectorAnchors(date)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetSectorAnchors error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) GetMarketEmotion() *data.MarketEmotion {\n\tres, err := data.NewClsMarketApi().GetMarketEmotion()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetMarketEmotion error: %v\", err)\n\t\treturn nil\n\t}\n\treturn res\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "a6fb80f17cf4fd61e3be742bcd03bd1dc082ecb6": {"t": 1785759763.0350068, "n": 108881, "h": "52666afd5a997a49", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 每日操作计划盘中预警监控定时器\n\t\tidPlan, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorDailyOperationPlan(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorDailyOperationPlan error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorDailyOperationPlan\", idPlan)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 统计始终基于全部历史记录,确保总盈亏与当日盈亏真实准确,不受列表筛选条件影响\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "a17ad56656de239cc27d286291c4d4b77bd47d77": {"t": 1785759764.6974149, "n": 108615, "h": "cb29a01398ab8cca", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\tif news == nil || len(*news) == 0 {\n\t\treturn\n\t}\n\n\t// 配置只需查询一次:循环内重复查 DB 会拖慢推送\n\tonlyPushRed := a.GetConfig().EnableOnlyPushRedNews\n\n\t// 仅在过滤模式下才需要关注列表;空名需过滤掉,否则 strings.Contains(s, \"\")==true 会命中所有新闻\n\tvar stockNames []string\n\tif onlyPushRed {\n\t\tfollows := data.NewStockDataApi().GetFollowList(0)\n\t\tif follows != nil {\n\t\t\tstockNames = slice.FilterMap(*follows, func(index int, item data.FollowedStock) (string, bool) {\n\t\t\t\treturn item.Name, item.Name != \"\"\n\t\t\t})\n\t\t}\n\t}\n\n\tfor _, telegraph := range *news {\n\t\tif onlyPushRed {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// UpdateAiConfigs 仅更新 AI 模型服务配置,供独立的 AI 模型服务管理页面调用\nfunc (a *App) UpdateAiConfigs(aiConfigs []*data.AIConfig) string {\n\treturn data.UpdateAiConfigsOnly(aiConfigs)\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n// 参数:\n// - query: 筛选条件(与列表查询一致),传空对象时返回全部统计\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics(query data.TradingRecordListQuery) *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "1c2781a674c51f49c57d1aa4735d7ce2e57a682d": {"t": 1785759766.8486264, "n": 108081, "h": "ec446202e27ca01f", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\n// GetTdxMinuteTimeData 通过 gotdx 获取当日分时图数据(A股走标准协议,港美股走 MAC MACTickCharts)。\n// 返回分时点列表(时间/价格/均价/成交量)+ 当日行情概览(昨收/今开/最高/最低/收盘/总量/总额)。\nfunc (a *App) GetTdxMinuteTimeData(stockCode string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetMinuteTimeDataAuto(stockCode)\n}\n\n// GetHistoryTdxMinuteTimeData 通过 gotdx 获取历史日期的分时图数据。\n// A 股走标准协议 StockHistoryTickChart(用 buildAShareMinuteTimeSlots 生成时间轴),\n// 港美股走扩展行情 ExTickChart(date>0 时返回历史分时,自带 Time 字段)。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。\nfunc (a *App) GetHistoryTdxMinuteTimeData(stockCode, tradeDate string) *data.TdxMinuteTimeDataBundle {\n\treturn data.NewTdxKLineApi().GetHistoryMinuteTimeDataAuto(stockCode, tradeDate)\n}\n\n// GetTdxTransactionData 通过 gotdx 获取当日分笔成交明细(A股走标准协议,港美股走 MAC MACTransactions)。\n// start 为起始偏移,count 为请求条数(A股最大 500,港美股最大 1000)。\nfunc (a *App) GetTdxTransactionData(stockCode string, start uint32, count uint32) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetTransactionDataAuto(stockCode, start, count)\n}\n\n// GetAllTdxTransactionData 通过 gotdx 循环分页拉取当日全量分笔成交明细。\n// A 股走 StockFullTransaction(内部循环 count=600),港美股走 MAC 循环 count=1000。\n// 返回顺序为「从早到晚」,安全上限 50000 笔。\n// 默认走数据库缓存(5 分钟 TTL),命中缓存直接返回不请求 gotdx。\nfunc (a *App) GetAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, false)\n}\n\n// RefreshAllTdxTransactionData 强制刷新:跳过缓存直接走 gotdx 拉取全量,并刷新缓存。\n// 供前端「刷新」按钮使用,确保拿到最新数据。\nfunc (a *App) RefreshAllTdxTransactionData(stockCode string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetAllTransactionDataAuto(stockCode, true)\n}\n\n// GetHistoryTdxTransactionData 通过 gotdx 获取历史日期的全量分笔成交明细(带买卖方向)。\n// A 股走 StockHistoryFullTransactionWithTrans,港美股走 ExHistoryTransaction。\n// tradeDate 格式 \"YYYY-MM-DD\"(如 \"2026-07-17\")。默认走缓存,5 分钟 TTL。\nfunc (a *App) GetHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, false)\n}\n\n// RefreshHistoryTdxTransactionData 强制刷新历史分笔成交:跳过缓存直接走 gotdx 拉取,并刷新缓存。\nfunc (a *App) RefreshHistoryTdxTransactionData(stockCode, tradeDate string) *[]data.TdxTransactionData {\n\treturn data.NewTdxKLineApi().GetHistoryTransactionDataAuto(stockCode, tradeDate, true)\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "11c2fa09e811ed1337da7ecd4d3385d3477b0f62": {"t": 1785759768.6778073, "n": 105798, "h": "e23818e9dc7ba820", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddConcept(concept data.Concept) string {\n\tok := data.NewStockConceptApi(db.Dao).AddConcept(concept)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) GetConceptList() []data.Concept {\n\treturn data.NewStockConceptApi(db.Dao).GetConceptList()\n}\n\n// UpdateConcept 修改概念名称\nfunc (a *App) UpdateConcept(id int, name string) string {\n\tok := data.NewStockConceptApi(db.Dao).UpdateConcept(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) RemoveConcept(conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveConcept(conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) AddStockConcept(conceptId int, stockCode string) string {\n\tok := data.NewStockConceptApi(db.Dao).AddStockConcept(conceptId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockConcept(code, name string, conceptId int) string {\n\tok := data.NewStockConceptApi(db.Dao).RemoveStockConcept(code, name, conceptId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\n// GetAllStockConcepts 返回全部概念-股票归属记录(含概念信息),供前端「全部」标签页表格渲染概念列。\nfunc (a *App) GetAllStockConcepts() []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetAllStockConcepts()\n}\n\nfunc (a *App) GetStockConceptsByStockCode(stockCode string) []data.ConceptStock {\n\treturn data.NewStockConceptApi(db.Dao).GetStockConceptsByStockCode(stockCode)\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "62858141a6167b4a537ef22d196509f5f320885f": {"t": 1785759770.3541963, "n": 104384, "h": "a5b770c5b096f8cc", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n\tfeishuBotMu sync.Mutex\n\tfeishuBot *agent.FeishuBot\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\n\t// 启动飞书应用机器人(如已启用)\n\tif config != nil && config.FeishuBotEnable {\n\t\tgo func() {\n\t\t\tdefer PanicHandler()\n\t\t\tif err := a.startFeishuBot(); err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"auto start feishu bot failed: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 停止飞书应用机器人长连接\n\ta.stopFeishuBotInternal()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// StartFeishuBot 启动飞书应用机器人(前端按钮触发)\n// 与 FeishuPush 自定义机器人推送完全独立,使用长连接接收消息并由 AI 回复\nfunc (a *App) StartFeishuBot() string {\n\tdefer PanicHandler()\n\tif err := a.startFeishuBot(); err != nil {\n\t\treturn \"启动失败:\" + err.Error()\n\t}\n\treturn \"飞书应用机器人已启动\"\n}\n\n// startFeishuBot 内部启动方法,返回 error 便于 domReady 调用\nfunc (a *App) startFeishuBot() error {\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot != nil && a.feishuBot.IsRunning() {\n\t\treturn fmt.Errorf(\"飞书应用机器人已在运行中\")\n\t}\n\n\tbot := agent.NewFeishuBot()\n\tif bot == nil {\n\t\treturn fmt.Errorf(\"请先在设置中填写飞书 App ID、App Secret,并选择 AI 配置\")\n\t}\n\n\tctx := context.Background()\n\tif a.ctx != nil {\n\t\tctx = a.ctx\n\t}\n\ta.feishuBot = bot\n\n\tgo func() {\n\t\tdefer PanicHandler()\n\t\tif err := bot.Start(ctx); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"feishu bot start error: %v\", err)\n\t\t}\n\t}()\n\n\tlogger.SugaredLogger.Infof(\"feishu bot started\")\n\treturn nil\n}\n\n// StopFeishuBot 停止飞书应用机器人\nfunc (a *App) StopFeishuBot() string {\n\tdefer PanicHandler()\n\ta.stopFeishuBotInternal()\n\treturn \"飞书应用机器人已停止\"\n}\n\n// stopFeishuBotInternal 内部停止方法(不加 Wails 锁,可被 domReady/shutdown 复用)\nfunc (a *App) stopFeishuBotInternal() {\n\ta.feishuBotMu.Lock()\n\tbot := a.feishuBot\n\ta.feishuBot = nil\n\ta.feishuBotMu.Unlock()\n\n\tif bot != nil {\n\t\tbot.Stop()\n\t\tlogger.SugaredLogger.Infof(\"feishu bot stopped\")\n\t}\n}\n\n// GetFeishuBotStatus 查询飞书应用机器人运行状态\nfunc (a *App) GetFeishuBotStatus() string {\n\tdefer PanicHandler()\n\ta.feishuBotMu.Lock()\n\tdefer a.feishuBotMu.Unlock()\n\n\tif a.feishuBot == nil {\n\t\treturn \"stopped\"\n\t}\n\tif a.feishuBot.IsRunning() {\n\t\treturn \"running\"\n\t}\n\treturn \"stopped\"\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "558169c85b51109859bf21a1fad76c2cea811566": {"t": 1785759772.4616008, "n": 102443, "h": "6aa561599ca71fa4", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo data.NewFeishuAPI().SendToFeishu(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendFeishuMessage 发送飞书自定义机器人消息(带 5 分钟去重缓存)\nfunc (a *App) SendFeishuMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\n// SendFeishuMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendFeishuMessageByType(message string, stockCode string, msgType int) string {\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewFeishuAPI().SendFeishuMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "38a439d4b635dbbe79be28ed235d3a3bf2475157": {"t": 1785759775.5208292, "n": 100562, "h": "2d75eb396112ec81", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\n// GetAllGroupStocks 返回全部分组-股票归属记录(含分组信息),供前端「全部」标签页表格渲染分组列。\nfunc (a *App) GetAllGroupStocks() []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetAllGroupStocks()\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "fd63415341f948385b98202351b90e0769ff36db": {"t": 1785759777.4021096, "n": 100386, "h": "4a4552b280e61917", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\", adjustFlag)\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\n// adjustFlag 控制复权类型:\"qfq\"前复权、\"hfq\"后复权、\"none\"/\"0\"不复权、\"\"沿用各数据源默认行为。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string, adjustFlag string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end, adjustFlag)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "79bafcf6aa42ad8cbe3f68427476ce73e5898272": {"t": 1785759779.2509983, "n": 100190, "h": "af28a9ec9d46c60b", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\n// PromptPlazaRequest 以 Go 后端代理的方式请求提示词广场 API,\n// 规避 macOS WKWebView 的 App Transport Security 对明文 HTTP 的限制,\n// 前端不应直接 fetch 远程广场接口。\n// method: GET/POST/PUT/DELETE\n// apiBase: 广场 API 根地址,如 http://go-stock.sparkmemory.top:1918/api\n// path: 接口路径,如 /auth/register\n// query: URL 查询参数,可为 nil;nil 值与空字符串会被跳过,与前端原 fetch 行为一致\n// body: 请求体 JSON 字符串,可为空\n// token: 鉴权 token,可为空\n// 返回响应体解析后的 map(含 code/message/data),网络或解析失败时 code != 0。\nfunc (a *App) PromptPlazaRequest(method, apiBase, path string, query map[string]any, body, token string) map[string]any {\n\tresult := map[string]any{\"code\": -1, \"message\": \"\", \"data\": nil}\n\tif apiBase == \"\" {\n\t\tresult[\"message\"] = \"apiBase 为空\"\n\t\treturn result\n\t}\n\turl := strings.TrimRight(apiBase, \"/\") + path\n\treq := data.SharedHTTPClient.R().SetHeader(\"Content-Type\", \"application/json\")\n\tif token != \"\" {\n\t\treq = req.SetHeader(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif len(query) > 0 {\n\t\tparams := make(map[string]string, len(query))\n\t\tfor k, v := range query {\n\t\t\tif v == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts := fmt.Sprintf(\"%v\", v)\n\t\t\tif s == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparams[k] = s\n\t\t}\n\t\tif len(params) > 0 {\n\t\t\treq = req.SetQueryParams(params)\n\t\t}\n\t}\n\tif body != \"\" {\n\t\treq = req.SetBody(body)\n\t}\n\n\tresp, err := req.Execute(strings.ToUpper(method), url)\n\tif err != nil {\n\t\tresult[\"message\"] = err.Error()\n\t\treturn result\n\t}\n\n\tvar respData map[string]any\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\tresult[\"message\"] = \"响应解析失败: \" + err.Error()\n\t\treturn result\n\t}\n\tif respData == nil {\n\t\trespData = map[string]any{}\n\t}\n\tif _, ok := respData[\"code\"]; !ok {\n\t\trespData[\"code\"] = -1\n\t}\n\treturn respData\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "f933d58cf440e5d675a6d9b882039181949eecda": {"t": 1785759780.9927487, "n": 98546, "h": "88c3d594d22b88c5", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\t// 全量覆盖完成后,用通达信即时数据对 A 股做增量校准(新股上市当天即可见)\n\tgo a.syncStockBasicFromTdx()\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t// 港股/美股全量覆盖完成后,用通达信扩展行情即时数据做增量校准\n\tgo a.syncHKUSStockBasicFromTdx()\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\n\n// syncStockBasicFromTdx 用通达信即时数据对 A 股基础信息做增量校准。\n// 在 CheckStockBaseInfo 全量覆盖之后调用:通达信本地证券列表新股上市当天即可见,\n// 以 upsert 方式补充全量 JSON 未及时覆盖的新上市/改名/退市股票。\nfunc (a *App) syncStockBasicFromTdx() {\n\tdefer PanicHandler()\n\tadded, updated, err := data.NewTdxKLineApi().SyncStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步股票基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步股票基础信息完成:新增 %d 条,更新 %d 条\", added, updated)\n}\n\n// syncHKUSStockBasicFromTdx 用通达信扩展行情即时数据对港股/美股基础信息做增量校准。\nfunc (a *App) syncHKUSStockBasicFromTdx() {\n\tdefer PanicHandler()\n\thkAdded, hkUpdated, usAdded, usUpdated, err := data.NewTdxKLineApi().SyncHKUSStockBasicToDB()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Warnf(\"通达信同步港美股基础信息失败:%s\", err.Error())\n\t\treturn\n\t}\n\tlogger.SugaredLogger.Infof(\"通达信同步港美股基础信息完成:港股新增 %d 更新 %d,美股新增 %d 更新 %d\",\n\t\thkAdded, hkUpdated, usAdded, usUpdated)\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\n// UpdateGroup 修改分组名称\nfunc (a *App) UpdateGroup(id int, name string) string {\n\tok := data.NewStockGroupApi(db.Dao).UpdateGroup(id, name)\n\tif ok {\n\t\treturn \"修改成功\"\n\t}\n\treturn \"修改失败\"\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\t// 港股优先使用 gotdx (通达信 ExKLine2) 获取日K线,失败再降级到腾讯接口\n\tif data.IsHKStockCode(stockCode) {\n\t\ttdxData := data.NewTdxKLineApi().GetMACKLineData(stockCode, \"101\", int(days))\n\t\tif tdxData != nil && len(*tdxData) > 0 {\n\t\t\treturn tdxData\n\t\t}\n\t}\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "9eb3fc71c6aa156fdb5f2cd975e96971411cfcec": {"t": 1785759783.292251, "n": 97114, "h": "cf5af0155b5388a6", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, err := time.LoadLocation(\"Asia/Shanghai\")\n\tif err != nil {\n\t\tloc = ShanghaiTimezone\n\t}\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "94d976aa982cf45ff774302c5bfcc9ad032bd038": {"t": 1785759785.498296, "n": 97067, "h": "795fb3be6256ba51", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\n// GetTdxSymbolBelongBoard 通过通达信 MAC 接口获取股票所属板块信息\nfunc (a *App) GetTdxSymbolBelongBoard(stockCode string) *[]data.MACBelongBoardItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetMACSymbolBelongBoard(stockCode)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "be0d40086cb94b76f931f1e4a0bb37f5b652d6d9": {"t": 1785759787.6701522, "n": 96852, "h": "6b2c04741b21275e", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t// 概念资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\tidConceptFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewConceptFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc ConceptFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"ConceptFundFlowFetchAndSave\", idConceptFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "ef9797bf9b84d91a619650b78b3b4f25ca295674": {"t": 1785759790.371926, "n": 96421, "h": "ef18c8caf382f12b", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "b34cf66cdb87019d58fca04cd504fbe599000219": {"t": 1785759792.1214495, "n": 96838, "h": "0602e107c2faf599", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t// 板块资金流向数据采集(交易日每60秒)\n\tgo func() {\n\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\tidBKFundFlow, err := a.cron.AddFunc(\"@every 60s\", func() {\n\t\t\tif a.IsTradingTime() {\n\t\t\t\tdata.NewBKFundFlowApi().FetchAndSave()\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc BKFundFlowFetchAndSave error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"BKFundFlowFetchAndSave\", idBKFundFlow)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// GetBKFundFlowListByDate 获取某个板块指定日期的资金流向历史数据\nfunc (a *App) GetBKFundFlowListByDate(code string, date string) []models.BKFundFlowPoint {\n\treturn data.NewBKFundFlowApi().GetBKFundFlowListByDate(code, date)\n}\n\n// GetBKFundFlowTopListByDate 获取指定日期最新快照的板块资金排名\nfunc (a *App) GetBKFundFlowTopListByDate(date string, topN int) []models.BKFundFlow {\n\treturn data.NewBKFundFlowApi().GetBKFundFlowTopListByDate(date, topN)\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "54816df6bed4b82aefe5fc361b0f625e06f4bd88": {"t": 1785759794.4889615, "n": 96020, "h": "d9f66b7c28e27c92", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"NewChatStream panic: %v\", err)\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"content\": fmt.Sprintf(\"AI分析异常: %v\", err),\n\t\t\t})\n\t\t\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n\t\t}\n\t}()\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "1c65f2fab118ad7b67dbdc16b66e59262bf52e90": {"t": 1785759796.426737, "n": 95709, "h": "aad37981123738ac", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\tclsURL := \"https://www.cls.cn/api/cache?app=CailianpressWeb&name=telegraph&os=web&sv=8.7.9\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\").\n\t\tGet(clsURL)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tres := map[string]any{}\n\tif err := json.Unmarshal(response.Body(), &res); err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tif v, _ := convertor.ToInt(res[\"errno\"]); v == 0 {\n\t\tif res[\"data\"] == nil {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tdataMap, ok := res[\"data\"].(map[string]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\trollData, ok := dataMap[\"roll_data\"].([]any)\n\t\tif !ok {\n\t\t\treturn &[]string{}\n\t\t}\n\t\tfor _, v := range rollData {\n\t\t\tnews, ok := v.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent, _ := news[\"content\"].(string)\n\t\t\tif content != \"\" {\n\t\t\t\ttelegraph = append(telegraph, content)\n\t\t\t}\n\t\t}\n\t}\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "3704b8f17e2fc3678b2dbc5709faaa54436be210": {"t": 1785759798.5149088, "n": 95475, "h": "46bc5f2c5f088438", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\tgithubApiHeaders := map[string]string{\n\t\t\"Accept\": \"application/vnd.github+json\",\n\t\t\"X-GitHub-Api-Version\": \"2022-11-28\",\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tif updateChannel == \"release\" {\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\tresp, err := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases failed, status:%d\", resp.StatusCode())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\ttagResp, tagErr := data.SharedHTTPClient.R().\n\t\t\tSetHeaders(githubApiHeaders).\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif tagErr == nil && tagResp.StatusCode() == 200 && tag.Object.Url != \"\" {\n\t\t\treleaseVersion.Tag = *tag\n\t\t\tcommit := &models.Commit{}\n\t\t\tcommitResp, commitErr := data.SharedHTTPClient.R().\n\t\t\t\tSetHeaders(githubApiHeaders).\n\t\t\t\tSetResult(commit).\n\t\t\t\tGet(tag.Object.Url)\n\t\t\tif commitErr == nil && commitResp.StatusCode() == 200 {\n\t\t\t\treleaseVersion.Commit = *commit\n\t\t\t}\n\t\t}\n\n\t\tcommitMessage := releaseVersion.Body\n\t\tif releaseVersion.Commit.Message != \"\" {\n\t\t\tcommitMessage = releaseVersion.Commit.Message\n\t\t}\n\n\t\tdownloadUrl := \"\"\n\t\tassetName := \"\"\n\t\tif IsWindows() {\n\t\t\tif IsArm64() {\n\t\t\t\tassetName = \"go-stock-windows-arm64.exe\"\n\t\t\t} else {\n\t\t\t\tassetName = \"go-stock-windows-amd64.exe\"\n\t\t\t}\n\t\t} else if IsMacOS() {\n\t\t\tassetName = \"go-stock-darwin-universal\"\n\t\t} else if IsLinux() {\n\t\t\tassetName = \"go-stock-linux-amd64\"\n\t\t}\n\n\t\tfor _, asset := range releaseVersion.Assets {\n\t\t\tif asset.Name == assetName {\n\t\t\t\tdownloadUrl = asset.BrowserDownloadUrl\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif downloadUrl == \"\" {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, assetName)\n\t\t}\n\n\t\toriginalDownloadUrl := downloadUrl\n\t\tdownloadUrl, _, _ = a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tmirrorDownloadUrl := \"https://gh.927223.xyz/\" + originalDownloadUrl\n\t\tmanualDownloadTip := fmt.Sprintf(\"\\n手动下载链接(加速镜像): %s\\n手动下载链接(原始地址): %s\\n下载后请替换当前程序文件即可完成更新。\", mirrorDownloadUrl, originalDownloadUrl)\n\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": commitMessage + \"\\n正在下载新版本,请耐心等待...\",\n\t\t})\n\n\t\ttmpFile, err := os.CreateTemp(\"\", \"go-stock-update-*.tmp\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"create temp file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法创建临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\ttmpPath := tmpFile.Name()\n\t\ttmpFile.Close()\n\t\tdefer os.Remove(tmpPath)\n\n\t\tdownloadClient := data.CreateDownloadClient()\n\n\t\tdownloadUrls := []string{mirrorDownloadUrl, downloadUrl}\n\t\tvar downloadSuccess bool\n\t\tfor _, url := range downloadUrls {\n\t\t\t_, err = downloadClient.R().\n\t\t\t\tSetHeader(\"User-Agent\", \"go-stock-updater\").\n\t\t\t\tSetOutput(tmpPath).\n\t\t\t\tGet(url)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s error: %s, trying next...\", url, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfileInfo, statErr := os.Stat(tmpPath)\n\t\t\tif statErr != nil || fileInfo.Size() < 1024*500 {\n\t\t\t\tlogger.SugaredLogger.Warnf(\"download from %s file size invalid, trying next...\", url)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdownloadSuccess = true\n\t\t\tbreak\n\t\t}\n\n\t\tif !downloadSuccess {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本自动下载失败,请手动下载更新。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tbody, err := os.ReadFile(tmpPath)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"read downloaded file error: %s\", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commitMessage + \"\\n新版本下载失败(无法读取临时文件)。\" + manualDownloadTip,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tif !IsRunningAsAdmin() {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateNeedAdmin\", map[string]any{\n\t\t\t\t\t\"version\": releaseVersion.TagName,\n\t\t\t\t\t\"message\": commitMessage,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\t}\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\twinAssetName := \"go-stock-windows-amd64.exe\"\n\t\t\tif IsArm64() {\n\t\t\t\twinAssetName = \"go-stock-windows-arm64.exe\"\n\t\t\t}\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/%s\", releaseVersion.TagName, winAssetName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gh.927223.xyz/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "f3a89bc69db3e29eaf1038be218b3168e5e8f89b": {"t": 1785759820.3683674, "n": 93008, "h": "aa0e87123503ee6c", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := data.SharedHTTPClient.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.R().SetHeader(\"ua-x\", \"go-stock\").SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil || res == nil {\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\nfunc (a *App) SearchFundCodes(keyword string) []data.FundSearchItem {\n\treturn data.NewFundApi().SearchFundCodes(keyword)\n}\nfunc (a *App) GetFollowedFundPaged(pageIndex, pageSize int, keyword string) *data.FollowedFundPagedResult {\n\treturn data.NewFundApi().GetFollowedFundPaged(pageIndex, pageSize, keyword)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\n\tresp, err := client.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\n\t\tresp, err := client.R().\n\t\t\tSetHeader(\"Authorization\", \"Bearer \"+apiKey).\n\t\t\tSetHeader(\"Content-Type\", \"application/json\").\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "751430d8a1ccdbbc325751745e4a193c139a5b1d": {"t": 1785759821.9350526, "n": 92976, "h": "e553f1b98603e72b", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\tmachineid \"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"msg\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\n\nfunc (a *App) GetFundRanking(marketType, fundType, sortField, sortOrder string, pageIndex, pageSize int) *data.FundRankingResult {\n\tres, err := data.NewFundApi().GetFundRanking(marketType, fundType, sortField, sortOrder, pageIndex, pageSize)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"GetFundRanking error: %v\", err)\n\t\treturn &data.FundRankingResult{}\n\t}\n\treturn res\n}\n\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "2a890399dcca1cf5e8359787eb19a853284dfc95": {"t": 1785759823.8302271, "n": 92600, "h": "32faea8bf3e4270d", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\tmachineid \"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"msg\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "193ac41509a2656f98249cc9280132d7a163a597": {"t": 1785759826.1311078, "n": 92599, "h": "caa640bf22644075", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\tmachineid \"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"msg\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) GetFundKLine(fundCode string, klt string, limit int) *data.KLineSourceResult {\n\treturn data.NewFundKLineApi().GetFundKLineWithFallback(fundCode, klt, limit)\n}\nfunc (a *App) GetFundHistoryNetValue(fundCode string, pageSize int, startDate string, endDate string) []data.FundHistoryNetValue {\n\tres, _ := data.NewFundApi().GetFundHistoryNetValue(fundCode, 1, pageSize, startDate, endDate)\n\tif res == nil {\n\t\treturn []data.FundHistoryNetValue{}\n\t}\n\treturn res\n}\nfunc (a *App) GetFundTop10Holdings(fundCode string) []data.FundHoldingStock {\n\tres, err := data.NewFundApi().GetFundTop10Holdings(fundCode)\n\tif err != nil || res == nil {\n\t\treturn []data.FundHoldingStock{}\n\t}\n\treturn res\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "3cb07488de9df227bae1d2694bb50fe1627b5c77": {"t": 1785759828.2384965, "n": 91904, "h": "70e52a8d23dafde5", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\tmachineid \"go-stock/backend/machineid\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\nfunc (a *App) GetMachineId() string {\n\treturn machineid.GetMachineId()\n}\n\nfunc (a *App) CheckDeviceBinding(token string, apiBase string) map[string]any {\n\tuuid := machineid.GetMachineId()\n\tresult := map[string]any{\n\t\t\"bound\": false,\n\t\t\"deviceCount\": 0,\n\t\t\"maxDevices\": 5,\n\t}\n\n\tif token == \"\" || apiBase == \"\" {\n\t\treturn result\n\t}\n\n\turl := fmt.Sprintf(\"%s/user/device-check?uuid=%s\", apiBase, uuid)\n\tresp, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Authorization\", \"Bearer \"+token).\n\t\tGet(url)\n\tif err != nil {\n\t\treturn result\n\t}\n\n\tvar respData struct {\n\t\tCode int `json:\"code\"`\n\t\tData struct {\n\t\t\tBound bool `json:\"bound\"`\n\t\t\tDeviceCount int `json:\"deviceCount\"`\n\t\t\tMaxDevices int `json:\"maxDevices\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(resp.Body(), &respData); err != nil {\n\t\treturn result\n\t}\n\tif respData.Code != 0 {\n\t\treturn result\n\t}\n\n\tresult[\"bound\"] = respData.Data.Bound\n\tresult[\"deviceCount\"] = respData.Data.DeviceCount\n\tresult[\"maxDevices\"] = respData.Data.MaxDevices\n\treturn result\n}\n\nfunc (a *App) QuitApp() {\n\tif a.ctx != nil {\n\t\tif a.cron != nil {\n\t\t\ta.cron.Stop()\n\t\t}\n\t\truntime.Quit(a.ctx)\n\t}\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "a60dd52185b88dcc03e30fa3cf346fd47f2c154e": {"t": 1785759830.093754, "n": 90727, "h": "0cc011e41eb1c84c", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tthinking := data.GetSettingConfig().GetAIConfigThinking(follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, thinking)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}, "e03c9c84ef50ae023382ff417fb1a348fdd2f3a9": {"t": 1785759831.9608967, "n": 90646, "h": "ce84c4d5e524994f", "c": "package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"go-stock/backend/agent\"\n\t\"go-stock/backend/agent/tools\"\n\t\"go-stock/backend/data\"\n\t\"go-stock/backend/db\"\n\t\"go-stock/backend/logger\"\n\t\"go-stock/backend/models\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/duke-git/lancet/v2/cryptor\"\n\t\"github.com/inconshreveable/go-update\"\n\t\"github.com/samber/lo\"\n\t\"golang.org/x/exp/slices\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/coocood/freecache\"\n\t\"github.com/duke-git/lancet/v2/convertor\"\n\t\"github.com/duke-git/lancet/v2/mathutil\"\n\t\"github.com/duke-git/lancet/v2/slice\"\n\t\"github.com/duke-git/lancet/v2/strutil\"\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n\tcache *freecache.Cache\n\tcron *cron.Cron\n\tcronEntrys map[string]cron.EntryID\n\tcronEntrysMu sync.Mutex\n\tAiTools []data.Tool\n\tSponsorInfo map[string]any\n\tVipLevel int64\n\tsummaryMu sync.Mutex\n\tsummaryCancel context.CancelFunc\n\tagentMu sync.Mutex\n\tagentCancel context.CancelFunc\n\tstockAlertMu sync.Mutex\n\tstockAlertLastSent map[string]time.Time\n\tpriceAtAlertReset map[string]float64\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\tcacheSize := 512 * 1024\n\tcache := freecache.NewCache(cacheSize)\n\tc := cron.New(cron.WithSeconds(), cron.WithChain(cron.Recover(cron.DefaultLogger)))\n\tc.Start()\n\tvar tools []data.Tool\n\ttools = data.Tools(tools)\n\treturn &App{\n\t\tcache: cache,\n\t\tcron: c,\n\t\tcronEntrys: make(map[string]cron.EntryID),\n\t\tAiTools: tools,\n\t\tstockAlertLastSent: make(map[string]time.Time),\n\t\tpriceAtAlertReset: make(map[string]float64),\n\t}\n}\n\nfunc (a *App) setCronEntry(key string, id cron.EntryID) {\n\ta.cronEntrysMu.Lock()\n\ta.cronEntrys[key] = id\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) getCronEntry(key string) (cron.EntryID, bool) {\n\ta.cronEntrysMu.Lock()\n\tid, exists := a.cronEntrys[key]\n\ta.cronEntrysMu.Unlock()\n\treturn id, exists\n}\n\nfunc (a *App) removeCronEntry(key string) {\n\ta.cronEntrysMu.Lock()\n\tdelete(a.cronEntrys, key)\n\ta.cronEntrysMu.Unlock()\n}\n\nfunc (a *App) GetSponsorInfo() map[string]any {\n\treturn a.SponsorInfo\n}\n\n// GetEffectiveSponsorVip 从本地配置解密赞助信息并判断当前是否在 VIP 有效期内(与 ai-assistant-web / data.EffectiveSponsorVipLevel 一致)。\nfunc (a *App) GetEffectiveSponsorVip() map[string]any {\n\tlevel, active := data.EffectiveSponsorVipLevel()\n\treturn map[string]any{\n\t\t\"vipLevel\": level,\n\t\t\"active\": active,\n\t}\n}\nfunc (a *App) CheckSponsorCode(sponsorCode string) map[string]any {\n\tsponsorCode = strutil.Trim(sponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码格式错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"版本错误,不支持赞助码!\",\n\t\t\t}\n\t\t}\n\t\tdecrypt := cryptor.AesEcbDecrypt(encrypted, key)\n\t\tif decrypt == nil || len(decrypt) == 0 {\n\t\t\treturn map[string]any{\n\t\t\t\t\"code\": 0,\n\t\t\t\t\"msg\": \"赞助码错误,请输入正确的赞助码!\",\n\t\t\t}\n\t\t}\n\n\t\t// 校验通过后,将赞助码持久化到 Settings 中\n\t\tconfig := data.GetSettingConfig()\n\t\t// 只在赞助码变更时写库,避免无谓更新\n\t\tif config.SponsorCode != sponsorCode {\n\t\t\tconfig.SponsorCode = sponsorCode\n\t\t\tdata.UpdateConfig(config)\n\t\t}\n\n\t\treturn map[string]any{\n\t\t\t\"code\": 1,\n\t\t\t\"msg\": \"赞助码校验成功,感谢您的支持!\",\n\t\t}\n\t} else {\n\t\treturn map[string]any{\"code\": 0, \"message\": \"赞助码不能为空,请输入正确的赞助码!\"}\n\t}\n}\n\nfunc (a *App) CheckUpdate(flag int) {\n\tsponsorCode := strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn\n\t\t}\n\t}\n\n\tupdateChannel := a.GetConfig().UpdateChannel\n\tif updateChannel == \"\" {\n\t\tupdateChannel = \"release\"\n\t}\n\n\treleaseVersion := &models.GitHubReleaseVersion{}\n\tvar err error\n\tif updateChannel == \"release\" {\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(releaseVersion).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github release version error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar releases []models.GitHubReleaseVersion\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(&releases).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/releases\")\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"get github releases error:%s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif len(releases) == 0 {\n\t\t\tlogger.SugaredLogger.Errorf(\"no releases found\")\n\t\t\treturn\n\t\t}\n\t\tif updateChannel == \"pre\" {\n\t\t\tfor _, r := range releases {\n\t\t\t\tif !r.Draft {\n\t\t\t\t\treleaseVersion = &r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif releaseVersion.TagName == \"\" {\n\t\t\t\treleaseVersion = &releases[0]\n\t\t\t}\n\t\t} else {\n\t\t\treleaseVersion = &releases[0]\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"releaseVersion:%+v\", releaseVersion.TagName)\n\n\tif _, vipLevel, ok := a.isVip(sponsorCode, \"\", releaseVersion); ok {\n\t\tlevel, _ := convertor.ToInt(vipLevel)\n\t\ta.VipLevel = level\n\t\tif level >= 2 {\n\t\t\tgo a.syncNews()\n\t\t}\n\t}\n\n\tif releaseVersion.TagName != Version {\n\t\ttag := &models.Tag{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(tag).\n\t\t\tGet(\"https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/\" + releaseVersion.TagName)\n\t\tif err == nil {\n\t\t\treleaseVersion.Tag = *tag\n\t\t}\n\n\t\tcommit := &models.Commit{}\n\t\t_, err = data.SharedHTTPClient.R().\n\t\t\tSetResult(commit).\n\t\t\tGet(tag.Object.Url)\n\t\tif err == nil {\n\t\t\treleaseVersion.Commit = *commit\n\t\t}\n\n\t\t// 构建下载链接\n\t\tdownloadUrl := fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\tif IsMacOS() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t} else if IsLinux() {\n\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t}\n\t\tdownloadUrl, _, done := a.isVip(sponsorCode, downloadUrl, releaseVersion)\n\t\tif !done {\n\t\t\treturn\n\t\t}\n\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\"time\": \"发现新版本:\" + releaseVersion.TagName,\n\t\t\t\"isRed\": true,\n\t\t\t\"source\": \"go-stock\",\n\t\t\t\"content\": fmt.Sprintf(\"%s\", commit.Message),\n\t\t})\n\t\tresp, err := data.SharedHTTPClient.R().Get(downloadUrl)\n\t\tif err != nil {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tbody := resp.Body()\n\n\t\tif len(body) < 1024*500 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": commit.Message + \"\\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\terr = update.Apply(bytes.NewReader(body), update.Options{})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(\"更新失败: \", err.Error())\n\t\t\tgo runtime.EventsEmit(a.ctx, \"updateVersion\", releaseVersion)\n\t\t\treturn\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"新版本:\" + releaseVersion.TagName,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"版本更新完成,下次重启软件生效.\",\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif flag == 1 {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\"time\": \"当前版本:\" + Version,\n\t\t\t\t\"isRed\": true,\n\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\"content\": \"当前版本无更新\",\n\t\t\t})\n\t\t}\n\n\t}\n}\n\nfunc (a *App) isVip(sponsorCode string, downloadUrl string, releaseVersion *models.GitHubReleaseVersion) (string, string, bool) {\n\tisVip := false\n\tvipLevel := \"0\"\n\tsponsorCode = strutil.Trim(a.GetConfig().SponsorCode)\n\tif sponsorCode != \"\" {\n\t\tencrypted, err := hex.DecodeString(sponsorCode)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tkey, err := hex.DecodeString(BuildKey)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tdecrypt := string(cryptor.AesEcbDecrypt(encrypted, key))\n\t\terr = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", \"0\", false\n\t\t}\n\t\tvipLevel = a.SponsorInfo[\"vipLevel\"].(string)\n\t\tvipStartTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipStartTime\"].(string), time.Local)\n\t\tvipEndTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipEndTime\"].(string), time.Local)\n\t\tvipAuthTime, err := time.ParseInLocation(\"2006-01-02 15:04:05\", a.SponsorInfo[\"vipAuthTime\"].(string), time.Local)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Error(err.Error())\n\t\t\treturn \"\", vipLevel, false\n\t\t}\n\n\t\tif time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {\n\t\t\tisVip = true\n\t\t}\n\n\t\tif IsWindows() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"winDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"winDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsMacOS() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"macDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"macDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\t\tif IsLinux() {\n\t\t\tif isVip {\n\t\t\t\tif a.SponsorInfo[\"linuxDownUrl\"] == nil {\n\t\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t\t} else {\n\t\t\t\t\tdownloadUrl = a.SponsorInfo[\"linuxDownUrl\"].(string)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdownloadUrl = fmt.Sprintf(\"https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-linux-amd64\", releaseVersion.TagName)\n\t\t\t}\n\t\t}\n\n\t}\n\treturn downloadUrl, vipLevel, isVip\n}\n\nfunc (a *App) syncNews() {\n\tdefer PanicHandler()\n\tclient := data.SharedHTTPClient\n\turl := fmt.Sprintf(\"http://go-stock.sparkmemory.top:16666/FinancialNews/json?since=%d\", time.Now().Add(-24*time.Hour).Unix())\n\t//logger.SugaredLogger.Infof(\"syncNews:%s\", url)\n\tresp, err := client.R().SetDoNotParseResponse(true).Get(url)\n\tbody := resp.RawBody()\n\tdefer body.Close()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"syncNews error:%s\", err.Error())\n\t}\n\tscanner := bufio.NewScanner(body)\n\tfor scanner.Scan() {\n\t\t//line := scanner.Text()\n\t\t//logger.SugaredLogger.Infof(\"Received data: %s\", line)\n\t\tnews := &models.NtfyNews{}\n\t\terr := json.Unmarshal(scanner.Bytes(), news)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdataTime := time.UnixMilli(int64(news.Time * 1000))\n\n\t\tif slice.ContainAny(news.Tags, []string{\"外媒资讯\", \"财联社电报\", \"新浪财经\", \"外媒简讯\", \"外媒\"}) {\n\t\t\tisRed := false\n\t\t\tif slice.Contain(news.Tags, \"rotating_light\") {\n\t\t\t\tisRed = true\n\t\t\t}\n\t\t\ttelegraph := &models.Telegraph{\n\t\t\t\tTitle: news.Title,\n\t\t\t\tContent: news.Message,\n\t\t\t\tDataTime: &dataTime,\n\t\t\t\tIsRed: isRed,\n\t\t\t\tTime: dataTime.Format(\"15:04:05\"),\n\t\t\t\tSource: GetSource(news.Tags),\n\t\t\t\tSentimentResult: data.AnalyzeSentiment(news.Message).Description,\n\t\t\t}\n\t\t\tcnt := int64(0)\n\t\t\tif telegraph.Title == \"\" {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"content=?\", telegraph.Content).Count(&cnt)\n\t\t\t} else {\n\t\t\t\tdb.Dao.Model(telegraph).Where(\"title=?\", telegraph.Title).Count(&cnt)\n\t\t\t}\n\t\t\tif cnt == 0 {\n\t\t\t\tdb.Dao.Model(telegraph).Create(&telegraph)\n\t\t\t\t//计算时间差如果<5分钟则推送\n\t\t\t\tif time.Now().Sub(dataTime) < 5*time.Minute {\n\t\t\t\t\ta.NewsPush(&[]models.Telegraph{*telegraph})\n\t\t\t\t}\n\t\t\t\ttags := slice.Filter(news.Tags, func(index int, item string) bool {\n\t\t\t\t\treturn !(item == \"rotating_light\" || item == \"loudspeaker\")\n\t\t\t\t})\n\t\t\t\tfor _, subject := range tags {\n\t\t\t\t\ttag := &models.Tags{\n\t\t\t\t\t\tName: subject,\n\t\t\t\t\t\tType: \"subject\",\n\t\t\t\t\t}\n\t\t\t\t\tdb.Dao.Model(tag).Where(\"name=? and type=?\", subject, \"subject\").FirstOrCreate(&tag)\n\t\t\t\t\tdb.Dao.Model(models.TelegraphTags{}).Where(\"telegraph_id=? and tag_id=?\", telegraph.ID, tag.ID).FirstOrCreate(&models.TelegraphTags{\n\t\t\t\t\t\tTelegraphId: telegraph.ID,\n\t\t\t\t\t\tTagId: tag.ID,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc GetSource(tags []string) string {\n\tif slice.ContainAny(tags, []string{\"外媒简讯\", \"外媒资讯\", \"外媒\"}) {\n\t\treturn \"外媒\"\n\t}\n\tif slices.Contains(tags, \"财联社电报\") {\n\t\treturn \"财联社电报\"\n\t}\n\tif slices.Contains(tags, \"新浪财经\") {\n\t\treturn \"新浪财经\"\n\t}\n\treturn \"\"\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\t// 增加延迟确保前端已准备好接收事件\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\truntime.EventsEmit(a.ctx, \"loadingMsg\", \"done\")\n\t\t}()\n\t}()\n\n\t//if stocksBin != nil && len(stocksBin) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查A股基础信息...\")\n\t//\tgo initStockData(a.ctx)\n\t//}\n\t//\n\t//if stocksBinHK != nil && len(stocksBinHK) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查港股基础信息...\")\n\t//\tgo initStockDataHK(a.ctx)\n\t//}\n\t//\n\t//if stocksBinUS != nil && len(stocksBinUS) > 0 {\n\t//\tgo runtime.EventsEmit(a.ctx, \"loadingMsg\", \"检查美股基础信息...\")\n\t//\tgo initStockDataUS(a.ctx)\n\t//}\n\tupdateBasicInfo()\n\n\t// Add your action here\n\t//定时更新数据\n\tconfig := data.GetSettingConfig()\n\tgo func() {\n\t\tgo data.NewMarketNewsApi().TelegraphList(30)\n\t\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\t\tgo data.NewMarketNewsApi().TradingViewNews()\n\n\t\tinterval := config.RefreshInterval\n\t\tif interval <= 0 {\n\t\t\tinterval = 1\n\t\t}\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(interval))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorStockPrices(a)\n\t\t//}\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\t//news := data.NewMarketNewsApi().GetNewTelegraph(30)\n\t\t\tnews := data.NewMarketNewsApi().TelegraphList(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newTelegraph\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"GetNewTelegraph\", entryID)\n\t\t}\n\n\t\tentryIDSina, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().GetSinaNews(30)\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newSinaNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"newSinaNews\", entryIDSina)\n\t\t}\n\n\t\tentryIDTradingViewNews, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", interval+10), func() {\n\t\t\tnews := data.NewMarketNewsApi().TradingViewNews()\n\t\t\tif data.GetSettingConfig().EnablePushNews {\n\t\t\t\tgo a.NewsPush(news)\n\t\t\t}\n\t\t\tgo runtime.EventsEmit(a.ctx, \"tradingViewNews\", news)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"tradingViewNews\", entryIDTradingViewNews)\n\t\t}\n\t}()\n\n\t//刷新基金净值信息\n\tgo func() {\n\t\t//ticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//defer ticker.Stop()\n\t\t//for range ticker.C {\n\t\t//\tMonitorFundPrices(a)\n\t\t//}\n\t\tif config.EnableFund {\n\t\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\t\tMonitorFundPrices(a)\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t\t} else {\n\t\t\t\ta.setCronEntry(\"MonitorFundPrices\", id)\n\t\t\t}\n\t\t}\n\n\t\t// AI 推荐股票价格监控定时器\n\t\tidAiStock, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorAiRecommendStockPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorAiRecommendStockPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorAiRecommendStockPrices\", idAiStock)\n\t\t}\n\n\t\t// 自选股成本价监控定时器\n\t\tidCostPrice, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\tMonitorFollowedStockCostPrices(a)\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc MonitorFollowedStockCostPrices error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"MonitorFollowedStockCostPrices\", idCostPrice)\n\t\t}\n\n\t}()\n\n\tif config.EnableNews {\n\t\t//go func() {\n\t\t//\tticker := time.NewTicker(time.Second * time.Duration(60))\n\t\t//\tdefer ticker.Stop()\n\t\t//\tfor range ticker.C {\n\t\t//\t\ttelegraph := refreshTelegraphList()\n\t\t//\t\tif telegraph != nil {\n\t\t//\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t//\t\t}\n\t\t//\t}\n\t\t//\n\t\t//}()\n\n\t\tid, err := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", 60), func() {\n\t\t\ttelegraph := refreshTelegraphList()\n\t\t\tif telegraph != nil {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", telegraph)\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"refreshTelegraphList\", id)\n\t\t}\n\n\t\tgo runtime.EventsEmit(a.ctx, \"telegraph\", refreshTelegraphList())\n\t}\n\tgo MonitorStockPrices(a)\n\tif config.EnableFund {\n\t\tgo MonitorFundPrices(a)\n\t\tgo data.NewFundApi().AllFund()\n\t}\n\t// AI 推荐股票价格监控\n\tgo MonitorAiRecommendStockPrices(a)\n\t// 自选股成本价监控\n\tgo MonitorFollowedStockCostPrices(a)\n\t// 市场统计数据采集(交易日每5分钟)\n\tgo func() {\n\t\ta.FetchAndSaveMarketStatistic()\n\t\tidMarketStat, err := a.cron.AddFunc(\"0 */5 9-15 * * 1-5\", func() {\n\t\t\ta.FetchAndSaveMarketStatistic()\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"AddFunc FetchAndSaveMarketStatistic error:%s\", err.Error())\n\t\t} else {\n\t\t\ta.setCronEntry(\"FetchAndSaveMarketStatistic\", idMarketStat)\n\t\t}\n\t}()\n\t//检查新版本\n\tgo func() {\n\t\ta.CheckUpdate(0)\n\t\tgo a.CheckStockBaseInfo(a.ctx)\n\t\tgo syncAllStockInfo(a.ctx)\n\n\t\ta.cron.AddFunc(\"0 0 2 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckStockBaseInfo(a.ctx)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tlogger.SugaredLogger.Errorf(\"Checking for updates...\")\n\t\t\ta.CheckUpdate(0)\n\t\t})\n\t\ta.cron.AddFunc(\"30 05 8,12,20 * * *\", func() {\n\t\t\tsyncAllStockInfo(a.ctx)\n\t\t})\n\t}()\n\n\t//检查谷歌浏览器\n\t//go func() {\n\t//\tf := checkChromeOnWindows()\n\t//\tif !f {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"谷歌浏览器未安装,ai分析功能可能无法使用\")\n\t//\t}\n\t//}()\n\n\t//检查Edge浏览器\n\t//go func() {\n\t//\tpath, e := checkEdgeOnWindows()\n\t//\tif !e {\n\t//\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"Edge浏览器未安装,ai分析功能可能无法使用\")\n\t//\t} else {\n\t//\t\tlogger.SugaredLogger.Infof(\"Edge浏览器已安装,路径为: %s\", path)\n\t//\t}\n\t//}()\n\tfollowList := data.NewStockDataApi().GetFollowList(0)\n\tfor _, follow := range *followList {\n\t\tif follow.Cron == nil || *follow.Cron == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tentryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"添加自动分析任务失败:%s cron=%s entryID:%v\", follow.Name, *follow.Cron, entryID)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(follow.StockCode, entryID)\n\t}\n\t//logger.SugaredLogger.Infof(\"domReady-cronEntrys:%+v\", a.cronEntrys)\n\n}\n\nfunc syncAllStockInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tdb.Dao.Unscoped().Model(&models.AllStockInfo{}).Where(\"1=1\").Delete(&models.AllStockInfo{})\n\tfor page := 1; page < 3; page++ {\n\t\tres := data.NewStockDataApi().GetAllStocks(page, 3000, \"\", models.TechnicalIndicators{})\n\t\tvar datas []models.AllStockInfo\n\t\tfor _, data := range (*res).Result.Data {\n\t\t\tdatas = append(datas, data.ToAllStockInfo())\n\t\t}\n\t\terr := db.Dao.CreateInBatches(&datas, 1000).Error\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"db.Dao.CreateInBatches error:%s\", err.Error())\n\t\t}\n\t}\n}\nfunc (a *App) CheckStockBaseInfo(ctx context.Context) {\n\tdefer PanicHandler()\n\tdefer func() {\n\t\tgo runtime.EventsEmit(ctx, \"loadingMsg\", \"done\")\n\t}()\n\tstockBasics := &[]data.StockBasic{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_basic.json\")\n\n\tdb.Dao.Unscoped().Model(&data.StockBasic{}).Where(\"1=1\").Delete(&data.StockBasic{})\n\terr := db.Dao.CreateInBatches(stockBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockBasic股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//count := int64(0)\n\t//db.Dao.Model(&data.StockBasic{}).Count(&count)\n\t//if count == int64(len(*stockBasics)) {\n\t//\treturn\n\t//}\n\t//for _, stock := range *stockBasics {\n\t//\tstockInfo := &data.StockBasic{\n\t//\t\tTsCode: stock.TsCode,\n\t//\t\tName: stock.Name,\n\t//\t\tSymbol: stock.Symbol,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t\tBKName: stock.BKName,\n\t//\t}\n\t//\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&data.StockBasic{}).Where(\"ts_code = ?\", stock.TsCode).Updates(stockInfo)\n\t//\t}\n\t//}\n\n\tstockHKBasics := &[]models.StockInfoHK{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockHKBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_hk.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoHK{}).Where(\"1=1\").Delete(&models.StockInfoHK{})\n\terr = db.Dao.CreateInBatches(stockHKBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoHK股票基础信息失败:%s\", err.Error())\n\t}\n\n\t//for _, stock := range *stockHKBasics {\n\t//\tstockInfo := &models.StockInfoHK{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoHK{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\tstockUSBasics := &[]models.StockInfoUS{}\n\tdata.SharedHTTPClient.R().\n\t\tSetHeader(\"user\", \"go-stock\").\n\t\tSetResult(stockUSBasics).\n\t\tGet(\"http://8.134.249.145:18080/go-stock/stock_base_info_us.json\")\n\n\tdb.Dao.Unscoped().Model(&models.StockInfoUS{}).Where(\"1=1\").Delete(&models.StockInfoUS{})\n\terr = db.Dao.CreateInBatches(stockUSBasics, 400).Error\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"保存StockInfoUS股票基础信息失败:%s\", err.Error())\n\t}\n\t//for _, stock := range *stockUSBasics {\n\t//\tstockInfo := &models.StockInfoUS{\n\t//\t\tCode: stock.Code,\n\t//\t\tName: stock.Name,\n\t//\t\tBKName: stock.BKName,\n\t//\t\tBKCode: stock.BKCode,\n\t//\t}\n\t//\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).First(stockInfo)\n\t//\tif stockInfo.ID == 0 {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)\n\t//\t} else {\n\t//\t\tdb.Dao.Model(&models.StockInfoUS{}).Where(\"code = ?\", stock.Code).Updates(stockInfo)\n\t//\t}\n\t//}\n\n}\nfunc (a *App) NewsPush(news *[]models.Telegraph) {\n\n\tfollows := data.NewStockDataApi().GetFollowList(0)\n\tstockNames := slice.Map(*follows, func(index int, item data.FollowedStock) string {\n\t\treturn item.Name\n\t})\n\n\tfor _, telegraph := range *news {\n\t\tif a.GetConfig().EnableOnlyPushRedNews {\n\t\t\tif telegraph.IsRed || strutil.ContainsAny(telegraph.Content, stockNames) {\n\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t\t}\n\t\t} else {\n\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", telegraph)\n\t\t}\n\t\t//go data.NewAlertWindowsApi(\"go-stock\", telegraph.Source+\" \"+telegraph.Time, telegraph.Content, string(icon)).SendNotification()\n\t\t//}\n\t}\n}\n\nfunc (a *App) AddCronTask(follow data.FollowedStock) func() {\n\treturn func() {\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"开始自动分析\"+follow.Name+\"_\"+follow.StockCode)\n\t\tai := data.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId)\n\t\tmsgs := ai.NewChatStream(follow.Name, follow.StockCode, \"\", nil, a.AiTools, true)\n\t\tvar res strings.Builder\n\n\t\tchatId := \"\"\n\t\tquestion := \"\"\n\t\tfor msg := range msgs {\n\t\t\tif v, ok := msg[\"extraContent\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v + \"\\n\")\n\t\t\t}\n\t\t\tif v, ok := msg[\"content\"].(string); ok && v != \"\" {\n\t\t\t\tres.WriteString(v)\n\t\t\t}\n\t\t\tif v, ok := msg[\"chatId\"].(string); ok {\n\t\t\t\tchatId = v\n\t\t\t}\n\t\t\tif v, ok := msg[\"question\"].(string); ok {\n\t\t\t\tquestion = v\n\t\t\t}\n\t\t}\n\n\t\tdata.NewDeepSeekOpenAi(a.ctx, follow.AiConfigId).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)\n\t\tgo runtime.EventsEmit(a.ctx, \"warnMsg\", \"AI分析完成:\"+follow.Name+\"_\"+follow.StockCode)\n\n\t}\n}\n\nfunc refreshTelegraphList() *[]string {\n\turl := \"https://www.cls.cn/telegraph\"\n\tresponse, err := data.SharedHTTPClient.R().\n\t\tSetHeader(\"Referer\", \"https://www.cls.cn/\").\n\t\tSetHeader(\"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\").\n\t\tGet(url)\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\t//logger.SugaredLogger.Info(string(response.Body()))\n\tdocument, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))\n\tif err != nil {\n\t\treturn &[]string{}\n\t}\n\tvar telegraph []string\n\tdocument.Find(\"div.telegraph-content-box\").Each(func(i int, selection *goquery.Selection) {\n\t\t//logger.SugaredLogger.Info(selection.Text())\n\t\ttelegraph = append(telegraph, selection.Text())\n\t})\n\treturn &telegraph\n}\n\n// isTradingDay 判断是否是交易日\nvar tradingDayCache = freecache.NewCache(64 * 1024)\n\nfunc isTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(3 * time.Second)\n\ttype holidayResp struct {\n\t\tCode int `json:\"code\"`\n\t\tHoliday struct {\n\t\t\tHoliday bool `json:\"holiday\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"holiday\"`\n\t}\n\tvar result holidayResp\n\tresp, err := client.R().SetResult(&result).Get(fmt.Sprintf(\"https://timor.tech/api/holiday/info/%s\", date))\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Code == 0 && result.Holiday.Holiday {\n\t\treturn true, true\n\t}\n\treturn false, true\n}\n\nfunc preCacheTradingDays() {\n\tloc, _ := time.LoadLocation(\"Asia/Shanghai\")\n\tnow := time.Now().In(loc)\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tisHKTradingDay(d)\n\t\t}\n\t}()\n\tgo func() {\n\t\test, _ := time.LoadLocation(\"America/New_York\")\n\t\tfor i := -7; i <= 7; i++ {\n\t\t\td := now.AddDate(0, 0, i)\n\t\t\tif est != nil {\n\t\t\t\td = d.In(est)\n\t\t\t}\n\t\t\tisUSTradingDay(d)\n\t\t}\n\t}()\n}\n\n// isTradingTime 判断是否是交易时间\nfunc isTradingTime(date time.Time) bool {\n\tif !isTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\t// 判断是否在9:15到11:30之间\n\tif (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {\n\t\treturn true\n\t}\n\n\t// 判断是否在13:00到15:00之间\n\tif (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// IsHKTradingTime 判断当前时间是否在港股交易时间内\nfunc IsHKTradingTime(date time.Time) bool {\n\tif !isHKTradingDay(date) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := date.Clock()\n\n\tif (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc isHKTradingDay(date time.Time) bool {\n\tweekday := date.Weekday()\n\tdateStr := date.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"hk:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkHKHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkHKHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype klineResp struct {\n\t\tData struct {\n\t\t\tKlines []string `json:\"klines\"`\n\t\t} `json:\"data\"`\n\t}\n\tvar result klineResp\n\tdateClean := strings.ReplaceAll(date, \"-\", \"\")\n\tapiURL := fmt.Sprintf(\"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=100.HSI&fields1=f1&fields2=f51&klt=101&fqt=0&beg=%s&end=%s\", dateClean, dateClean)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\tif result.Data.Klines != nil && len(result.Data.Klines) > 0 {\n\t\treturn false, true\n\t}\n\treturn true, true\n}\n\n// IsUSTradingTime 判断当前时间是否在美股交易时间内\nfunc IsUSTradingTime(date time.Time) bool {\n\test, err := time.LoadLocation(\"America/New_York\")\n\tvar estTime time.Time\n\tif err != nil {\n\t\testTime = date.Add(time.Hour * -12)\n\t} else {\n\t\testTime = date.In(est)\n\t}\n\n\tif !isUSTradingDay(estTime) {\n\t\treturn false\n\t}\n\n\thour, minute, _ := estTime.Clock()\n\n\tif (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {\n\t\treturn true\n\t}\n\n\tif (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {\n\t\treturn true\n\t}\n\n\tif (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc isUSTradingDay(estTime time.Time) bool {\n\tweekday := estTime.Weekday()\n\tdateStr := estTime.Format(\"2006-01-02\")\n\n\tcacheKey := []byte(\"us:\" + dateStr)\n\tif cached, err := tradingDayCache.Get(cacheKey); err == nil {\n\t\treturn string(cached) == \"1\"\n\t}\n\n\tif weekday == time.Saturday || weekday == time.Sunday {\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\treturn false\n\t}\n\n\tisHoliday, apiOk := checkUSHolidayAPI(dateStr)\n\tif apiOk {\n\t\tif isHoliday {\n\t\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"0\"), 86400)\n\t\t\treturn false\n\t\t}\n\t\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 86400)\n\t\treturn true\n\t}\n\n\t_ = tradingDayCache.Set(cacheKey, []byte(\"1\"), 600)\n\treturn true\n}\n\nfunc checkUSHolidayAPI(date string) (isHoliday bool, apiOk bool) {\n\tclient := data.SharedHTTPClient\n\tclient.SetTimeout(5 * time.Second)\n\ttype usHolidayResp struct {\n\t\tIsHoliday bool `json:\"is_holiday\"`\n\t\tIsEarlyClose bool `json:\"is_early_close\"`\n\t\tIsWeekend bool `json:\"is_weekend\"`\n\t\tStatus string `json:\"status\"`\n\t}\n\tvar result usHolidayResp\n\tapiURL := fmt.Sprintf(\"https://fincalapi.com/v1/day_status?calendar=NYSE&date=%s\", date)\n\tresp, err := client.R().SetResult(&result).Get(apiURL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn false, false\n\t}\n\treturn result.IsHoliday, true\n}\nfunc MonitorFundPrices(a *App) {\n\t// 检查 A 股是否开市(基金交易时间与 A 股一致)\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前 A 股未开市,跳过基金价格监控\")\n\t\treturn\n\t}\n\n\tlogger.SugaredLogger.Debugf(\"A 股市场已开市,开始基金价格监控\")\n\n\tdest := &[]data.FollowedFund{}\n\tdb.Dao.Model(&data.FollowedFund{}).Find(dest)\n\tfor _, follow := range *dest {\n\t\t_, err := data.NewFundApi().CrawlFundBasic(follow.Code)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"获取基金基本信息失败,基金代码:%s,错误信息:%s\", follow.Code, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tdata.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)\n\t\tdata.NewFundApi().CrawlFundNetUnitValue(follow.Code)\n\t}\n}\n\n// MonitorAiRecommendStockPrices 监控 AI 推荐股票的价格,当股价达到预警线时发送通知\nfunc MonitorAiRecommendStockPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过 AI 推荐股票价格监控\")\n\t\treturn\n\t}\n\n\tvar aiRecommendStocks []models.AiRecommendStocks\n\tdb.Dao.Model(&models.AiRecommendStocks{}).Where(\"enable_alert = ?\", true).Find(&aiRecommendStocks)\n\n\tif len(aiRecommendStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockCodeMap := make(map[string]*models.AiRecommendStocks)\n\tfor i := range aiRecommendStocks {\n\t\tstock := &aiRecommendStocks[i]\n\t\tstopLossPrice, _ := convertor.ToFloat(stock.RecommendStopLossPrice)\n\t\tif stock.RecommendBuyPriceMin <= 0 && stock.RecommendStopProfitPriceMin <= 0 && stopLossPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockCodeMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tif len(stockCodes) == 0 {\n\t\tlogger.SugaredLogger.Debugf(\"没有设置预警价格的 AI 推荐股票,跳过价格监控\")\n\t\treturn\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取 AI 推荐股票实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\taiStock, ok := stockCodeMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tbaseAlertKey := fmt.Sprintf(\"%s:%s\", aiStock.StockCode, aiStock.DataTime.Format(\"20060102\"))\n\n\t\tbuyAlertKey := baseAlertKey + \":BUY\"\n\t\tif aiStock.RecommendBuyPriceMin > 0 && currentPrice <= aiStock.RecommendBuyPriceMin {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif priceSinceLastBuyAlert == 0 || priceSinceLastBuyAlert > aiStock.RecommendBuyPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【买入预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议买入价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议买入价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendBuyPriceMin, aiStock.RecommendBuyPriceMax)\n\t\t\t\tif a.canSendAlert(buyAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(buyAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastBuyAlert := a.getPriceAtAlertReset(buyAlertKey)\n\t\t\tif currentPrice > aiStock.RecommendBuyPriceMin && (priceSinceLastBuyAlert == 0 || currentPrice > priceSinceLastBuyAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(buyAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tprofitAlertKey := baseAlertKey + \":PROFIT\"\n\t\tif aiStock.RecommendStopProfitPriceMin > 0 && currentPrice >= aiStock.RecommendStopProfitPriceMin {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif priceSinceLastProfitAlert == 0 || priceSinceLastProfitAlert < aiStock.RecommendStopProfitPriceMin {\n\t\t\t\ttitle := fmt.Sprintf(\"【止盈预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止盈价**: %.2f - %.2f\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止盈价: %.2f-%.2f\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopProfitPriceMin, aiStock.RecommendStopProfitPriceMax)\n\t\t\t\tif a.canSendAlert(profitAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(profitAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastProfitAlert := a.getPriceAtAlertReset(profitAlertKey)\n\t\t\tif currentPrice < aiStock.RecommendStopProfitPriceMin && (priceSinceLastProfitAlert == 0 || currentPrice < priceSinceLastProfitAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(profitAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\n\t\tstopLossAlertKey := baseAlertKey + \":LOSS\"\n\t\tstopLossPrice, _ := convertor.ToFloat(aiStock.RecommendStopLossPrice)\n\t\tif stopLossPrice > 0 && currentPrice <= stopLossPrice {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif priceSinceLastLossAlert == 0 || priceSinceLastLossAlert > stopLossPrice {\n\t\t\t\ttitle := fmt.Sprintf(\"【止损预警】%s\", aiStock.StockName)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **建议止损价**: %s\\n- **推荐时间**: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice,\n\t\t\t\t\taiStock.DataTime.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n建议止损价: %s\",\n\t\t\t\t\taiStock.StockName, aiStock.StockCode, currentPrice, aiStock.RecommendStopLossPrice)\n\t\t\t\tif a.canSendAlert(stopLossAlertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(stopLossAlertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastLossAlert := a.getPriceAtAlertReset(stopLossAlertKey)\n\t\t\tif currentPrice > stopLossPrice && (priceSinceLastLossAlert == 0 || currentPrice > priceSinceLastLossAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(stopLossAlertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// MonitorFollowedStockCostPrices 监控自选股的持仓成本价,当股价低于成本价时发送预警\nfunc MonitorFollowedStockCostPrices(a *App) {\n\tisAStockOpen := isTradingTime(time.Now())\n\tisHKStockOpen := IsHKTradingTime(time.Now())\n\tisUSStockOpen := IsUSTradingTime(time.Now())\n\n\tif !isAStockOpen && !isHKStockOpen && !isUSStockOpen {\n\t\tlogger.SugaredLogger.Debugf(\"当前所有市场均未开市,跳过自选股成本价监控\")\n\t\treturn\n\t}\n\n\tvar followedStocks []data.FollowedStock\n\tdb.Dao.Model(&data.FollowedStock{}).Where(\"cost_price > 0\").Find(&followedStocks)\n\n\tif len(followedStocks) == 0 {\n\t\treturn\n\t}\n\n\tstockCodes := make([]string, 0)\n\tstockMap := make(map[string]*data.FollowedStock)\n\tfor i := range followedStocks {\n\t\tstock := &followedStocks[i]\n\t\tstockCodes = append(stockCodes, tools.GetStockCode(stock.StockCode))\n\t\tstockMap[tools.GetStockCode(stock.StockCode)] = stock\n\t}\n\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil || len(*stockData) == 0 {\n\t\tlogger.SugaredLogger.Errorf(\"获取自选股实时数据失败: %v\", err)\n\t\treturn\n\t}\n\n\tfor _, stockInfo := range *stockData {\n\t\tfollowedStock, ok := stockMap[tools.GetStockCode(stockInfo.Code)]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrentPrice, _ := convertor.ToFloat(stockInfo.Price)\n\t\tif currentPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcostPrice := followedStock.CostPrice\n\t\tif costPrice <= 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\talertKey := fmt.Sprintf(\"COST:%s:%s\", followedStock.StockCode, followedStock.Time.Format(\"20060102\"))\n\n\t\tif currentPrice < costPrice {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif priceSinceLastAlert == 0 || priceSinceLastAlert >= costPrice {\n\t\t\t\tdropPercent := ((costPrice - currentPrice) / costPrice) * 100\n\t\t\t\ttitle := fmt.Sprintf(\"【成本价预警】%s\", followedStock.Name)\n\t\t\t\tcontent := fmt.Sprintf(\"## %s\\n\\n- **股票代码**: %s\\n- **当前价格**: %.2f\\n- **持仓成本价**: %.2f\\n- **亏损比例**: %.2f%%\\n- **关注时间**: %s\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent,\n\t\t\t\t\tfollowedStock.Time.Format(\"2006-01-02 15:04:05\"))\n\t\t\t\tplainContent := fmt.Sprintf(\"%s(%s)\\n当前价格: %.2f\\n成本价: %.2f\\n亏损: %.2f%%\",\n\t\t\t\t\tfollowedStock.Name, followedStock.StockCode, currentPrice, costPrice, dropPercent)\n\t\t\t\tif a.canSendAlert(alertKey, 5*time.Minute) {\n\t\t\t\t\tgo data.NewAlertWindowsApi(\"go-stock价格预警\", title, content, \"\").SendNotification()\n\t\t\t\t\tgo data.NewDingDingAPI().SendToDingDing(title, content)\n\t\t\t\t\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\t\t\t\t\"time\": title,\n\t\t\t\t\t\t\"isRed\": true,\n\t\t\t\t\t\t\"source\": \"go-stock\",\n\t\t\t\t\t\t\"content\": plainContent,\n\t\t\t\t\t})\n\t\t\t\t\ta.updateAlertSentTime(alertKey)\n\t\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t} else {\n\t\t\tpriceSinceLastAlert := a.getPriceAtAlertReset(alertKey)\n\t\t\tif currentPrice >= costPrice && (priceSinceLastAlert == 0 || currentPrice < priceSinceLastAlert) {\n\t\t\t\ta.updatePriceAtAlertReset(alertKey, currentPrice)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// canSendAlert 检查是否可以发送预警,避免重复发送\n// alertKey: 预警的唯一标识\n// interval: 发送间隔\n// 返回 true 表示可以发送,false 表示需要在间隔后才能发送\nfunc (a *App) canSendAlert(alertKey string, interval time.Duration) bool {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\n\tlastSent, exists := a.stockAlertLastSent[alertKey]\n\tif !exists {\n\t\treturn true\n\t}\n\n\treturn time.Since(lastSent) >= interval\n}\n\n// updateAlertSentTime 更新预警发送时间\nfunc (a *App) updateAlertSentTime(alertKey string) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.stockAlertLastSent[alertKey] = time.Now()\n}\n\n// getPriceAtAlertReset 获取预警重置后的价格(用于判断是否需要重新触发预警)\nfunc (a *App) getPriceAtAlertReset(alertKey string) float64 {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\treturn a.priceAtAlertReset[alertKey]\n}\n\n// updatePriceAtAlertReset 更新预警重置后的价格\nfunc (a *App) updatePriceAtAlertReset(alertKey string, price float64) {\n\ta.stockAlertMu.Lock()\n\tdefer a.stockAlertMu.Unlock()\n\ta.priceAtAlertReset[alertKey] = price\n}\n\nfunc GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {\n\tstockInfos := make([]data.StockInfo, 0)\n\tstockCodes := make([]string, 0)\n\tfor _, follow := range follows {\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\t\tcontinue\n\t\t}\n\t\tstockCodes = append(stockCodes, follow.StockCode)\n\t}\n\tstockData, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)\n\tif err != nil || stockData == nil {\n\t\treturn &stockInfos\n\t}\n\tfor _, info := range *stockData {\n\t\tv, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {\n\t\t\tif strutil.HasPrefixAny(follow.StockCode, []string{\"US\", \"us\"}) {\n\t\t\t\treturn strings.ToLower(strings.Replace(follow.StockCode, \"us\", \"gb_\", 1)) == info.Code\n\t\t\t}\n\n\t\t\treturn follow.StockCode == info.Code\n\t\t})\n\t\tif ok {\n\t\t\taddStockFollowData(v, &info)\n\t\t\tstockInfos = append(stockInfos, info)\n\t\t}\n\t}\n\treturn &stockInfos\n}\nfunc getStockInfo(follow data.FollowedStock) *data.StockInfo {\n\tstockCode := follow.StockCode\n\tstockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\tif err != nil || stockDatas == nil || len(*stockDatas) == 0 {\n\t\treturn &data.StockInfo{}\n\t}\n\tstockData := (*stockDatas)[0]\n\taddStockFollowData(follow, &stockData)\n\treturn &stockData\n}\n\nfunc addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {\n\tstockData.PrePrice = follow.Price //上次当前价格\n\tstockData.Sort = follow.Sort\n\tstockData.CostPrice = follow.CostPrice //成本价\n\tstockData.CostVolume = follow.Volume //成本量\n\tstockData.AlarmChangePercent = follow.AlarmChangePercent\n\tstockData.AlarmPrice = follow.AlarmPrice\n\tstockData.Groups = follow.Groups\n\n\t//当前价格\n\tprice, _ := convertor.ToFloat(stockData.Price)\n\t//当前价格为0 时 使用卖一价格作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.A1P)\n\t}\n\t//当前价格依然为0 时 使用买一报价作为当前价格\n\tif price == 0 {\n\t\tprice, _ = convertor.ToFloat(stockData.B1P)\n\t}\n\n\t//昨日收盘价\n\tpreClosePrice, _ := convertor.ToFloat(stockData.PreClose)\n\n\t//当前价格依然为0 时 使用昨日收盘价为当前价格\n\tif price == 0 {\n\t\tprice = preClosePrice\n\t}\n\n\t//今日最高价\n\thighPrice, _ := convertor.ToFloat(stockData.High)\n\tif highPrice == 0 {\n\t\thighPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\n\t//今日最低价\n\tlowPrice, _ := convertor.ToFloat(stockData.Low)\n\tif lowPrice == 0 {\n\t\tlowPrice, _ = convertor.ToFloat(stockData.Open)\n\t}\n\t//开盘价\n\t//openPrice, _ := convertor.ToFloat(stockData.Open)\n\n\tif price > 0 && preClosePrice > 0 {\n\t\tstockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)\n\t\tstockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif highPrice > 0 && preClosePrice > 0 {\n\t\tstockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif lowPrice > 0 && preClosePrice > 0 {\n\t\tstockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)\n\t}\n\tif follow.CostPrice > 0 && follow.Volume > 0 {\n\t\tif price > 0 {\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\tstockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)\n\t\t} else {\n\t\t\t//未开盘时当前价格为昨日收盘价\n\t\t\tstockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)\n\t\t\tstockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)\n\t\t\t// 未开盘时,今日盈亏为 0\n\t\t\tstockData.ProfitAmountToday = 0\n\t\t}\n\n\t}\n\n\t//logger.SugaredLogger.Debugf(\"stockData:%+v\", stockData)\n\tif follow.Price != price && price > 0 {\n\t\tgo db.Dao.Model(follow).Where(\"stock_code = ?\", follow.StockCode).Updates(map[string]interface{}{\n\t\t\t\"price\": price,\n\t\t})\n\t}\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\tdefer PanicHandler()\n\t// 记录当前窗口大小,供下次启动时还原\n\tif a.ctx != nil {\n\t\tif w, h := runtime.WindowGetSize(a.ctx); w > 0 && h > 0 {\n\t\t\tcfg := data.GetSettingConfig()\n\t\t\tcfg.WindowWidth = w\n\t\t\tcfg.WindowHeight = h\n\t\t\tdata.UpdateConfig(cfg)\n\t\t\t//logger.SugaredLogger.Infof(\"save window size: %dx%d\", w, h)\n\t\t}\n\t}\n\t//logger.SugaredLogger.Infof(\"application shutdown Version:%s\", Version)\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(stockCode string) *data.StockInfo {\n\t//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)\n\n\tfollow := &data.FollowedStock{\n\t\tStockCode: stockCode,\n\t}\n\tdb.Dao.Model(follow).Where(\"stock_code = ?\", stockCode).Preload(\"Groups\").Preload(\"Groups.GroupInfo\").First(follow)\n\tstockInfo := getStockInfo(*follow)\n\treturn stockInfo\n}\n\nfunc (a *App) Follow(stockCode string) string {\n\treturn data.NewStockDataApi().Follow(stockCode)\n}\n\nfunc (a *App) UnFollow(stockCode string) string {\n\treturn data.NewStockDataApi().UnFollow(stockCode)\n}\n\nfunc (a *App) GetFollowList(groupId int) *[]data.FollowedStock {\n\treturn data.NewStockDataApi().GetFollowList(groupId)\n}\n\nfunc (a *App) GetStockList(key string) []data.StockBasic {\n\treturn data.NewStockDataApi().GetStockList(key)\n}\n\nfunc (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {\n\treturn data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)\n}\n\nfunc (a *App) SetTradingPrice(stockCode string, entryPrice, takeProfitPrice, stopLossPrice, costPrice float64) string {\n\treturn data.NewStockDataApi().SetTradingPrice(entryPrice, takeProfitPrice, stopLossPrice, costPrice, stockCode)\n}\n\nfunc (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {\n\treturn data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)\n}\nfunc (a *App) SetStockSort(sort int64, stockCode string) {\n\tdata.NewStockDataApi().SetStockSort(sort, stockCode)\n}\nfunc (a *App) SendDingDingMessage(message string, stockCode string) string {\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\t//logger.SugaredLogger.Infof(\"stockCode %s ttl:%d\", stockCode, ttl)\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), 60*5)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\n// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警\nfunc (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {\n\n\tif strutil.HasPrefixAny(stockCode, []string{\"SZ\", \"SH\", \"sh\", \"sz\"}) && (!isTradingTime(time.Now())) {\n\t\treturn \"非A股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"hk\", \"HK\"}) && (!IsHKTradingTime(time.Now())) {\n\t\treturn \"非港股交易时间\"\n\t}\n\tif strutil.HasPrefixAny(stockCode, []string{\"us\", \"US\", \"gb_\"}) && (!IsUSTradingTime(time.Now())) {\n\t\treturn \"非美股交易时间\"\n\t}\n\n\tttl, _ := a.cache.TTL([]byte(stockCode))\n\tif ttl > 0 {\n\t\treturn \"\"\n\t}\n\terr := a.cache.Set([]byte(stockCode), []byte(\"1\"), getMsgTypeTTL(msgType))\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"set cache error:%s\", err.Error())\n\t\treturn \"\"\n\t}\n\tstockInfo := &data.StockInfo{}\n\tdb.Dao.Model(stockInfo).Where(\"code = ?\", stockCode).First(stockInfo)\n\tgo data.NewAlertWindowsApi(\"go-stock消息通知\", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), \"\").SendNotification()\n\n\tgo runtime.EventsEmit(a.ctx, \"newsPush\", map[string]any{\n\t\t\"time\": \"📈 \" + getMsgTypeName(msgType),\n\t\t\"isRed\": true,\n\t\t\"source\": \"go-stock\",\n\t\t\"content\": GenNotificationMsg(stockInfo),\n\t})\n\n\treturn data.NewDingDingAPI().SendDingDingMessage(message)\n}\n\nfunc (a *App) NewChatStream(stock, stockCode, question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool) {\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools, think)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(a.ctx, aiConfigId).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{}, think)\n\t}\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, \"newChatStream\", msg)\n\t}\n\truntime.EventsEmit(a.ctx, \"newChatStream\", \"DONE\")\n}\n\nfunc (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string, aiConfigId int) {\n\tdata.NewDeepSeekOpenAi(a.ctx, aiConfigId).SaveAIResponseResult(stockCode, stockName, result, chatId, question)\n}\nfunc (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {\n\treturn data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stock)\n}\n\nfunc (a *App) GetVersionInfo() *models.VersionInfo {\n\treturn &models.VersionInfo{\n\t\tVersion: Version,\n\t\tIcon: GetImageBase(icon),\n\t\tAlipay: GetImageBase(alipay),\n\t\tWxpay: GetImageBase(wxpay),\n\t\tWxgzh: GetImageBase(wxgzh),\n\t\tContent: VersionCommit,\n\t\tOfficialStatement: OFFICIAL_STATEMENT,\n\t}\n}\n\nfunc (a *App) GetUserManual() string {\n\treturn string(userManual)\n}\n\n//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装\n//func checkChromeOnWindows() bool {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\t_, _, err = key.GetValue(\"Path\", nil)\n//\treturn err == nil\n//}\n//\n//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装,并返回安装路径\n//func checkEdgeOnWindows() (string, bool) {\n//\tkey, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\tif err != nil {\n//\t\t// 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)\n//\t\tkey, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe`, registry.QUERY_VALUE)\n//\t\tif err != nil {\n//\t\t\treturn \"\", false\n//\t\t}\n//\t\tdefer key.Close()\n//\t}\n//\tdefer key.Close()\n//\tpath, _, err := key.GetStringValue(\"Path\")\n//\tif err != nil {\n//\t\treturn \"\", false\n//\t}\n//\treturn path, true\n//}\n\nfunc GetImageBase(bytes []byte) string {\n\treturn \"data:image/jpeg;base64,\" + base64.StdEncoding.EncodeToString(bytes)\n}\n\nfunc GenNotificationMsg(stockInfo *data.StockInfo) string {\n\tPrice, err := convertor.ToFloat(stockInfo.Price)\n\tif err != nil {\n\t\tPrice = 0\n\t}\n\tPreClose, err := convertor.ToFloat(stockInfo.PreClose)\n\tif err != nil {\n\t\tPreClose = 0\n\t}\n\tvar RF float64\n\tif PreClose > 0 {\n\t\tRF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)\n\t}\n\n\treturn \"[\" + stockInfo.Name + \"] \" + stockInfo.Price + \" \" + convertor.ToString(RF) + \"% \" + stockInfo.Date + \" \" + stockInfo.Time\n}\n\n// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟) 4 止盈报警(5分钟) 5 止损报警(5分钟)\nfunc getMsgTypeTTL(msgType int) int {\n\tswitch msgType {\n\tcase 1:\n\t\treturn 60 * 5\n\tcase 2:\n\t\treturn 60 * 30\n\tcase 3:\n\t\treturn 60 * 30\n\tcase 4:\n\t\treturn 60 * 5\n\tcase 5:\n\t\treturn 60 * 5\n\tdefault:\n\t\treturn 60 * 5\n\t}\n}\n\nfunc getMsgTypeName(msgType int) string {\n\tswitch msgType {\n\tcase 1:\n\t\treturn \"涨跌报警\"\n\tcase 2:\n\t\treturn \"股价报警\"\n\tcase 3:\n\t\treturn \"成本价报警\"\n\tcase 4:\n\t\treturn \"止盈报警\"\n\tcase 5:\n\t\treturn \"止损报警\"\n\tdefault:\n\t\treturn \"未知类型\"\n\t}\n}\n\nfunc onExit(a *App) {\n\t// 清理操作\n\t//logger.SugaredLogger.Infof(\"systray onExit\")\n\t//systray.Quit()\n\t//runtime.Quit(a.ctx)\n}\n\nfunc (a *App) UpdateConfig(settingConfig *data.SettingConfig) string {\n\t//s1, _ := json.Marshal(settingConfig)\n\t//logger.SugaredLogger.Infof(\"UpdateConfig:%s\", s1)\n\tif settingConfig.RefreshInterval > 0 {\n\t\tif entryID, exists := a.getCronEntry(\"MonitorStockPrices\"); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tid, _ := a.cron.AddFunc(fmt.Sprintf(\"@every %ds\", settingConfig.RefreshInterval), func() {\n\t\t\tMonitorStockPrices(a)\n\t\t})\n\t\ta.setCronEntry(\"MonitorStockPrices\", id)\n\t}\n\n\treturn data.UpdateConfig(settingConfig)\n}\n\nfunc (a *App) GetConfig() *data.SettingConfig {\n\treturn data.GetSettingConfig()\n}\n\nfunc (a *App) ExportConfig() string {\n\tconfig := data.NewSettingsApi().Export()\n\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"导出配置文件\",\n\t\tCanCreateDirectories: true,\n\t\tDefaultFilename: \"config.json\",\n\t})\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(file, []byte(config), os.ModePerm)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"导出配置文件失败:%s\", err.Error())\n\t\treturn err.Error()\n\t}\n\treturn \"导出成功:\" + file\n}\n\nfunc (a *App) ShareAnalysis(stockCode, stockName string) string {\n\t//http://go-stock.sparkmemory.top:16688/upload\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006/01/02\")\n\t\t//logger.SugaredLogger.Infof(\"%s analysisTime:%s\", res.CreatedAt, analysisTime)\n\t\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\t\"text\": res.Content,\n\t\t\t\"stockCode\": stockCode,\n\t\t\t\"stockName\": stockName,\n\t\t\t\"analysisTime\": analysisTime,\n\t\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\treturn response.String()\n\t} else {\n\t\treturn \"分析结果异常\"\n\t}\n}\n\n// ShareText 直接把文本分享到社区(用于 AI 助手等非 AIResponseResult 场景)\nfunc (a *App) ShareText(text, title string) string {\n\ttext = strings.TrimSpace(text)\n\ttitle = strings.TrimSpace(title)\n\tif text == \"\" {\n\t\treturn \"内容为空\"\n\t}\n\tif title == \"\" {\n\t\ttitle = \"AI助手\"\n\t}\n\tanalysisTime := time.Now().Format(\"2006/01/02\")\n\tresponse, err := data.SharedHTTPClient.SetHeader(\"ua-x\", \"go-stock\").R().SetFormData(map[string]string{\n\t\t\"text\": text,\n\t\t\"stockCode\": title,\n\t\t\"stockName\": title,\n\t\t\"analysisTime\": analysisTime,\n\t}).Post(\"http://go-stock.sparkmemory.top:16688/upload\")\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn response.String()\n}\n\nfunc (a *App) GetfundList(key string) []data.FundBasic {\n\treturn data.NewFundApi().GetFundList(key)\n}\nfunc (a *App) GetFollowedFund() []data.FollowedFund {\n\treturn data.NewFundApi().GetFollowedFund()\n}\nfunc (a *App) FollowFund(fundCode string) string {\n\treturn data.NewFundApi().FollowFund(fundCode)\n}\nfunc (a *App) UnFollowFund(fundCode string) string {\n\treturn data.NewFundApi().UnFollowFund(fundCode)\n}\nfunc (a *App) SaveAsMarkdown(stockCode, stockName string) string {\n\tres := data.NewDeepSeekOpenAi(a.ctx, 0).GetAIResponseResult(stockCode)\n\tif res != nil && len(res.Content) > 100 {\n\t\tanalysisTime := res.CreatedAt.Format(\"2006-01-02_15_04_05\")\n\t\tfile, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\t\tTitle: \"保存为Markdown\",\n\t\t\tDefaultFilename: fmt.Sprintf(\"%s[%s]AI分析结果_%s.md\", stockName, stockCode, analysisTime),\n\t\t\tFilters: []runtime.FileFilter{\n\t\t\t\t{\n\t\t\t\t\tDisplayName: \"Markdown\",\n\t\t\t\t\tPattern: \"*.md;*.markdown\",\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\terr = os.WriteFile(file, []byte(res.Content), 0644)\n\t\treturn \"已保存至:\" + file\n\t}\n\treturn \"分析结果异常,无法保存。\"\n}\n\nfunc (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {\n\treturn data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)\n}\nfunc (a *App) AddPrompt(prompt models.Prompt) string {\n\tpromptTemplate := models.PromptTemplate{\n\t\tID: prompt.ID,\n\t\tContent: prompt.Content,\n\t\tName: prompt.Name,\n\t\tType: prompt.Type,\n\t}\n\treturn data.NewPromptTemplateApi().AddPrompt(promptTemplate)\n}\nfunc (a *App) DelPrompt(id uint) string {\n\treturn data.NewPromptTemplateApi().DelPrompt(id)\n}\nfunc (a *App) SetStockAICron(cronText, stockCode string) {\n\tdata.NewStockDataApi().SetStockAICron(cronText, stockCode)\n\tif strutil.HasPrefixAny(stockCode, []string{\"gb_\"}) {\n\t\tstockCode = strings.ToUpper(stockCode)\n\t\tstockCode = strings.Replace(stockCode, \"gb_\", \"us\", 1)\n\t\tstockCode = strings.Replace(stockCode, \"GB_\", \"us\", 1)\n\t}\n\tif entryID, exists := a.getCronEntry(stockCode); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\tfollow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)\n\tid, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))\n\ta.setCronEntry(stockCode, id)\n\n}\nfunc (a *App) AddGroup(group data.Group) string {\n\tok := data.NewStockGroupApi(db.Dao).AddGroup(group)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\nfunc (a *App) GetGroupList() []data.Group {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupList()\n}\n\nfunc (a *App) UpdateGroupSort(id int, newSort int) bool {\n\treturn data.NewStockGroupApi(db.Dao).UpdateGroupSort(id, newSort)\n}\n\nfunc (a *App) InitializeGroupSort() bool {\n\treturn data.NewStockGroupApi(db.Dao).InitializeGroupSort()\n}\n\nfunc (a *App) GetGroupStockList(groupId int) []data.GroupStock {\n\treturn data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)\n}\n\nfunc (a *App) AddStockGroup(groupId int, stockCode string) string {\n\tok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)\n\tif ok {\n\t\treturn \"添加成功\"\n\t} else {\n\t\treturn \"添加失败\"\n\t}\n}\n\nfunc (a *App) RemoveStockGroup(code, name string, groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) RemoveGroup(groupId int) string {\n\tok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)\n\tif ok {\n\t\treturn \"移除成功\"\n\t} else {\n\t\treturn \"移除失败\"\n\t}\n}\n\nfunc (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetHK_KLineData(stockCode, \"day\", days)\n}\n\nfunc (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {\n\tres := make(map[string]any, 4)\n\tpriceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)\n\tres[\"priceData\"] = priceData\n\tres[\"date\"] = date\n\tres[\"stockName\"] = stockName\n\tres[\"stockCode\"] = stockCode\n\treturn res\n}\n\nfunc (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {\n\treturn data.NewStockDataApi().GetCommonKLineData(stockCode, \"day\", days)\n}\n\n// GetStockEastMoneyKLine 东方财富多周期 K 线(分钟:1/5/10/60/120;日 101、周 102、半年 105、年 106)。\n// klt 与东方财富接口一致;10 分钟由 1 分钟数据聚合。limit 为根数上限(最大 5000)。\nfunc (a *App) GetStockEastMoneyKLine(stockCode, stockName string, klt string, limit int) *[]data.KLineData {\n\treturn a.GetStockEastMoneyKLinePage(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockEastMoneyKLinePage 分页拉取 K 线:end 为东财 end 参数(YYYYMMDD 或 YYYYMMDDHHmmss),空字符串表示取最新一段(同 GetStockEastMoneyKLine)。\nfunc (a *App) GetStockEastMoneyKLinePage(stockCode, stockName string, klt string, limit int, end string) *[]data.KLineData {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"1\"\n\t}\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tend = strings.TrimSpace(end)\n\t//if klt == \"10\" {\n\t//\tfetchN := limit * 10\n\t//\tif fetchN > 5000 {\n\t//\t\tfetchN = 5000\n\t//\t}\n\t//\traw := api.GetKLineDataBefore(stockCode, \"1\", \"\", fetchN, end)\n\t//\treturn data.AggregateKLineEveryN(raw, 10)\n\t//}\n\treturn api.GetKLineDataBefore(stockCode, klt, \"\", limit, end)\n}\n\n// GetStockKLineWithFallback 多数据源自动切换 K 线:优先东方财富,不可用时自动切换新浪财经。\n// 返回 KLineSourceResult,包含 data(K 线数组)和 source(实际使用的数据源标识:eastmoney / sina)。\nfunc (a *App) GetStockKLineWithFallback(stockCode, stockName string, klt string, limit int) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, \"\")\n}\n\n// GetStockKLinePageWithFallback 多数据源自动切换 K 线(分页):优先东方财富,不可用时自动切换新浪财经。\n// end 参数仅对东方财富有效;新浪数据源不支持分页,将返回最新一段数据。\nfunc (a *App) GetStockKLinePageWithFallback(stockCode, stockName string, klt string, limit int, end string) *data.KLineSourceResult {\n\tif limit <= 0 {\n\t\tlimit = 500\n\t}\n\tif limit > 5000 {\n\t\tlimit = 5000\n\t}\n\tklt = strings.TrimSpace(klt)\n\tif klt == \"\" {\n\t\tklt = \"101\"\n\t}\n\tend = strings.TrimSpace(end)\n\treturn data.FetchKLineWithFallback(stockCode, stockName, klt, limit, end)\n}\n\n// GetChipDistribution 获取/计算股票筹码分布(筹码图)数据(用于前端绘图)。\n// days:近多少个交易日;bins:分箱数量;adjustFlag:\"\"/qfq/hfq\nfunc (a *App) GetChipDistribution(stockCode string, days int, bins int, adjustFlag string) (*data.ChipDistributionResult, error) {\n\tstockCode = strings.TrimSpace(stockCode)\n\tif stockCode == \"\" {\n\t\treturn nil, fmt.Errorf(\"stockCode 不能为空\")\n\t}\n\tif days <= 0 {\n\t\tdays = 120\n\t}\n\tif bins <= 0 {\n\t\tbins = 80\n\t}\n\tadjustFlag = strings.TrimSpace(strings.ToLower(adjustFlag))\n\tif adjustFlag != \"\" && adjustFlag != \"qfq\" && adjustFlag != \"hfq\" {\n\t\tadjustFlag = \"qfq\"\n\t}\n\n\tapi := data.NewEastMoneyKLineApi(data.GetSettingConfig())\n\tif !api.ValidateStockCode(stockCode) {\n\t\treturn nil, fmt.Errorf(\"股票代码无效:%s\", stockCode)\n\t}\n\n\tvar kLines *[]data.KLineData\n\n\tif adjustFlag != \"\" {\n\t\tkLines = api.GetKLineData(stockCode, \"101\", adjustFlag, days)\n\t} else {\n\t\tresult := data.FetchKLineWithFallback(stockCode, \"\", \"101\", days, \"\")\n\t\tif result != nil && result.Data != nil {\n\t\t\tkLines = result.Data\n\t\t}\n\t}\n\n\tif kLines == nil || len(*kLines) == 0 {\n\t\treturn nil, fmt.Errorf(\"未获取到K线数据\")\n\t}\n\tcalculator := data.NewChipDistributionCalculator()\n\treturn calculator.Calculate(stockCode, *kLines, bins)\n}\n\n// GetTdxCallAuction 通过通达信协议获取集合竞价数据。\n// stockCode 格式如 600519.SH、000001.SZ、430047.BJ;start 为起始位置(0=最新);count 为请求数量(最大 500)。\nfunc (a *App) GetTdxCallAuction(stockCode string, start uint32, count uint32) *[]data.TdxCallAuctionData {\n\tif count <= 0 {\n\t\tcount = 500\n\t}\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetCallAuction(stockCode, start, count)\n}\n\nfunc (a *App) GetTdxCompanyInfo(stockCode string) *data.TdxCompanyInfoBundle {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10Data(stockCode)\n}\n\nfunc (a *App) GetTdxFinanceInfo(stockCode string) *data.TdxFinanceInfo {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetFinanceInfo(stockCode)\n}\n\nfunc (a *App) GetTdxXDXRInfo(stockCode string) *[]data.TdxXDXRItem {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetXDXRInfo(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryList(stockCode string) *[]data.TdxCompanyCategory {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryList(stockCode)\n}\n\nfunc (a *App) GetTdxCompanyCategoryContent(stockCode string, categoryName string) *data.TdxCompanyInfoSection {\n\tapi := data.NewTdxKLineApi()\n\treturn api.GetF10CategoryContent(stockCode, categoryName)\n}\n\nfunc (a *App) GetTelegraphList(source string) *[]*models.Telegraph {\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {\n\t//data.NewMarketNewsApi().GetNewTelegraph(30)\n\tgo data.NewMarketNewsApi().TelegraphList(30)\n\tgo data.NewMarketNewsApi().GetSinaNews(30)\n\tgo data.NewMarketNewsApi().TradingViewNews()\n\ttelegraphs := data.NewMarketNewsApi().GetTelegraphList(source)\n\treturn telegraphs\n}\n\nfunc (a *App) GlobalStockIndexes() map[string]any {\n\treturn data.NewMarketNewsApi().GlobalStockIndexes(30)\n}\n\n// GlobalStockIndexesReadable 将全球指数 JSON 转为 AI 易读 Markdown 文本。\nfunc (a *App) GlobalStockIndexesReadable() string {\n\treturn data.NewMarketNewsApi().GlobalStockIndexesReadable(30)\n}\n\nfunc (a *App) SummaryStockNews(question string, aiConfigId int, sysPromptId *int, enableTools bool, think bool, eventName string, historyJSON string) {\n\tctx, cancel := context.WithCancel(a.ctx)\n\n\t// 保存当前会话的 cancel,用于前端中断\n\ta.summaryMu.Lock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t}\n\ta.summaryCancel = cancel\n\ta.summaryMu.Unlock()\n\n\t// 允许前端自定义事件名,避免不同页面之间的事件冲突\n\tif strings.TrimSpace(eventName) == \"\" {\n\t\teventName = \"summaryStockNews\"\n\t}\n\n\t// 解析对话历史(AI 助手记忆):空字符串或解析失败则无历史\n\tvar history []map[string]interface{}\n\tif strings.TrimSpace(historyJSON) != \"\" {\n\t\tvar list []models.AiAssistantMessage\n\t\tif err := json.Unmarshal([]byte(historyJSON), &list); err == nil && len(list) > 0 {\n\t\t\thistory = make([]map[string]interface{}, 0, len(list))\n\t\t\tfor _, m := range list {\n\t\t\t\titem := map[string]interface{}{\"role\": m.Role, \"content\": m.Content}\n\t\t\t\tif m.Role == \"assistant\" && m.Reasoning != \"\" {\n\t\t\t\t\titem[\"reasoning_content\"] = m.Reasoning\n\t\t\t\t}\n\t\t\t\thistory = append(history, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar msgs <-chan map[string]any\n\tif enableTools {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools, think, history)\n\t} else {\n\t\tmsgs = data.NewDeepSeekOpenAi(ctx, aiConfigId).NewSummaryStockNewsStream(question, sysPromptId, think, history)\n\t}\n\n\tfor msg := range msgs {\n\t\truntime.EventsEmit(a.ctx, eventName, msg)\n\t}\n\n\ta.summaryMu.Lock()\n\ta.summaryCancel = nil\n\ta.summaryMu.Unlock()\n\n\truntime.EventsEmit(a.ctx, eventName, \"DONE\")\n}\nfunc (a *App) GetIndustryRank(sort string, cnt int) []any {\n\tres := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)\n\treturn res[\"data\"].([]any)\n}\nfunc (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)\n\treturn res\n}\nfunc (a *App) GetMoneyRankSina(sort string) []map[string]any {\n\tres := data.NewMarketNewsApi().GetMoneyRankSina(sort)\n\treturn res\n}\n\nfunc (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {\n\tres := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)\n\tslice.Reverse(res)\n\treturn res\n}\n\n// OpenURL\n//\n//\t@Description: 跨平台打开默认浏览器\n//\t@receiver a\n//\t@param url\nfunc (a *App) OpenURL(url string) {\n\truntime.BrowserOpenURL(a.ctx, url)\n}\n\n// SaveImage\n//\n//\t@Description: 跨平台保存图片\n//\t@receiver a\n//\t@param name\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveImage(name, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存图片\",\n\t\tDefaultFilename: name + \"AI分析.png\",\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{\n\t\t\t\tDisplayName: \"PNG 图片\",\n\t\t\t\tPattern: \"*.png\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, os.ModePerm)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// SaveWordFile\n//\n//\t@Description: // 跨平台保存word\n//\t@receiver a\n//\t@param filename\n//\t@param base64Data\n//\t@return error\nfunc (a *App) SaveWordFile(filename string, base64Data string) string {\n\tfilePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"保存 Word 文件\",\n\t\tDefaultFilename: filename,\n\t\tFilters: []runtime.FileFilter{\n\t\t\t{DisplayName: \"Word 文件\", Pattern: \"*.docx\"},\n\t\t},\n\t})\n\tif err != nil || filePath == \"\" {\n\t\treturn \"文件路径,无法保存。\"\n\t}\n\n\tbase64Data = strings.ReplaceAll(base64Data, \" \", \"+\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\n\", \"\")\n\tbase64Data = strings.ReplaceAll(base64Data, \"\\r\", \"\")\n\tif idx := strings.Index(base64Data, \";base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+8:]\n\t} else if idx := strings.Index(base64Data, \"base64,\"); idx != -1 {\n\t\tbase64Data = base64Data[idx+7:]\n\t} else if strings.HasPrefix(base64Data, \"data:\") {\n\t\tif commaIdx := strings.Index(base64Data, \",\"); commaIdx != -1 {\n\t\t\tbase64Data = base64Data[commaIdx+1:]\n\t\t}\n\t}\n\tdecodeString, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\tdecodeString, err = base64.RawStdEncoding.DecodeString(base64Data)\n\t}\n\tif err != nil {\n\t\treturn \"文件内容异常,无法保存。\" + err.Error()\n\t}\n\terr = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)\n\tif err != nil {\n\t\treturn \"保存结果异常,无法保存。\"\n\t}\n\treturn filePath\n}\n\n// GetAiConfigs\n//\n//\t@Description: // 获取 AiConfig 列表\n//\t@receiver a\n//\t@return error\nfunc (a *App) GetAiConfigs() []*data.AIConfig {\n\treturn data.GetSettingConfig().AiConfigs\n}\n\n// GetAiAssistantSession 获取 AI 助手会话消息列表,sessionId 为空时获取最新的\nfunc (a *App) GetAiAssistantSession(sessionId string) (*models.AiAssistantSessionResp, error) {\n\treturn data.GetAiAssistantSession(sessionId)\n}\n\n// SaveAiAssistantSession 保存 AI 助手会话消息到数据库\nfunc (a *App) SaveAiAssistantSession(sessionId string, messages []models.AiAssistantMessage) error {\n\treturn data.SaveAiAssistantSession(sessionId, messages)\n}\n\n// FetchAiModels\n//\n//\t@Description: 根据接口地址与 apiKey 自动获取支持的模型列表(OpenAI/DeepSeek 兼容 /models 接口)\n//\t@receiver a\n//\t@param baseUrl 接口地址(如 https://api.deepseek.com)\n//\t@param apiKey 鉴权令牌\n//\t@return []string 模型 ID 列表\nfunc (a *App) FetchAiModels(baseUrl, apiKey string) []string {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tapiKey = strutil.Trim(apiKey)\n\tif baseUrl == \"\" || apiKey == \"\" {\n\t\treturn []string{}\n\t}\n\n\ttype modelItem struct {\n\t\tID string `json:\"id\"`\n\t}\n\tvar respData struct {\n\t\tData []modelItem `json:\"data\"`\n\t}\n\n\tclient := data.SharedHTTPClient\n\tclient.SetBaseURL(baseUrl)\n\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\n\tresp, err := client.R().\n\t\tSetResult(&respData).\n\t\tGet(\"/models\")\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels error: %v\", err)\n\t\treturn []string{}\n\t}\n\tif resp.IsError() {\n\t\tlogger.SugaredLogger.Errorf(\"FetchAiModels http error: %s\", resp.Status())\n\t\treturn []string{}\n\t}\n\n\tmodelsList := make([]string, 0, len(respData.Data))\n\tfor _, m := range respData.Data {\n\t\tif strings.TrimSpace(m.ID) != \"\" {\n\t\t\tmodelsList = append(modelsList, m.ID)\n\t\t}\n\t}\n\treturn modelsList\n}\n\ntype AiModelInfo struct {\n\tModelName string `json:\"modelName\"`\n\tMaxTokens int `json:\"maxTokens\"`\n\tSource string `json:\"source\"`\n}\n\nfunc (a *App) FetchAiModelInfo(baseUrl, apiKey, modelName string) *AiModelInfo {\n\tbaseUrl = strutil.Trim(baseUrl)\n\tmodelName = strutil.Trim(modelName)\n\tif baseUrl == \"\" || modelName == \"\" {\n\t\treturn nil\n\t}\n\n\tinfo := &AiModelInfo{\n\t\tModelName: modelName,\n\t\tMaxTokens: 0,\n\t\tSource: \"\",\n\t}\n\n\tif apiKey != \"\" {\n\t\ttype modelDetail struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tMaxContextLen int `json:\"max_context_length\"`\n\t\t\tContextLength int `json:\"context_length\"`\n\t\t\tMaxOutputTok int `json:\"max_output_tokens\"`\n\t\t\tMaxTokensField int `json:\"max_tokens\"`\n\t\t}\n\t\tvar detail modelDetail\n\n\t\tclient := data.SharedHTTPClient\n\t\tclient.SetBaseURL(baseUrl)\n\t\tclient.SetHeader(\"Authorization\", \"Bearer \"+apiKey)\n\t\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\t\tclient.SetTimeout(10 * time.Second)\n\n\t\tresp, err := client.R().\n\t\t\tSetResult(&detail).\n\t\t\tGet(\"/models/\" + modelName)\n\n\t\tif err == nil && !resp.IsError() && detail.ID != \"\" {\n\t\t\tif detail.MaxContextLen > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxContextLen\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.ContextLength > 0 {\n\t\t\t\tinfo.MaxTokens = detail.ContextLength\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxOutputTok > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxOutputTok\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t} else if detail.MaxTokensField > 0 {\n\t\t\t\tinfo.MaxTokens = detail.MaxTokensField\n\t\t\t\tinfo.Source = \"api\"\n\t\t\t}\n\t\t}\n\t}\n\n\tif info.MaxTokens == 0 {\n\t\tif maxTokens := getBuiltinModelMaxTokens(modelName); maxTokens > 0 {\n\t\t\tinfo.MaxTokens = maxTokens\n\t\t\tinfo.Source = \"builtin\"\n\t\t}\n\t}\n\n\treturn info\n}\n\nfunc getBuiltinModelMaxTokens(modelName string) int {\n\tmodelTokenMap := map[string]int{\n\t\t\"deepseek-chat\": 65536,\n\t\t\"deepseek-reasoner\": 65536,\n\t\t\"deepseek-coder\": 16384,\n\t\t\"deepseek-v3\": 65536,\n\t\t\"deepseek-r1\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4o-mini\": 16384,\n\t\t\"gpt-4o-2024-05-13\": 4096,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-turbo-preview\": 4096,\n\t\t\"gpt-4\": 8192,\n\t\t\"gpt-4-32k\": 32768,\n\t\t\"gpt-3.5-turbo\": 4096,\n\t\t\"gpt-3.5-turbo-16k\": 16384,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"gpt-4.1-mini\": 32768,\n\t\t\"gpt-4.1-nano\": 32768,\n\t\t\"o1\": 100000,\n\t\t\"o1-mini\": 65536,\n\t\t\"o1-preview\": 32768,\n\t\t\"o3-mini\": 100000,\n\t\t\"o4-mini\": 100000,\n\t\t\"claude-3-5-sonnet\": 8192,\n\t\t\"claude-3-5-haiku\": 8192,\n\t\t\"claude-3-opus\": 4096,\n\t\t\"claude-3-sonnet\": 4096,\n\t\t\"claude-3-haiku\": 4096,\n\t\t\"glm-4\": 8192,\n\t\t\"glm-4-plus\": 4096,\n\t\t\"glm-4-air\": 4096,\n\t\t\"glm-4-flash\": 4096,\n\t\t\"glm-4-long\": 4096,\n\t\t\"chatglm-turbo\": 4096,\n\t\t\"moonshot-v1-8k\": 8192,\n\t\t\"moonshot-v1-32k\": 32768,\n\t\t\"moonshot-v1-128k\": 131072,\n\t\t\"qwen-turbo\": 8192,\n\t\t\"qwen-plus\": 131072,\n\t\t\"qwen-max\": 8192,\n\t\t\"qwen-long\": 65536,\n\t\t\"qwen2.5-72b-instruct\": 32768,\n\t\t\"hunyuan-lite\": 4096,\n\t\t\"hunyuan-standard\": 4096,\n\t\t\"hunyuan-pro\": 4096,\n\t\t\"hunyuan-turbo\": 4096,\n\t\t\"spark-lite\": 4096,\n\t\t\"spark-pro\": 4096,\n\t\t\"spark-max\": 4096,\n\t\t\"spark-4.0-ultra\": 4096,\n\t\t\"yi-light\": 16384,\n\t\t\"yi-large\": 16384,\n\t\t\"yi-medium\": 16384,\n\t\t\"yi-spark\": 16384,\n\t\t\"yi-vision\": 16384,\n\t\t\"abab6.5-chat\": 8192,\n\t\t\"abab6.5s-chat\": 8192,\n\t\t\"abab5.5-chat\": 4096,\n\t\t\"baichuan2-turbo\": 4096,\n\t\t\"baichuan2-53b\": 4096,\n\t\t\"ernie-4.0\": 4096,\n\t\t\"ernie-3.5\": 4096,\n\t\t\"ernie-speed\": 4096,\n\t\t\"ernie-lite\": 4096,\n\t}\n\n\tif maxTokens, ok := modelTokenMap[modelName]; ok {\n\t\treturn maxTokens\n\t}\n\n\tfor prefix, maxTokens := range map[string]int{\n\t\t\"deepseek\": 65536,\n\t\t\"gpt-4o\": 16384,\n\t\t\"gpt-4-turbo\": 4096,\n\t\t\"gpt-4-\": 8192,\n\t\t\"gpt-3.5\": 4096,\n\t\t\"gpt-4.1\": 32768,\n\t\t\"o1-\": 65536,\n\t\t\"o3-\": 100000,\n\t\t\"o4-\": 100000,\n\t\t\"claude-3\": 8192,\n\t\t\"glm-4\": 8192,\n\t\t\"chatglm\": 4096,\n\t\t\"moonshot-v1\": 8192,\n\t\t\"qwen-\": 8192,\n\t\t\"qwen2\": 32768,\n\t\t\"hunyuan-\": 4096,\n\t\t\"spark-\": 4096,\n\t\t\"yi-\": 16384,\n\t\t\"abab\": 8192,\n\t\t\"baichuan\": 4096,\n\t\t\"ernie-\": 4096,\n\t\t\"llama-3\": 8192,\n\t\t\"llama3\": 8192,\n\t\t\"mistral-\": 8192,\n\t\t\"mixtral-\": 32768,\n\t\t\"codestral-\": 32768,\n\t\t\"gemini-1.5\": 8192,\n\t\t\"gemini-2\": 8192,\n\t\t\"command-r\": 4096,\n\t\t\"Qwen/Qwen\": 32768,\n\t\t\"deepseek-ai/\": 65536,\n\t\t\"meta-llama/\": 8192,\n\t\t\"mistralai/\": 32768,\n\t\t\"Pro/deepseek-\": 65536,\n\t\t\"Pro/qwen-\": 32768,\n\t} {\n\t\tif strings.HasPrefix(modelName, prefix) {\n\t\t\treturn maxTokens\n\t\t}\n\t}\n\n\treturn 0\n}\n\n// InitCronTasks 在应用启动时,自动为启用状态的定时任务创建调度\nfunc (a *App) InitCronTasks() {\n\tcronApi := agent.NewCronTaskApi()\n\tif !cronApi.ExistsByTaskType(\"stock_change_save\") {\n\t\ttask := &models.CronTask{\n\t\t\tName: \"异动数据保存\",\n\t\t\tCronExpr: \"0 */1 * * * *\",\n\t\t\tTaskType: \"stock_change_save\",\n\t\t\tEnable: true,\n\t\t\tStatus: \"active\",\n\t\t\tDescription: \"每分钟自动保存A股异动数据(火箭发射、快速反弹、大笔买入、封涨停板等),交易时间外自动跳过\",\n\t\t}\n\t\terr := cronApi.Create(task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建异动数据保存任务失败:%v\", err)\n\t\t} else {\n\t\t\tlogger.SugaredLogger.Info(\"已自动创建异动数据保存定时任务\")\n\t\t}\n\t}\n\ttasks := cronApi.GetAll()\n\tif len(tasks) == 0 {\n\t\treturn\n\t}\n\tfor _, t := range tasks {\n\t\ttaskCopy := t\n\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SugaredLogger.Errorf(\"启动任务失败:%v %s\", err, taskCopy.Name)\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"自动创建定时任务失败:%v %s\", err, taskCopy.Name)\n\t\t\tcontinue\n\t\t}\n\t\ta.setCronEntry(convertor.ToString(taskCopy.ID)+\"_\"+taskCopy.Name, entryID)\n\t}\n}\n\n// AbortSummaryStockNews 取消当前进行中的 SummaryStockNews 流式回答\nfunc (a *App) AbortSummaryStockNews() {\n\ta.summaryMu.Lock()\n\tdefer a.summaryMu.Unlock()\n\tif a.summaryCancel != nil {\n\t\ta.summaryCancel()\n\t\ta.summaryCancel = nil\n\t}\n}\n\n// CreateCronTask\n//\n//\t@Description: 创建定时任务\n//\t@receiver a\n//\t@param task 定时任务信息\n//\t@return string 操作结果\nfunc (a *App) CreateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Create(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"创建失败:%v\", err)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn \"任务创建成功,但定时失败\"\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateCronTask(task *models.CronTask) string {\n\terr := agent.NewCronTaskApi().Update(task)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\tif entryID, exists := a.getCronEntry(convertor.ToString(task.ID) + \"_\" + task.Name); exists {\n\t\ta.cron.Remove(entryID)\n\t}\n\ttaskCopy := *task\n\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, taskCopy.Name)\n\t\t\treturn\n\t\t}\n\t})\n\ta.setCronEntry(convertor.ToString(task.ID)+\"_\"+task.Name, entryID)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"更新失败:%v\", err)\n\t}\n\treturn \"更新成功\"\n}\n\n// DeleteCronTask\n//\n//\t@Description: 删除定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) DeleteCronTask(id uint) string {\n\terr := agent.NewCronTaskApi().Delete(id)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"删除失败:%v\", err)\n\t}\n\treturn \"删除成功\"\n}\n\n// GetCronTaskByID\n//\n//\t@Description: 根据 ID 获取定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return *models.CronTask 任务信息\nfunc (a *App) GetCronTaskByID(id uint) *models.CronTask {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn task\n}\n\n// GetCronTaskList\n//\n//\t@Description: 获取定时任务列表\n//\t@receiver a\n//\t@param query 查询条件\n//\t@return *models.CronTaskPageResp 分页结果\nfunc (a *App) GetCronTaskList(query *models.CronTaskQuery) *models.CronTaskPageResp {\n\treturn agent.NewCronTaskApi().List(query)\n}\n\n// EnableCronTask\n//\n//\t@Description: 启用/禁用定时任务\n//\t@receiver a\nfunc (a *App) EnableCronTask(id uint, enable bool) string {\n\terr := agent.NewCronTaskApi().EnableTask(id, enable)\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err == nil {\n\t\tif entryID, exists := a.getCronEntry(convertor.ToString(id) + \"_\" + task.Name); exists {\n\t\t\ta.cron.Remove(entryID)\n\t\t}\n\t\tif enable {\n\t\t\ttaskCopy := *task\n\t\t\tentryID, err := a.cron.AddFunc(taskCopy.CronExpr, func() {\n\t\t\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, &taskCopy)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SugaredLogger.Errorf(\"%s 执行任务失败:%v\", taskCopy.Name, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t})\n\t\t\ta.setCronEntry(convertor.ToString(id)+\"_\"+task.Name, entryID)\n\t\t\tif err != nil {\n\t\t\t\treturn \"操作成功,但定时失败\"\n\t\t\t}\n\t\t}\n\n\t}\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"操作失败:%v\", err)\n\t}\n\treturn \"操作成功\"\n}\n\n// ExecuteCronTaskNow\n//\n//\t@Description: 立即执行定时任务\n//\t@receiver a\n//\t@param id 任务 ID\n//\t@return string 操作结果\nfunc (a *App) ExecuteCronTaskNow(id uint) string {\n\ttask, err := agent.NewCronTaskApi().GetByID(id)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"任务不存在:%v\", err)\n\t}\n\n\tgo func() {\n\t\terr := agent.NewCronTaskApi().ExecuteTask(a.ctx, task)\n\t\tif err != nil {\n\t\t\tlogger.SugaredLogger.Errorf(\"执行任务失败:%v %s\", err, task.Name)\n\t\t}\n\t}()\n\n\treturn \"任务执行中\"\n}\n\n// GetCronTaskTypes\n//\n//\t@Description: 获取所有任务类型\n//\t@receiver a\n//\t@return []lo.Tuple2[string, string] 任务类型列表\nfunc (a *App) GetCronTaskTypes() []lo.Tuple2[string, string] {\n\treturn agent.NewCronTaskApi().GetTaskTypes()\n}\n\n// ValidateCronExpr\n//\n//\t@Description: 验证 Cron 表达式\n//\t@receiver a\n//\t@param expr Cron 表达式\n//\t@return string 验证结果\nfunc (a *App) ValidateCronExpr(expr string) string {\n\terr := agent.NewCronTaskApi().ValidateCronExpr(expr)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"无效表达式:%v\", err)\n\t}\n\treturn \"有效表达式\"\n}\n\n// SearchCronTasks\n//\n//\t@Description: 搜索定时任务\n//\t@receiver a\n//\t@param keyword 搜索关键词\n//\t@return []models.CronTask 搜索结果\nfunc (a *App) SearchCronTasks(keyword string) []models.CronTask {\n\treturn agent.NewCronTaskApi().SearchTasks(keyword)\n}\n\n// CalculateNextRunTime 根据 Cron 表达式计算下一次运行时间\n// 参数:\n// - cron: Cron 表达式,用于定义任务调度的时间规则\n//\n// 返回值:\n// - string: 格式化为 \"2006-01-02 15:04:05\" 的下一次运行时间字符串\nfunc (a *App) CalculateNextRunTime(cron string) string {\n\tnextRunTime := agent.NewCronTaskApi().CalculateNextRunTime(cron)\n\treturn nextRunTime.Format(\"2006-01-02 15:04:05\")\n}\n\n// CalculateNextRunTimes 根据 Cron 表达式计算未来多次运行时间\n// 参数:\n// - cron: Cron 表达式\n// - count: 需要计算的次数\n//\n// 返回值:\n// - []string: 按时间顺序排序的运行时间列表,格式为 \"2006-01-02 15:04:05\"\nfunc (a *App) CalculateNextRunTimes(cron string, count int) []string {\n\ttimes := agent.NewCronTaskApi().CalculateNextRunTimes(cron, count)\n\tresult := make([]string, 0, len(times))\n\tfor _, t := range times {\n\t\tresult = append(result, t.Format(\"2006-01-02 15:04:05\"))\n\t}\n\treturn result\n}\n\n// AddTradingRecord 添加交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - uint: 新添加的交易记录ID\n// - error: 错误信息\nfunc (a *App) AddTradingRecord(record data.TradingRecord) (uint, error) {\n\treturn data.NewStockDataApi().AddTradingRecord(record)\n}\n\n// GetTradingRecordList 获取交易记录列表(分页与筛选,返回结构与 AI 推荐列表一致)\nfunc (a *App) GetTradingRecordList(query data.TradingRecordListQuery) *data.TradingRecordPageData {\n\tpage, err := data.NewStockDataApi().GetTradingRecordList(query)\n\tif err != nil {\n\t\treturn &data.TradingRecordPageData{}\n\t}\n\treturn page\n}\n\n// GetTradingRecordById 根据ID获取单个交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - *data.TradingRecord: 交易记录指针\n// - error: 错误信息\nfunc (a *App) GetTradingRecordById(id uint) (*data.TradingRecord, error) {\n\treturn data.NewStockDataApi().GetTradingRecordById(id)\n}\n\n// GetTradingRecordStatistics 获取交易记录统计数据\n//\n// 返回值:\n// - *data.TradingRecordStatistics: 统计数据指针\nfunc (a *App) GetTradingRecordStatistics() *data.TradingRecordStatistics {\n\tstats, err := data.NewStockDataApi().GetTradingRecordStatistics()\n\tif err != nil {\n\t\treturn &data.TradingRecordStatistics{}\n\t}\n\treturn stats\n}\n\n// UpdateTradingRecord 更新交易记录\n// 参数:\n// - record: 交易记录结构体\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) UpdateTradingRecord(record data.TradingRecord) error {\n\treturn data.NewStockDataApi().UpdateTradingRecord(record)\n}\n\n// DeleteTradingRecord 删除交易记录\n// 参数:\n// - id: 交易记录ID\n//\n// 返回值:\n// - error: 错误信息\nfunc (a *App) DeleteTradingRecord(id uint) error {\n\treturn data.NewStockDataApi().DeleteTradingRecord(id)\n}\n\n// CheckFrequentTrading 检查是否频繁交易\n// 参数:\n// - stockCode: 股票代码\n//\n// 返回值:\n// - map[string]any: 包含 canTrade (bool) 和 msg (string)\nfunc (a *App) CheckFrequentTrading(stockCode string) map[string]any {\n\tcanTrade, msg := data.NewStockDataApi().CheckFrequentTrading(stockCode)\n\treturn map[string]any{\n\t\t\"canTrade\": canTrade,\n\t\t\"msg\": msg,\n\t}\n}\n\nfunc (a *App) FetchAndSaveMarketStatistic() {\n\tif !isTradingTime(time.Now()) {\n\t\tlogger.SugaredLogger.Debugf(\"当前非交易时间,跳过市场统计数据采集\")\n\t\treturn\n\t}\n\terr := data.NewMarketStatisticApi().FetchAndSave()\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取市场统计数据失败: %v\", err)\n\t}\n}\n\nfunc (a *App) GetTodayMarketStatistic() []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetTodayData()\n}\n\nfunc (a *App) GetMarketStatisticByDate(date string) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetByDate(date)\n}\n\nfunc (a *App) GetRecentDaysMarketStatistic(days int) []models.MarketStatistic {\n\treturn data.NewMarketStatisticApi().GetRecentDaysData(days)\n}\n\nfunc (a *App) CreateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Create(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建MCP服务器失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateMCPServer(server *models.MCPServer) string {\n\terr := data.NewMCPServerApi().Update(server)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新MCP服务器失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteMCPServer(id uint) string {\n\terr := data.NewMCPServerApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除MCP服务器失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetMCPServerByID(id uint) *models.MCPServer {\n\tserver, err := data.NewMCPServerApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取MCP服务器失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn server\n}\n\nfunc (a *App) GetMCPServerList(query *models.MCPServerQuery) *models.MCPServerPageResp {\n\treturn data.NewMCPServerApi().List(query)\n}\n\nfunc (a *App) EnableMCPServer(id uint, enable bool) string {\n\terr := data.NewMCPServerApi().EnableServer(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用MCP服务器失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) TestMCPServer(id uint) string {\n\tresult, err := data.NewMCPServerApi().TestConnection(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"测试MCP服务器连接失败: %v\", err)\n\t\treturn \"测试失败: \" + err.Error()\n\t}\n\treturn result\n}\n\nfunc (a *App) CreateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Create(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"创建技能失败: %v\", err)\n\t\treturn \"创建失败: \" + err.Error()\n\t}\n\treturn \"创建成功\"\n}\n\nfunc (a *App) UpdateSkill(skill *models.Skill) string {\n\terr := data.NewSkillApi().Update(skill)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"更新技能失败: %v\", err)\n\t\treturn \"更新失败: \" + err.Error()\n\t}\n\treturn \"更新成功\"\n}\n\nfunc (a *App) DeleteSkill(id uint) string {\n\terr := data.NewSkillApi().Delete(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"删除技能失败: %v\", err)\n\t\treturn \"删除失败: \" + err.Error()\n\t}\n\treturn \"删除成功\"\n}\n\nfunc (a *App) GetSkillByID(id uint) *models.Skill {\n\tskill, err := data.NewSkillApi().GetByID(id)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"获取技能失败: %v\", err)\n\t\treturn nil\n\t}\n\treturn skill\n}\n\nfunc (a *App) GetSkillList(query *models.SkillQuery) *models.SkillPageResp {\n\treturn data.NewSkillApi().List(query)\n}\n\nfunc (a *App) EnableSkill(id uint, enable bool) string {\n\terr := data.NewSkillApi().EnableSkill(id, enable)\n\tif err != nil {\n\t\tlogger.SugaredLogger.Errorf(\"启用/禁用技能失败: %v\", err)\n\t\treturn \"操作失败: \" + err.Error()\n\t}\n\tif enable {\n\t\treturn \"已启用\"\n\t}\n\treturn \"已禁用\"\n}\n\nfunc (a *App) GetAllSkills() []models.Skill {\n\treturn data.NewSkillApi().GetAll()\n}\n\nfunc (a *App) GetMCPToolsByServerID(serverID uint) []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetToolsByServerID(serverID)\n}\n\nfunc (a *App) GetAllMCPTools() []models.MCPServerTool {\n\treturn data.NewMCPServerApi().GetAllTools()\n}\n"}}, "frontend/src/components/ai-config-manager.vue": {"1ce0f27108ff8513bd8a668d30218b2406fad1f9": {"t": 1785809839.298191, "n": 17060, "h": "c15efa7c4ee831fa", "c": "<script setup>\nimport {computed, h, onMounted, ref} from \"vue\";\nimport {useRouter} from \"vue-router\";\nimport {GetAiConfigs, UpdateAiConfigs, FetchAiModels, FetchAiModelInfo} from \"../../wailsjs/go/main/App\";\nimport {NButton, NSpace, NTag, useMessage} from \"naive-ui\";\nimport {data} from \"../../wailsjs/go/models\";\nimport {EventsEmit} from \"../../wailsjs/runtime\";\nimport {ChevronLeftIcon, HelpCircleFilledIcon} from \"tdesign-icons-vue-next\";\n\nconst message = useMessage()\nconst router = useRouter()\n\n// 返回基础设置页面\nfunction goBackToSettings() {\n router.push({name: 'settings'})\n}\n\n// AI 配置列表(独立加载,不依赖 settings.vue 的 formValue)\nconst aiConfigs = ref([])\nconst saving = ref(false)\n\n// 搜索关键字\nconst searchKeyword = ref('')\n// 分页配置\nconst pagination = ref({\n page: 1,\n pageSize: 10,\n showSizePicker: true,\n pageSizes: [5, 10, 20, 50],\n itemCount: 0,\n prefix: ({itemCount}) => `共 ${itemCount} 条`\n})\n\n// 抽屉编辑相关\nconst drawerVisible = ref(false)\nconst editingIndex = ref(-1) // -1 表示新增/复制\nconst editingConfig = ref(null)\n// 抽屉模式:add 新增 / copy 复制 / edit 编辑\nconst drawerMode = ref('add')\n// 抽屉标题(根据模式动态切换)\nconst drawerTitle = computed(() => {\n if (drawerMode.value === 'copy') return '复制新建AI配置'\n if (drawerMode.value === 'edit') return '编辑AI配置'\n return '添加AI配置'\n})\n\n// 经过搜索过滤后的列表\nconst filteredConfigs = computed(() => {\n const keyword = (searchKeyword.value || '').trim().toLowerCase()\n if (!keyword) return aiConfigs.value\n return aiConfigs.value.filter(c => {\n const name = (c.name || '').toLowerCase()\n const modelName = (c.modelName || '').toLowerCase()\n const baseUrl = (c.baseUrl || '').toLowerCase()\n const platformName = getPlatformName(c.baseUrl).toLowerCase()\n // 平台 label 形如 \"DeepSeek (https://api.deepseek.com)\",匹配整串以支持中文别名(如\"智谱AI\"、\"火山引擎\")\n const platformOpt = aiPlatformOptions.find(opt => opt.value === c.baseUrl)\n const platformLabel = platformOpt ? platformOpt.label.toLowerCase() : ''\n return name.includes(keyword)\n || modelName.includes(keyword)\n || baseUrl.includes(keyword)\n || platformName.includes(keyword)\n || platformLabel.includes(keyword)\n })\n})\n\n// n-data-table 所需的 data(带 key)\nconst tableData = computed(() => {\n return filteredConfigs.value.map((c, idx) => ({\n ...c,\n _key: c.ID || idx,\n _index: idx\n }))\n})\n\n// 平台选项\nconst aiPlatformOptions = [\n {label: 'DeepSeek (https://api.deepseek.com)', value: 'https://api.deepseek.com'},\n {label: '硅基流动 (https://api.siliconflow.cn/v1)', value: 'https://api.siliconflow.cn/v1'},\n {label: '智谱AI(GLM) (https://open.bigmodel.cn/api/paas/v4)', value: 'https://open.bigmodel.cn/api/paas/v4'},\n {label: '智谱GLM Coding Plan (https://open.bigmodel.cn/api/coding/paas/v4)', value: 'https://open.bigmodel.cn/api/coding/paas/v4'},\n {label: '字节豆包(火山引擎) (https://ark.cn-beijing.volces.com/api/v3)', value: 'https://ark.cn-beijing.volces.com/api/v3'},\n {label: '火山引擎Ark Plan (https://ark.cn-beijing.volces.com/api/plan/v3)', value: 'https://ark.cn-beijing.volces.com/api/plan/v3'},\n {label: '火山引擎Ark Coding (https://ark.cn-beijing.volces.com/api/coding/v3)', value: 'https://ark.cn-beijing.volces.com/api/coding/v3'},\n {label: '阿里云百炼 (https://dashscope.aliyuncs.com/compatible-mode/v1)', value: 'https://dashscope.aliyuncs.com/compatible-mode/v1'},\n {label: '阿里云百炼 Token Plan 团队版 (https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1)', value: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'},\n {label: '阿里云百炼 Coding Plan (https://coding.dashscope.aliyuncs.com/v1)', value: 'https://coding.dashscope.aliyuncs.com/v1'},\n {label: 'Moonshot(月之暗面) (https://api.moonshot.cn/v1)', value: 'https://api.moonshot.cn/v1'},\n {label: '腾讯混元 (https://api.hunyuan.cloud.tencent.com/v1)', value: 'https://api.hunyuan.cloud.tencent.com/v1'},\n {label: '讯飞星火 (https://spark-api-open.xf-yun.com/v1)', value: 'https://spark-api-open.xf-yun.com/v1'},\n {label: '零一万物 (https://api.lingyiwanwu.com/v1)', value: 'https://api.lingyiwanwu.com/v1'},\n {label: 'MiniMax (https://api.minimax.chat/v1)', value: 'https://api.minimax.chat/v1'},\n {label: '小米MiMo TokenPlan (https://token-plan-cn.xiaomimimo.com/v1)', value: 'https://token-plan-cn.xiaomimimo.com/v1'},\n {label: '小米MiMo (https://api.xiaomimimo.com/v1)', value: 'https://api.xiaomimimo.com/v1'},\n {label: '腾讯云TokenHub (https://tokenhub.tencentmaas.com/v1)', value: 'https://tokenhub.tencentmaas.com/v1'},\n {label: '腾讯云Token Plan 个人版 (https://api.lkeap.cloud.tencent.com/plan/v3)', value: 'https://api.lkeap.cloud.tencent.com/plan/v3'},\n {label: '腾讯云Coding Plan (https://api.lkeap.cloud.tencent.com/coding/v3)', value: 'https://api.lkeap.cloud.tencent.com/coding/v3'},\n {label: 'OpenAI (https://api.openai.com/v1)', value: 'https://api.openai.com/v1'},\n {label: 'Azure OpenAI (https://YOUR_RESOURCE.openai.azure.com)', value: 'https://YOUR_RESOURCE.openai.azure.com'},\n {label: 'OpenRouter (https://openrouter.ai/api/v1)', value: 'https://openrouter.ai/api/v1'},\n {label: 'Ollama (http://localhost:11434/v1)', value: 'http://localhost:11434/v1'},\n]\n\nfunction getPlatformName(baseUrl) {\n if (!baseUrl) return ''\n const platform = aiPlatformOptions.find(opt => opt.value === baseUrl)\n if (platform) {\n const idx = platform.label.indexOf(' (')\n return idx > 0 ? platform.label.substring(0, idx) : platform.label\n }\n return ''\n}\n\nfunction onBaseUrlChange(aiConfig, newBaseUrl) {\n const platformName = getPlatformName(newBaseUrl)\n if (platformName && aiConfig.name && !aiConfig.name.startsWith(platformName)) {\n aiConfig.name = platformName + '-' + aiConfig.name\n } else if (platformName && !aiConfig.name) {\n aiConfig.name = platformName\n }\n}\n\nfunction onModelNameChange(aiConfig, newModelName) {\n if (!newModelName) return\n const platformName = getPlatformName(aiConfig.baseUrl)\n const baseName = platformName || 'AI'\n\n if (!aiConfig.name) {\n aiConfig.name = baseName + '-' + newModelName\n } else if (aiConfig.name === platformName) {\n aiConfig.name = platformName + '-' + newModelName\n } else {\n const parts = aiConfig.name.split('-')\n if (parts.length >= 2 && parts[0] === platformName) {\n parts[parts.length - 1] = newModelName\n aiConfig.name = parts.join('-')\n } else if (!aiConfig.name.endsWith(newModelName)) {\n aiConfig.name = aiConfig.name + '-' + newModelName\n }\n }\n\n fetchModelInfo(aiConfig, newModelName)\n}\n\nasync function fetchAiModels(aiConfig) {\n if (!aiConfig.baseUrl || !aiConfig.apiKey) {\n message.warning('请先填写接口地址和 apiKey')\n return\n }\n if (aiConfig._loadingModels) {\n return\n }\n aiConfig._loadingModels = true\n try {\n const list = await FetchAiModels(aiConfig.baseUrl, aiConfig.apiKey)\n const options = (list || []).map(id => ({label: id, value: id}))\n aiConfig._modelOptions = options\n if (!aiConfig.modelName && options.length > 0) {\n aiConfig.modelName = options[0].value\n onModelNameChange(aiConfig, aiConfig.modelName)\n }\n if (!options.length) {\n message.warning('未从接口获取到可用模型,请检查地址和 apiKey')\n }\n } catch (e) {\n console.error('FetchAiModels error', e)\n message.error('获取模型列表失败,请检查接口地址和 apiKey')\n } finally {\n aiConfig._loadingModels = false\n }\n}\n\nasync function fetchModelInfo(aiConfig, modelName) {\n if (!modelName || !aiConfig.baseUrl) return\n try {\n const info = await FetchAiModelInfo(aiConfig.baseUrl, aiConfig.apiKey || '', modelName)\n if (info && info.maxTokens > 0) {\n aiConfig.maxTokens = info.maxTokens\n const sourceLabel = info.source === 'api' ? 'API' : '内置数据'\n message.success(`已自动设置 ${modelName} 的 MaxTokens 为 ${info.maxTokens}(来源:${sourceLabel})`)\n }\n } catch (e) {\n console.error('FetchAiModelInfo error', e)\n }\n}\n\n// 打开新增抽屉\nfunction openAddDrawer() {\n editingIndex.value = -1\n drawerMode.value = 'add'\n editingConfig.value = new data.AIConfig({\n name: '',\n baseUrl: 'https://api.deepseek.com',\n apiKey: '',\n modelName: 'deepseek-reasoner',\n temperature: 0.1,\n maxTokens: 8192,\n timeOut: 6000,\n httpProxy: \"\",\n httpProxyEnabled: false,\n thinking: true,\n })\n drawerVisible.value = true\n}\n\n// 基于已有配置快速复制新建(清空 ID,名称加「-副本」后缀)\nfunction openCopyDrawer(row) {\n editingIndex.value = -1\n drawerMode.value = 'copy'\n const copy = JSON.parse(JSON.stringify(row))\n copy.ID = 0\n copy.name = (copy.name || '') + '-副本'\n // 清空模型列表缓存,避免沿用旧配置的模型选项\n delete copy._modelOptions\n delete copy._loadingModels\n editingConfig.value = copy\n drawerVisible.value = true\n}\n\n// 打开编辑抽屉\nfunction openEditDrawer(row) {\n editingIndex.value = row._index\n drawerMode.value = 'edit'\n // 深拷贝避免直接修改原对象\n editingConfig.value = JSON.parse(JSON.stringify(row))\n drawerVisible.value = true\n}\n\n// 保存抽屉中的配置(写入列表,不立即调后端)\nfunction applyDrawerConfig() {\n const c = editingConfig.value\n if (!c.name || !c.baseUrl || !c.apiKey || !c.modelName) {\n message.warning('名称/接口地址/apiKey/模型名称未填写完整')\n return\n }\n if (editingIndex.value === -1) {\n aiConfigs.value.push(c)\n } else {\n aiConfigs.value[editingIndex.value] = c\n }\n drawerVisible.value = false\n}\n\n// 从列表中删除\nfunction removeConfig(row) {\n const idx = aiConfigs.value.findIndex(c => (c.ID && c.ID === row.ID) || c === row)\n if (idx >= 0) {\n aiConfigs.value.splice(idx, 1)\n }\n}\n\n// 表格列定义\nconst columns = [\n {title: '配置名称', key: 'name', resizable: true, minWidth: 160},\n {\n title: '接口平台',\n key: 'baseUrl',\n resizable: true,\n minWidth: 180,\n render(row) {\n const name = getPlatformName(row.baseUrl)\n return h('span', name || row.baseUrl)\n }\n },\n {title: '模型', key: 'modelName', resizable: true, minWidth: 160},\n {\n title: '深度思考',\n key: 'thinking',\n width: 100,\n align: 'center',\n render(row) {\n return h(NTag, {type: row.thinking ? 'success' : 'default', size: 'small', bordered: false},\n () => row.thinking ? '开启' : '关闭')\n }\n },\n {\n title: 'http代理',\n key: 'httpProxyEnabled',\n width: 100,\n align: 'center',\n render(row) {\n return h(NTag, {type: row.httpProxyEnabled ? 'warning' : 'default', size: 'small', bordered: false},\n () => row.httpProxyEnabled ? '开启' : '关闭')\n }\n },\n {\n title: 'MaxTokens',\n key: 'maxTokens',\n width: 110,\n align: 'right'\n },\n {\n title: '操作',\n key: 'actions',\n width: 200,\n fixed: 'right',\n render(row) {\n return h(NSpace, {size: 4}, () => [\n h(NButton, {\n size: 'small',\n type: 'primary',\n ghost: true,\n onClick: () => openEditDrawer(row)\n }, () => '编辑'),\n h(NButton, {\n size: 'small',\n type: 'info',\n ghost: true,\n onClick: () => openCopyDrawer(row)\n }, () => '复制'),\n h(NButton, {\n size: 'small',\n type: 'error',\n ghost: true,\n onClick: () => removeConfig(row)\n }, () => '删除')\n ])\n }\n }\n]\n\n// 行 key\nfunction rowKey(row) {\n return row._key\n}\n\n// 搜索时回到第一页\nfunction onSearchInput() {\n pagination.value.page = 1\n}\n\n// 分页变化\nfunction onPageChange(page) {\n pagination.value.page = page\n}\n\nfunction onPageSizeChange(pageSize) {\n pagination.value.pageSize = pageSize\n pagination.value.page = 1\n}\n\n// 保存全部配置到后端\nfunction saveAiConfigs() {\n if (saving.value) return\n saving.value = true\n UpdateAiConfigs(aiConfigs.value).then(res => {\n if (res === '保存成功!') {\n message.success(res)\n EventsEmit(\"updateSettings\")\n } else {\n message.error(res)\n }\n }).catch(e => {\n message.error('保存失败:' + e)\n }).finally(() => {\n saving.value = false\n })\n}\n\nfunction loadAiConfigs() {\n GetAiConfigs().then(res => {\n aiConfigs.value = res || []\n })\n}\n\nonMounted(() => {\n loadAiConfigs()\n})\n</script>\n\n<template>\n <n-flex justify=\"left\" style=\"text-align: left; --wails-draggable:no-drag\">\n <n-form :label-placement=\"'left'\" :label-align=\"'left'\" style=\"width: 100%;\">\n <n-space vertical size=\"large\" style=\"width: 100%;\">\n <n-card size=\"small\">\n <template #header>\n <n-space align=\"center\" size=\"small\">\n <n-button quaternary circle size=\"tiny\" @click=\"goBackToSettings\" title=\"返回基础设置\">\n <template #icon>\n <n-icon><ChevronLeftIcon/></n-icon>\n </template>\n </n-button>\n <n-tag type=\"primary\" :bordered=\"false\">AI模型服务配置</n-tag>\n </n-space>\n </template>\n <template #header-extra>\n <n-space>\n <n-button type=\"primary\" dashed @click=\"openAddDrawer\">+ 添加AI配置</n-button>\n <n-button type=\"primary\" strong :loading=\"saving\" @click=\"saveAiConfigs\">保存配置</n-button>\n </n-space>\n </template>\n <n-space vertical size=\"medium\">\n <n-text depth=\"3\" style=\"font-size: 12px;\">\n 管理 AI 模型服务接口配置(可添加多个,用于 AI 诊股、飞书机器人、AI 助手等功能)。支持按名称/模型/接口地址搜索,分页展示。修改后请点击「保存配置」。\n </n-text>\n <n-input\n v-model:value=\"searchKeyword\"\n placeholder=\"搜索:配置名称 / 平台 / 模型名称 / 接口地址\"\n clearable\n @update:value=\"onSearchInput\"\n style=\"max-width: 480px;\"\n />\n <n-data-table\n :columns=\"columns\"\n :data=\"tableData\"\n :row-key=\"rowKey\"\n :pagination=\"pagination\"\n :bordered=\"false\"\n :single-line=\"false\"\n size=\"small\"\n style=\"height: calc(100vh - 280px);\"\n @update:page=\"onPageChange\"\n @update:page-size=\"onPageSizeChange\"\n />\n </n-space>\n </n-card>\n </n-space>\n </n-form>\n\n <!-- 编辑/新增抽屉 -->\n <n-drawer v-model:show=\"drawerVisible\" :width=\"640\" placement=\"right\">\n <n-drawer-content :title=\"drawerTitle\" closable>\n <n-form v-if=\"editingConfig\" :label-placement=\"'left'\" :label-width=\"120\">\n <n-form-item label=\"配置名称\" required>\n <n-input v-model:value=\"editingConfig.name\" placeholder=\"配置名称\" clearable/>\n </n-form-item>\n <n-form-item label=\"接口地址\" required>\n <n-select\n v-model:value=\"editingConfig.baseUrl\"\n :options=\"aiPlatformOptions\"\n filterable\n tag\n clearable\n placeholder=\"选择或输入AI接口地址\"\n @update:value=\"(val) => onBaseUrlChange(editingConfig, val)\"\n />\n </n-form-item>\n <n-form-item label=\"令牌(apiKey)\" required>\n <n-input type=\"password\" v-model:value=\"editingConfig.apiKey\" placeholder=\"apiKey\"\n clearable show-password-on=\"click\"/>\n </n-form-item>\n <n-form-item label=\"模型名称\" required>\n <n-select\n v-model:value=\"editingConfig.modelName\"\n :options=\"editingConfig._modelOptions || []\"\n filterable\n tag\n :loading=\"editingConfig._loadingModels\"\n placeholder=\"点击获取模型列表或手动输入\"\n @click=\"fetchAiModels(editingConfig)\"\n @update:value=\"(val) => onModelNameChange(editingConfig, val)\"\n />\n </n-form-item>\n <n-form-item label=\"Temperature\">\n <n-input-number v-model:value=\"editingConfig.temperature\" :step=\"0.1\" style=\"width: 100%;\"/>\n </n-form-item>\n <n-form-item label=\"MaxTokens\">\n <n-input-number v-model:value=\"editingConfig.maxTokens\" style=\"width: 100%;\"/>\n </n-form-item>\n <n-form-item label=\"Timeout(秒)\">\n <n-input-number :min=\"60\" :step=\"1\" v-model:value=\"editingConfig.timeOut\" style=\"width: 100%;\"/>\n </n-form-item>\n <n-form-item label=\"深度思考\">\n <n-space align=\"center\">\n <n-switch v-model:value=\"editingConfig.thinking\"/>\n <n-tooltip placement=\"top\">\n <template #trigger>\n <n-icon color=\"#0e7a0d\" size=\"20\">\n <HelpCircleFilledIcon/>\n </n-icon>\n </template>\n <n-gradient-text :type=\"'warning'\">\n <div style=\"max-width: 400px;text-align: left\">\n 启用深度思考模式:<br>\n 适用于 DeepSeek-Reasoner、MiMo-V2.5-Pro 等支持推理的模型。<br>\n 如使用普通模型请关闭此选项\n </div>\n </n-gradient-text>\n </n-tooltip>\n </n-space>\n </n-form-item>\n <n-form-item label=\"http代理\">\n <n-switch v-model:value=\"editingConfig.httpProxyEnabled\"/>\n </n-form-item>\n <n-form-item v-if=\"editingConfig.httpProxyEnabled\" label=\"http代理地址\">\n <n-input v-model:value=\"editingConfig.httpProxy\" placeholder=\"http代理地址\" clearable/>\n </n-form-item>\n </n-form>\n <template #footer>\n <n-space>\n <n-button @click=\"drawerVisible = false\">取消</n-button>\n <n-button type=\"primary\" @click=\"applyDrawerConfig\">确定</n-button>\n </n-space>\n </template>\n </n-drawer-content>\n </n-drawer>\n </n-flex>\n</template>\n\n<style scoped>\n</style>\n"}}}} |