Files

1 line
3.3 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "arvinlovegood/go-stock", "files": {"README.md": {"76b952311e44ad3544366337e14136f21d0e20ea": {"t": 1785596175.807316, "n": 14331, "h": "c4fb8e6be1b43696", "c": "# go-stock : 基于大语言模型的AI赋能股票分析工具\n## ![go-stock](./build/appicon.png)\n![GitHub Release](https://img.shields.io/github/v/release/ArvinLovegood/go-stock?link=https%3A%2F%2Fgithub.com%2FArvinLovegood%2Fgo-stock%2Freleases&link=https%3A%2F%2Fgithub.com%2FArvinLovegood%2Fgo-stock%2Freleases)\n[![GitHub Repo stars](https://img.shields.io/github/stars/ArvinLovegood/go-stock?link=https%3A%2F%2Fgithub.com%2FArvinLovegood%2Fgo-stock)](https://github.com/ArvinLovegood/go-stock)\n[![star](https://gitee.com/arvinlovegood_admin/go-stock/badge/star.svg?theme=dark)](https://gitee.com/arvinlovegood_admin/go-stock)\n\n[//]: # ([![star]&#40;https://gitcode.com/ArvinLovegood/go-stock/star/badge.svg&#41;]&#40;https://gitcode.com/ArvinLovegood/go-stock&#41;)\n\n[//]: # (## 💖 赞助商 &#40;Sponsors&#41;)\n\n[//]: # (### 🌟[Anspire开放平台]&#40;https://open.anspire.cn/?share_code=OMSTE9LW&#41;,一Key同时启用全球热门大模型和联网搜索,无需科学上网,含免费额度)\n\n[//]: # ([![Anspire开放平台]&#40;./build/screenshot/anspire-ai.png&#41;]&#40;https://open.anspire.cn/?share_code=OMSTE9LW&#41;)\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服务平台&#40;Anspire Open&#41;是面向智能体时代的领先的基础设施提供商,现已上线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![扫码_搜索联合传播样式-白色版.png](build/screenshot/%E6%89%AB%E7%A0%81_%E6%90%9C%E7%B4%A2%E8%81%94%E5%90%88%E4%BC%A0%E6%92%AD%E6%A0%B7%E5%BC%8F-%E7%99%BD%E8%89%B2%E7%89%88.png)\n\n### 📈 交流群\n\n[//]: # (- QQ交流群2:[点击链接加入群聊【go-stock交流群2】:892666282]&#40;https://qm.qq.com/q/5mYiy6Yxh0&#41;)\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]&#40;https://github.com/ArvinLovegood/go-stock/releases&#41;)\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]&#40;https://github.com/ArvinLovegood/go-stock/releases&#41;)\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,海量热门源项目镜像一键部署,[注册链接]&#40;https://www.compshare.cn/image-community?ytag=GPU_YY-gh_gostock&#41;)\n- 火山方舟:新用户每个模型注册即送50万tokens,[注册链接](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=IJSE43PZ)\n\n[//]: # (- Tushare大数据开放社区,免费提供各类金融数据,助力行业和量化研究&#40;注意:Tushare只需要120积分即可,注册完成个人资料补充即可得120积分!!!&#41;,[注册链接]&#40;https://tushare.pro/register?reg=701944&#41;)\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![img_1.png](build/screenshot/img16.png)\n- 2025.11.21 新增带频率权重的情感分析功能\n![img_1.png](build/screenshot/img15.png)\n- 2025.04.25 市场资讯支持AI分析和总结:让AI帮你读市场!\n![img.png](img.png)\n- 2025.04.24 新增市场行情模块:即时掌握全球市场行情资讯/动态,从此再也不用偷摸去各大财经网站啦。go-stock一键帮你搞定!\n![img.png](build/screenshot/img13.png)\n![img_13.png](build/screenshot/img_13.png)\n- ![img_14.png](build/screenshot/img_14.png)\n- 2025.01.17 新增AI大模型分析股票功能\n ![img_5.png](build/screenshot/img.png)\n## 📸 功能截图\n![img_1.png](build/screenshot/img_6.png)\n### 设置\n![img_12.png](build/screenshot/img_4.png)\n### 成本设置\n![img.png](build/screenshot/img_7.png)\n### 日K\n![img_12.png](build/screenshot/img_12.png)\n### 分时\n![img_3.png](build/screenshot/img_9.png)\n### 钉钉报警通知\n![img_4.png](build/screenshot/img_5.png)\n### AI分析股票\n![img_5.png](build/screenshot/img.png)\n### 版本信息提示\n![img_11.png](build/screenshot/img_11.png)\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![Alt](https://repobeats.axiom.co/api/embed/40b07d415a42c2264a18c4fe1b6f182ff1470687.svg \"Repobeats analytics image\")\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"}}}}