Compare commits

..

85 Commits

Author SHA1 Message Date
fuzhongyun b61bbe4ede chore: 权限问题 debug 2026-01-04 17:34:03 +08:00
fuzhongyun 3045790dd5 chore: 权限问题 debug 2026-01-04 17:23:17 +08:00
renzhiyuan d4a79bb915 Merge remote-tracking branch 'origin/master' 2026-01-04 17:22:51 +08:00
renzhiyuan f85bbc1fad feat: 增加销量下滑分析报表功能 2026-01-04 17:22:49 +08:00
fuzhongyun 3d8678566e chore: 权限问题 debug 2026-01-04 17:17:21 +08:00
renzhiyuan 1ca305b3a0 Merge remote-tracking branch 'origin/master' 2026-01-04 17:16:55 +08:00
renzhiyuan 2df24a5ff3 feat: 增加销量下滑明细分析功能 2026-01-04 17:16:38 +08:00
fuzhongyun bfbaee2028 chore: 权限问题 debug 2026-01-04 17:12:19 +08:00
renzhiyuan 8271e7941d Merge remote-tracking branch 'origin/master' 2026-01-04 11:25:12 +08:00
renzhiyuan 8fb5d1b038 fix: 优化报表标题生成逻辑 2026-01-04 11:25:03 +08:00
fuzhongyun 8919a296e6 fix: 优化一版获取统一登录token 2026-01-04 11:22:06 +08:00
renzhiyuan 63ddd84a43 fix: 优化报表标题生成逻辑 2026-01-04 11:20:00 +08:00
renzhiyuan c6fda17b87 fix: 优化报表标题生成逻辑 2026-01-04 11:06:51 +08:00
fuzhongyun 31b131f1db feat: 增加获取统一登录token
的方法
2026-01-04 10:52:45 +08:00
renzhiyuan a3ec04a3ce fix: 优化报表标题生成逻辑 2026-01-04 09:37:13 +08:00
renzhiyuan 62d22c1405 fix: 优化销售分析数据处理逻辑 2026-01-01 16:24:06 +08:00
renzhiyuan 08c2094940 Merge remote-tracking branch 'origin/v4' into v4 2025-12-31 18:29:07 +08:00
renzhiyuan 6323612d8b fix: 优化销售分析数据处理逻辑 2025-12-31 18:29:00 +08:00
fuzhongyun c63b01ba6d fix: 1. pass Profit < -100 记录 2.更新模板 2025-12-31 18:21:50 +08:00
renzhiyuan 8d9794a93c fix: 调整销售分析黑名单过滤逻辑 2025-12-31 18:12:55 +08:00
fuzhongyun 877403fc97 feat: add tool 2025-12-31 17:37:54 +08:00
renzhiyuan 4e974efc1c fix: 调整销售分析与排行榜功能 2025-12-31 17:36:18 +08:00
renzhiyuan c9ca9d97e7 fix: 调整定时任务配置与群组ID 2025-12-31 17:26:16 +08:00
renzhiyuan b76bd90508 fix: 调整定时任务配置与群组ID 2025-12-31 17:24:27 +08:00
renzhiyuan f9390f7495 Merge branch 'v4' 2025-12-31 17:24:08 +08:00
renzhiyuan d908757fac fix: 调整定时任务配置与群组ID 2025-12-31 17:23:57 +08:00
renzhiyuan 84dae06187 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	internal/services/cron.go
2025-12-31 17:21:14 +08:00
renzhiyuan 0b02343d9f fix: 调整定时任务配置与群组ID 2025-12-31 17:20:39 +08:00
fuzhongyun 7c783fa524 fix: 调整配置 2025-12-31 17:15:42 +08:00
fuzhongyun bcfdeade46 fix: 调整配置 2025-12-31 17:10:27 +08:00
fuzhongyun 782ab77603 Merge branch 'v4' 2025-12-31 17:07:57 +08:00
fuzhongyun 8dddf61926 chore: gitignore 2025-12-31 17:02:34 +08:00
fuzhongyun 39776dfcce fix: 调整配置,打开定时任务 2025-12-31 17:01:16 +08:00
fuzhongyun fc2499d627 feat: 增加线上配置 2025-12-31 16:21:05 +08:00
fuzhongyun 06606539d2 feat: 增加分销商黑名单 2025-12-31 16:10:08 +08:00
fuzhongyun 2d725ecd53 fix: 修改模板路径 2025-12-31 15:55:08 +08:00
fuzhongyun ad3c5bc4b1 fix: 优化excel数据生成方法,支持动态title 2025-12-31 15:44:26 +08:00
renzhiyuan f497872d51 Merge remote-tracking branch 'origin/v4' into v4 2025-12-31 15:42:48 +08:00
renzhiyuan 603a4fdb0e feat: 优化报表系统功能与配置 2025-12-31 15:42:26 +08:00
fuzhongyun d32df33478 fix:调整模板 2025-12-31 14:31:58 +08:00
fuzhongyun 36d9d3f363 fix: 1.降低图片生成倍率 2.调整模板 2025-12-31 14:15:43 +08:00
fuzhongyun e77da7875e feat: 增加电商充值系统我们的商品统计 2025-12-31 12:02:31 +08:00
renzhiyuan 86713dbb1a feat: 优化报表功能与商品配置 2025-12-31 11:21:14 +08:00
fuzhongyun 5d120a6c05 fix:包名改动 2025-12-31 10:26:17 +08:00
fuzhongyun 212b7cd860 Merge branch 'analysis' into v4 2025-12-31 10:18:49 +08:00
fuzhongyun c965112c16 feat:追加配置 2025-12-31 10:18:12 +08:00
fuzhongyun b39d58280c fix: 增加我们的商品统计工作流 2025-12-31 10:17:46 +08:00
renzhiyuan b22f4550ef feat: 优化报表系统功能与配置 2025-12-31 09:30:40 +08:00
fuzhongyun 571b9a88f4 feat: 增加电商充值系统-我们的商品统计工具 2025-12-30 18:31:20 +08:00
fuzhongyun f7afef05f7 Merge branch 'v4' into analysis 2025-12-30 17:35:04 +08:00
fuzhongyun 04c67f86d0 fix: 模板调整 2025-12-30 17:34:51 +08:00
renzhiyuan 35bdd4fb43 Merge remote-tracking branch 'origin/v4' into v4
# Conflicts:
#	internal/tools/bbxt/bbxt.go
2025-12-30 16:50:23 +08:00
renzhiyuan c77e6da296 feat: 添加利润排行和商品统计功能 2025-12-30 16:49:53 +08:00
fuzhongyun 87b9599ef8 feat: 1.增加oss上传方法 2.报表系统截图上传oss 2025-12-30 16:17:35 +08:00
wuchao 8576778bff Merge branch 'v4' of ssh://gitea.cdlsxd.cn:222/fuzhongyun/ai_scheduler into v4 2025-12-30 14:46:04 +08:00
wuchao 1db689bcd1 fix(zltx): 修改订单统计工具中number字段类型为interface{} 2025-12-30 14:44:36 +08:00
fuzhongyun a665dea13d feat: 增加excel2picPy生成图片方法 2025-12-30 12:00:22 +08:00
fuzhongyun ff6f97aa37 feat: 增加负利润导出V2版本,合并单元格版本 2025-12-30 10:53:53 +08:00
renzhiyuan 6fa91e6e43 Merge branch 'master' into v4
# Conflicts:
#	internal/tools/bbxt/bbxt_test.go
2025-12-30 10:23:02 +08:00
fuzhongyun 3a77e0e32b fix: 使用额外文件 2025-12-30 10:22:19 +08:00
fuzhongyun 37a8318814 fix:1.仅分销商导出也使用模板 2.修改分销商、分销商详情模板 3.增加日志 2025-12-30 09:36:37 +08:00
fuzhongyun 8deb91dea9 feat: 增加分销商负利润详情excel填充 2025-12-30 09:15:58 +08:00
renzhiyuan f6b5d71a05 fix: 重构代码结构,优化定时任务 2025-12-29 21:11:34 +08:00
renzhiyuan 1fbfe8eae2 fix: 重构bbxt数据处理逻辑 2025-12-29 17:58:13 +08:00
renzhiyuan 0d8ef7056c fix: 添加bbxt工具包和数据处理功能 2025-12-29 17:11:42 +08:00
fuzhongyun 0f0da660ba chore: 暂存一版 2025-12-29 14:59:34 +08:00
renzhiyuan a9a05c19e9 fix: 修改订单号参数类型为interface 2025-12-27 15:41:57 +08:00
renzhiyuan 965719774a fix: 修改订单号参数类型为interface 2025-12-27 15:35:06 +08:00
renzhiyuan d52fd23ecc fix: 修改订单号参数类型为interface 2025-12-27 15:33:34 +08:00
renzhiyuan b02b988c5d fix: 修改订单号参数类型为interface 2025-12-27 15:33:11 +08:00
renzhiyuan 41e128683d fix: 修改订单号参数类型为interface 2025-12-27 15:27:49 +08:00
renzhiyuan 8ff37be7b5 Merge remote-tracking branch 'origin/master' 2025-12-27 11:43:55 +08:00
renzhiyuan 1ab8d21d1c fix: 修改订单号参数类型为interface 2025-12-27 11:43:51 +08:00
fuzhongyun 65a7e5dad2 fix: 需求收集bot优化 2025-12-27 11:36:17 +08:00
renzhiyuan 82f58efc1a fix: 修改订单号参数类型为interface 2025-12-27 11:31:09 +08:00
renzhiyuan 7732f8b0ed fix: 修改订单号参数类型为interface 2025-12-27 11:27:13 +08:00
renzhiyuan 54a098a254 fix: 修改订单号参数类型为interface 2025-12-27 11:25:53 +08:00
renzhiyuan c3242c9f08 Merge remote-tracking branch 'origin/master' 2025-12-27 11:18:51 +08:00
renzhiyuan 7c13f1668d fix: 更新ollama base url配置 2025-12-27 11:18:42 +08:00
fuzhongyun 1e076380b5 Merge branch 'test' 2025-12-27 11:16:41 +08:00
fuzhongyun fb934214fe fix: 处理大小写问题 2025-12-27 11:04:15 +08:00
renzhiyuan 3975086ff4 fix: 更新ollama base url配置 2025-12-27 10:52:43 +08:00
renzhiyuan a6fec7c0dc Merge branch 'v3' 2025-12-27 10:45:04 +08:00
renzhiyuan beb2e3fcf4 fix: 更新ollama base url配置 2025-12-26 16:23:24 +08:00
fuzhongyun 6b2894b95c fix: 更新 ollama base url 2025-12-26 16:13:11 +08:00
76 changed files with 3885 additions and 599 deletions

4
.gitignore vendored
View File

@ -4,4 +4,6 @@
docs
cmd/server/wire_gen.go
__debug*
.bin/
.bin/
.idea/
cache/

View File

@ -13,6 +13,7 @@ func main() {
configPath := flag.String("config", "./config/config_test.yaml", "Path to configuration file")
onBot := flag.String("bot", "", "bot start")
flag.Parse()
ctx := context.Background()
bc, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
@ -25,7 +26,9 @@ func main() {
defer func() {
cleanup()
}()
app.DingBotServer.Run(context.Background(), *onBot)
//钉钉机器人
app.DingBotServer.Run(ctx, *onBot)
//定时任务
app.Cron.Run(ctx)
log.Fatal(app.HttpServer.Listen(fmt.Sprintf(":%d", bc.Server.Port)))
}

View File

@ -43,6 +43,12 @@ redis:
db:
driver: mysql
source: root:SD###sdf323r343@tcp(121.199.38.107:3306)/sys_ai?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
oss:
access_key: "LTAI5tGGZzjf3tvqWk8SQj2G"
secret_key: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq"
bucket: "attachment-public"
domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com"
endpoint: "https://oss-cn-hangzhou.aliyuncs.com"
tools:
zltxOrderDetail:
@ -118,12 +124,19 @@ eino_tools:
# 货易通商品品牌查询
hytGoodsBrandSearch:
base_url: "https://hyt.86698.cn/admin_upload/api/v1/goods/brand/list"
# == 电商充值系统 ==
# 我们的商品统计
rechargeStatisticsOursProduct:
base_url: "http://admin.lanseds.cn/admin/statistics/oursProduct"
api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzY3MTc5ODgzLCJuYmYiOjE3NjcxNjkwODMsImp0aSI6IjEiLCJQaG9uZSI6IjE4MjAwMTYwMTQzIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MiwiR3JvdXBDb2RlcyI6IkxTWEREU19TWVNURU0sU1RBVElTVElDQUxTWVNURU1fQURNSU4sUEhZU0lDQUxHT09EU19BRE1JTiIsIkRpbmdVc2VySWQiOiIifQ.ELNF1Iv6yEwA12nCbXGKwXCw-F5Gq4GI2t2nqo1PlSkFdQ5Oz5s5NwV0RUXA66LxCggI-9IjBtFI1MvBHpvTHq9QRlm-HKzVTMcOBkEtKEfCCI6SPKVTAZyntTJlWPKG3u-CJUotT5YW0j2rU1VcpA7uGEiY7gs5VPUOZ80R1uGJ7HBSqVI2DRqar6STa1xryygdCjK7qamUM2d6aJ6r9VPTBt-JO6dkDdw3KHs3wl-PGM3wcbXHZ2aC18WFd_PxLmtjqErpxTEkdUBCnUHOSKDePG0henDJq71Nh3yRdRmY9VvszHMyIxJA2BVGPIPUT_Y5aewaaEMQVEjhiBnn-Q"
excel2pic:
base_url: "http://192.168.6.109:8010/api/v1/convert"
dingtalk:
api_key: "dingsbbntrkeiyazcfdg"
api_secret: "ObqxwyR20r9rVNhju0sCPQyQA98_FZSc32W4vgxnGFH_b02HZr1BPCJsOAF816nu"
table_demand:
url: "https://alidocs.dingtalk.com/i/nodes/YQBnd5ExVE6qAbnOiANQg2KKJyeZqMmz"
url: "https://alidocs.dingtalk.com/i/nodes/2Amq4vjg89RnYx9DTp66m2orW3kdP0wQ"
base_id: "2Amq4vjg89RnYx9DTp66m2orW3kdP0wQ"
sheet_id_or_name: "数据表"

View File

@ -23,6 +23,13 @@ coze:
base_url: "https://api.coze.cn"
api_secret: "sat_AqvFcdNgesP8megy1ItTscWFXRcsHRzmM4NJ1KNavfcdT0EPwYuCPkDqGhItpx13"
lsxd:
# 统一登录
login_url: "http://api.test.user.1688sup.com/v1/login/phone"
phone: "0zmINhJBwsDJYTmeaxXK4A=="
password: "LSSGoWhc63NpZc1rq3LT8g=="
check_token_url: "http://api.test.user.1688sup.com/v1/user/welcome"
sys:
session_len: 6
@ -43,6 +50,12 @@ redis:
db:
driver: mysql
source: root:SD###sdf323r343@tcp(121.199.38.107:3306)/sys_ai_test?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
oss:
access_key: "LTAI5tGGZzjf3tvqWk8SQj2G"
secret_key: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq"
bucket: "attachment-public"
domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com"
endpoint: "https://oss-cn-hangzhou.aliyuncs.com"
tools:
zltxOrderDetail:
@ -78,6 +91,7 @@ tools:
# eino tool 配置
eino_tools:
# == 货易通 hyt ==
# 货易通商品上传
hytProductUpload:
base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/supplier/batch/add/complete"
@ -104,6 +118,23 @@ eino_tools:
# 货易通商品品牌查询
hytGoodsBrandSearch:
base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/brand/list"
# == 报表分析 data analytics ==
# 负利润分析列表
daOursProductLoss:
base_url: "https://reportapi.1688sup.com/api/dataanalytics/statisOursProductLossSum"
# 利润同比排行榜
daProfitRanking:
base_url: "https://reportapi.1688sup.com/api/dataanalytics/profitRankingSum"
# 销售同比分析列表
daOfficialProduct:
base_url: "https://reportapi.1688sup.com/api/dataanalytics/statisOfficialProduct"
# == 电商充值系统 ==
# 我们的商品统计
rechargeStatisticsOursProduct:
base_url: "http://admin.1688sup.cn:8001/admin/statistics/oursProduct"
api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzY3MDc3NzcwLCJuYmYiOjE3NjcwNzU5NzAsImp0aSI6IjEiLCJQaG9uZSI6IjE4MDAwMDAwMDAwIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MSwiR3JvdXBDb2RlcyI6IlZDTF9DQVNISUVSLFZDTF9PUEVSQVRFLFZDTF9BRE1JTixWQ0xfQUFBLFZDTF9WQ0xfT1BFUkFULFZDTF9JTlZPSUNFLENSTV9BRE1JTixMSUFOTElBTl9BRE1JTixNQVJLRVRNQUcyX0FETUlOLFBIT05FQklMTF9BRE1JTixRSUFOWkhVX1NVUFBFUl9BRE0sTUFSS0VUSU5HU0FBU19TVVBFUkFETUlOLENBUkRfQ09ERSxDQVJEX1BST0NVUkVNRU5ULE1BUktFVElOR1NZU1RFTV9TVVBFUixTVEFUSVNUSUNBTFNZU1RFTV9BRE1JTixaTFRYX0FETUlOLFpMVFhfT1BFUkFURSIsIkRpbmdVc2VySWQiOiIxNjIwMjYxMjMwMjg5MzM4MzQifQ.Nuw_aR6iSPmhhh9E5rhyTxHBsgWtaTZvbnc7SFTnUBJXTQvYahnk0LyZaVpsVw6FB3cU0F5xOdX3rmGyWyaiszWO6yi-o1oxGMXwhf39fMiWT2xUI6pAn9Ync8DmZ4tOMCNUTdEk4CaQFzrTwJs0c-VR4yW6LgoPmNPvUVZ-KwmusUpnPz5j9RsJItzIWE3bpGGsfB54e2UERcZdbo9BXxCZIBbpAYKBKdl73KuI8SNaXgKvGTrJ6hEN4ESpnbisJVwT5pp_kuChJlcfjHTHFsEf4RJDjN9gTrtDbBWZyY3OmO2ukqYAM7tZPs6TXJwvQGJQsFRVZUBGxS1nD_6DzQ"
excel2pic:
base_url: "http://192.168.6.109:8010/api/v1/convert"
dingtalk:
api_key: "dingsbbntrkeiyazcfdg"

View File

@ -4,7 +4,7 @@ server:
host: "0.0.0.0"
ollama:
base_url: "http://host.docker.internal:11434"
base_url: "http://127.0.0.1:11434"
model: "qwen3-coder:480b-cloud"
generate_model: "qwen3-coder:480b-cloud"
mapping_model: "deepseek-v3.2:cloud"
@ -43,6 +43,12 @@ redis:
db:
driver: mysql
source: root:SD###sdf323r343@tcp(121.199.38.107:3306)/sys_ai_test?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
oss:
access_key: "LTAI5tGGZzjf3tvqWk8SQj2G"
secret_key: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq"
bucket: "attachment-public"
domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com"
endpoint: "https://oss-cn-hangzhou.aliyuncs.com"
tools:
zltxOrderDetail:
@ -118,6 +124,13 @@ eino_tools:
# 货易通商品品牌查询
hytGoodsBrandSearch:
base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/brand/list"
# == 电商充值系统 ==
# 我们的商品统计
rechargeStatisticsOursProduct:
base_url: "http://admin.lanseds.cn/admin/statistics/oursProduct"
api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzY3MTc5ODgzLCJuYmYiOjE3NjcxNjkwODMsImp0aSI6IjEiLCJQaG9uZSI6IjE4MjAwMTYwMTQzIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MiwiR3JvdXBDb2RlcyI6IkxTWEREU19TWVNURU0sU1RBVElTVElDQUxTWVNURU1fQURNSU4sUEhZU0lDQUxHT09EU19BRE1JTiIsIkRpbmdVc2VySWQiOiIifQ.ELNF1Iv6yEwA12nCbXGKwXCw-F5Gq4GI2t2nqo1PlSkFdQ5Oz5s5NwV0RUXA66LxCggI-9IjBtFI1MvBHpvTHq9QRlm-HKzVTMcOBkEtKEfCCI6SPKVTAZyntTJlWPKG3u-CJUotT5YW0j2rU1VcpA7uGEiY7gs5VPUOZ80R1uGJ7HBSqVI2DRqar6STa1xryygdCjK7qamUM2d6aJ6r9VPTBt-JO6dkDdw3KHs3wl-PGM3wcbXHZ2aC18WFd_PxLmtjqErpxTEkdUBCnUHOSKDePG0henDJq71Nh3yRdRmY9VvszHMyIxJA2BVGPIPUT_Y5aewaaEMQVEjhiBnn-Q"
excel2pic:
base_url: "http://192.168.6.109:8010/api/v1/convert"
dingtalk:
api_key: "dingsbbntrkeiyazcfdg"
@ -188,7 +201,3 @@ llm:
temperature: 0.7
max_tokens: 4096
stream: true
#ding_talk_bots:
# public:
# client_id: "dingchg59zwwvmuuvldx",
# client_secret: "ZwetAnRiTQobNFVlNrshRagSMAJIFpBAepWkWI7on7Tt_o617KHtTjBLp8fQfplz",

View File

@ -14,7 +14,7 @@ fi
CONFIG_FILE="config/config.yaml"
BRANCH="master"
BOT="ALL"
BOT="All"
if [ "$MODE" = "dev" ]; then
CONFIG_FILE="config/config_test.yaml"
BOT="zltx"
@ -32,9 +32,13 @@ docker rm -f ${CONTAINER_NAME}
docker run -itd \
--name "${CONTAINER_NAME}" \
--restart=always \
--add-host=host.docker.internal:host-gateway \
-e "OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}" \
-e "MODE=${MODE}" \
-p 8090:8090 \
"${CONTAINER_NAME}" ./server --config "./${CONFIG_FILE}" --bot "./${BOT}"
-v ./cache:/app/cache \
-v ./tmpl:/app/tmpl \
-v ./go.mod:/app/go.mod \
"${CONTAINER_NAME}" ./server --config "./${CONFIG_FILE}" --bot "${BOT}"
docker logs -f ${CONTAINER_NAME}

26
go.mod
View File

@ -17,7 +17,7 @@ require (
github.com/faabiosr/cachego v0.26.0
github.com/fastwego/dingding v1.0.0-beta.4
github.com/gabriel-vasile/mimetype v1.4.11
github.com/go-kratos/kratos/v2 v2.9.1
github.com/go-kratos/kratos/v2 v2.9.2
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.20.0
@ -25,11 +25,13 @@ require (
github.com/gofiber/websocket/v2 v2.2.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/json-iterator/go v1.1.12
github.com/ollama/ollama v0.12.7
github.com/redis/go-redis/v9 v9.16.0
github.com/spf13/viper v1.17.0
github.com/tmc/langchaingo v0.1.13
golang.org/x/sync v0.15.0
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/sync v0.17.0
google.golang.org/grpc v1.64.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.0
@ -37,12 +39,14 @@ require (
)
require (
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/gateway-dingtalk v1.0.2 // indirect
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/aliyun/credentials-go v1.4.6 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
@ -69,7 +73,6 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@ -86,33 +89,40 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

51
go.sum
View File

@ -35,6 +35,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
@ -92,6 +94,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.6 h1:ZkmUlhlQbaDC+Eba/GARMPy6hKdCLi
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
@ -193,6 +197,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kratos/kratos/v2 v2.9.1 h1:EGif6/S/aK/RCR5clIbyhioTNyoSrii3FC118jG40Z0=
github.com/go-kratos/kratos/v2 v2.9.1/go.mod h1:a1MQLjMhIh7R0kcJS9SzJYR43BRI7EPzzN0J1Ksu2bA=
github.com/go-kratos/kratos/v2 v2.9.2 h1:px8GJQBeLpquDKQWQ9zohEWiLA8n4D/pv7aH3asvUvo=
github.com/go-kratos/kratos/v2 v2.9.2/go.mod h1:Jc7jaeYd4RAPjetun2C+oFAOO7HNMHTT/Z4LxpuEDJM=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -368,8 +374,15 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
@ -381,6 +394,8 @@ github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWR
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@ -423,6 +438,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@ -440,6 +457,12 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -479,8 +502,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -495,6 +518,8 @@ golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrC
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -561,8 +586,8 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -584,8 +609,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -640,8 +665,8 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -650,8 +675,8 @@ golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -664,11 +689,13 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

View File

@ -7,14 +7,19 @@ import (
"ai_scheduler/internal/data/constants"
"ai_scheduler/internal/data/impl"
"ai_scheduler/internal/data/model"
"ai_scheduler/internal/domain/workflow/recharge"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/entitys"
"ai_scheduler/internal/pkg/l_request"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/internal/tools"
"ai_scheduler/internal/tools/bbxt"
"ai_scheduler/tmpl/dataTemp"
"io"
"net/http"
"strconv"
"time"
"unicode"
"ai_scheduler/internal/config"
"context"
@ -32,18 +37,20 @@ import (
// AiRouterBiz 智能路由服务
type DingTalkBotBiz struct {
do *do.Do
handle *do.Handle
botConfigImpl *impl.BotConfigImpl
replier *chatbot.ChatbotReplier
log log.Logger
dingTalkUser *dingtalk.User
botTools []model.AiBotTool
botGroupImpl *impl.BotGroupImpl
toolManager *tools.Manager
chatHis *impl.BotChatHisImpl
conf *config.Config
cardSend *dingtalk.SendCardClient
do *do.Do
handle *do.Handle
botConfigImpl *impl.BotConfigImpl
replier *chatbot.ChatbotReplier
log log.Logger
dingTalkUser *dingtalk.User
botTools []model.AiBotTool
botGroupImpl *impl.BotGroupImpl
toolManager *tools.Manager
chatHis *impl.BotChatHisImpl
conf *config.Config
cardSend *dingtalk.SendCardClient
ossClient *utils_oss.Client
workflowManager *runtime.Registry
}
// NewDingTalkBotBiz
@ -58,19 +65,23 @@ func NewDingTalkBotBiz(
toolManager *tools.Manager,
conf *config.Config,
cardSend *dingtalk.SendCardClient,
ossClient *utils_oss.Client,
workflowManager *runtime.Registry,
) *DingTalkBotBiz {
return &DingTalkBotBiz{
do: do,
handle: handle,
botConfigImpl: botConfigImpl,
replier: chatbot.NewChatbotReplier(),
dingTalkUser: dingTalkUser,
botTools: tools.BootTools,
botGroupImpl: botGroupImpl,
toolManager: toolManager,
chatHis: chatHis,
conf: conf,
cardSend: cardSend,
do: do,
handle: handle,
botConfigImpl: botConfigImpl,
replier: chatbot.NewChatbotReplier(),
dingTalkUser: dingTalkUser,
botTools: tools.BootTools,
botGroupImpl: botGroupImpl,
toolManager: toolManager,
chatHis: chatHis,
conf: conf,
cardSend: cardSend,
ossClient: ossClient,
workflowManager: workflowManager,
}
}
@ -137,10 +148,14 @@ func (d *DingTalkBotBiz) handleSingleChat(ctx context.Context, requireData *enti
func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entitys.RequireDataDingTalkBot) (err error) {
group, err := d.initGroup(ctx, requireData.Req.ConversationId, requireData.Req.ConversationTitle, requireData.Req.RobotCode)
//宏
err, isFinal := d.Macro(ctx, requireData, group)
if err != nil {
return
}
if isFinal {
return
}
requireData.ID = group.GroupID
groupTools, err := d.getGroupTools(ctx, group)
if err != nil {
@ -151,7 +166,60 @@ func (d *DingTalkBotBiz) handleGroupChat(ctx context.Context, requireData *entit
return
}
return d.handleMatch(ctx, rec)
return d.handleMatch(ctx, rec, group)
}
func (d *DingTalkBotBiz) Macro(ctx context.Context, requireData *entitys.RequireDataDingTalkBot, group *model.AiBotGroup) (err error, isFinish bool) {
content := processString(requireData.Req.Text.Content)
if strings.Contains(content, "[利润同比报表]商品修改:") {
// 提取冒号后的内容
if parts := strings.SplitN(content, "", 2); len(parts) == 2 {
itemInfo := strings.TrimSpace(parts[1])
log.Infof("商品修改信息: %s", itemInfo)
group.ProductName = itemInfo
cond := builder.NewCond()
cond = cond.And(builder.Eq{"group_id": group.GroupID})
err = d.botGroupImpl.UpdateByCond(&cond, group)
if err != nil {
entitys.ResText(requireData.Ch, "", fmt.Sprintf("修改失败:%v", err))
}
entitys.ResText(requireData.Ch, "", "修改成功")
isFinish = true
return
}
}
if strings.Contains(content, "[利润同比报表]商品列表") {
// 提取冒号后的内容
if len(group.ProductName) == 0 {
entitys.ResText(requireData.Ch, "", "暂未设置")
} else {
entitys.ResText(requireData.Ch, "", group.ProductName)
isFinish = true
}
return
}
return
}
func processString(s string) string {
// 1. 替换中文逗号为英文逗号
s = strings.ReplaceAll(s, "", ",")
// 2. 过滤控制字符(如 \n, \t, \r 等)
var result []rune
for _, char := range s {
// 判断是否是控制字符ASCII < 32 或 = 127
if !unicode.IsControl(char) {
// 如果需要完全移除 \n 和 \t可以改成
// if !unicode.IsControl(char)
result = append(result, char)
}
}
return string(result)
}
func (d *DingTalkBotBiz) initGroup(ctx context.Context, conversationId string, conversationTitle string, robotCode string) (group *model.AiBotGroup, err error) {
@ -287,7 +355,7 @@ func (d *DingTalkBotBiz) getUserContent(msgType string, msgContent interface{})
return
}
func (d *DingTalkBotBiz) handleMatch(ctx context.Context, rec *entitys.Recognize) (err error) {
func (d *DingTalkBotBiz) handleMatch(ctx context.Context, rec *entitys.Recognize, group *model.AiBotGroup) (err error) {
if !rec.Match.IsMatch {
if len(rec.Match.Chat) != 0 {
@ -312,6 +380,8 @@ func (d *DingTalkBotBiz) handleMatch(ctx context.Context, rec *entitys.Recognize
switch constants.TaskType(pointTask.Type) {
case constants.TaskTypeFunc:
return d.handleTask(ctx, rec, pointTask)
case constants.TaskTypeReport:
return d.handleReport(ctx, rec, pointTask, group)
case constants.TaskTypeCozeWorkflow:
return d.handleCozeWorkflow(ctx, rec, pointTask)
default:
@ -420,6 +490,94 @@ func handleCozeWorkflowEvents(ctx context.Context, resp coze.Stream[coze.Workflo
fmt.Printf("done, log:%s\n", resp.Response().LogID())
}
func (d *DingTalkBotBiz) handleReport(ctx context.Context, rec *entitys.Recognize, task *model.AiBotTool, group *model.AiBotGroup) error {
var configData entitys.ConfigDataReport
err := json.Unmarshal([]byte(rec.Match.Parameters), &configData)
if err != nil {
return err
}
t, err := time.Parse(time.DateTime, configData.Time)
if err != nil {
t, err = time.Parse("2006-01-02 15:04", configData.Time)
if err != nil {
t, err = time.Parse("2006-01-02", configData.Time)
if err != nil {
log.Infof("时间识别失败:%s", configData.Time)
entitys.ResText(rec.Ch, "", "时间识别失败了可以给我一份比较具体的时间吗例如“2025-12-31 12:00,抱歉抱歉😀")
}
}
}
rep, err := bbxt.NewBbxtTools()
uploader := bbxt.NewUploader(d.ossClient)
if err != nil {
return err
}
var reports []*bbxt.ReportRes
switch rec.Match.Index {
case "report_loss_analysis":
repo, _err := rep.StatisOursProductLossSum(t)
if _err != nil {
return _err
}
reports = append(reports, repo...)
case "report_sales_analysis":
product := strings.Split(group.ProductName, ",")
repo, _err := rep.GetStatisOfficialProductSum(t, product)
if _err != nil {
return _err
}
reports = append(reports, repo)
case "report_ranking_of_distributors":
repo, _err := rep.GetProfitRankingSum(t)
if _err != nil {
return _err
}
reports = append(reports, repo)
case "report_daily":
product := strings.Split(group.ProductName, ",")
repo, _err := rep.DailyReport(t, bbxt.DownWardValue, product, bbxt.SumFilter, nil)
if _err != nil {
return _err
}
reports = append(reports, repo...)
case "report_daily_recharge":
product := strings.Split(group.ProductName, ",")
repo, _err := d.rechargeDailyReport(ctx, t, product, nil)
if _err != nil || len(repo) == 0 {
return _err
}
reports = append(reports, repo...)
case "report_sale_down_analysis":
product := strings.Split(group.ProductName, ",")
repo, _err := rep.GetStatisOfficialProductSumDecline(t, bbxt.DownWardValue, product, bbxt.SumFilter)
if _err != nil {
return _err
}
reports = append(reports, repo)
default:
return fmt.Errorf("未找到的报表:%s", rec.Match.Index)
}
for _, report := range reports {
err = uploader.Run(report)
if err != nil {
log.Error(err)
continue
}
entitys.ResText(rec.Ch, "", fmt.Sprintf("%s![图片](%s)", report.Title, report.Url))
//rec.Ch <- report.Title
//reportChan <- fmt.Sprintf("![图片](%s)", report.Url)
//err = d.SendReport(ctx, group, report)
//if err != nil {
// log.Error(err)
// continue
//}
}
return nil
}
func (d *DingTalkBotBiz) handleTask(ctx context.Context, rec *entitys.Recognize, task *model.AiBotTool) (err error) {
var configData entitys.ConfigDataTool
err = json.Unmarshal([]byte(task.Config), &configData)
@ -470,6 +628,95 @@ func (d *DingTalkBotBiz) HandleStreamRes(ctx context.Context, data *chatbot.BotC
SenderStaffId: data.SenderStaffId,
Title: data.Text.Content,
})
return
}
func (d *DingTalkBotBiz) GetReportLists(ctx context.Context, group *model.AiBotGroup) (reports []*bbxt.ReportRes, err error) {
reportList, err := bbxt.NewBbxtTools()
if err != nil {
return
}
var product []string
if group.ProductName != "" {
product = strings.Split(group.ProductName, ",")
}
//[]string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}
reports, err = reportList.DailyReport(time.Now(), bbxt.DownWardValue, product, bbxt.SumFilter, d.ossClient)
if err != nil {
return
}
// 追加电商充值系统统计 - 返回统一使用 []*bbxt.ReportRes
//rechargeReports, err := d.rechargeDailyReport(ctx, time.Now(), product, d.ossClient)
//if err != nil || len(rechargeReports) == 0 {
// return
//}
//
//reports = append(reports, rechargeReports...)
return
}
// rechargeDailyReport 获取电商充值系统统计报告
func (d *DingTalkBotBiz) rechargeDailyReport(ctx context.Context, now time.Time, productNames []string, ossClient *utils_oss.Client) (reports []*bbxt.ReportRes, err error) {
defer func() {
if err := recover(); err != nil {
log.Error(err)
}
}()
workflowId := recharge.WorkflowIDStatisticsOursProduct
args := &runtime.WorkflowArgs{
Args: map[string]any{
"product_names": productNames,
"now": now,
},
}
res, err := d.workflowManager.Invoke(ctx, workflowId, args)
if err != nil {
return
}
log.Infof("imgUrl: %s", res["url"].(string))
reports = []*bbxt.ReportRes{
{
ReportName: "我们的商品统计(电商充值系统)",
Title: fmt.Sprintf("%s 电商充值系统我们的商品统计", now.Format("2006-01-02")),
Path: res["path"].(string),
Url: res["url"].(string),
Data: res["data"].([][]string),
Desc: res["desc"].(string),
},
}
return
}
func (d *DingTalkBotBiz) SendReport(ctx context.Context, groupInfo *model.AiBotGroup, report *bbxt.ReportRes) (err error) {
reportChan := make(chan string, 10)
defer close(reportChan)
reportChan <- report.Title
reportChan <- fmt.Sprintf("![图片](%s)", report.Url)
err = d.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{
RobotCode: groupInfo.RobotCode,
ConversationType: constants.ConversationTypeGroup,
ConversationId: groupInfo.ConversationID,
Text: chatbot.BotCallbackDataTextModel{
Content: report.ReportName,
},
}, reportChan)
return
}
func (d *DingTalkBotBiz) GetGroupInfo(ctx context.Context, groupId int) (group model.AiBotGroup, err error) {
cond := builder.NewCond()
cond = cond.And(builder.Eq{"group_id": groupId})
cond = cond.And(builder.Eq{"status": constants.Enable})
err = d.botGroupImpl.GetOneBySearchToStrut(&cond, &group)
return
}
@ -537,7 +784,7 @@ func (d *DingTalkBotBiz) SaveHis(ctx context.Context, requireData *entitys.Requi
}
func (d *DingTalkBotBiz) defaultPrompt() string {
now := time.Now().Format(time.DateTime)
return `[system] 你是一个智能路由系统核心职责是 **精准解析用户意图并路由至对应任务模块**请严格遵循以下规则
[rule]
1. **返回格式**
@ -560,5 +807,6 @@ func (d *DingTalkBotBiz) defaultPrompt() string {
4. 格式强制要求
-所有字段值必须是**字符串**包括 confidence
-parameters 必须是 **转义后的 JSON 字符串** "{\"product_name\": \"京东月卡\"}"`
-parameters 必须是 **转义后的 JSON 字符串** "{\"product_name\": \"京东月卡\"}"
当前时间` + now + `所有的时间识别精确到秒`
}

View File

@ -349,7 +349,7 @@ func (d *Do) LoadUserPermission(client *gateway.Client, requireData *entitys.Req
// 检查响应状态码
if res.StatusCode != http.StatusOK {
err = errors.SysErrf("获取用户权限失败")
err = errors.SysErr(fmt.Sprintf("获取用户权限失败,状态码:%d %s %s %s", res.StatusCode, res.Text, request.Url, request.Headers["Authorization"]))
return
}

View File

@ -249,6 +249,8 @@ func (r *Handle) handleKnowle(ctx context.Context, rec *entitys.Recognize, task
// bot 临时实现,后续转到 eino 工作流
func (r *Handle) handleBot(ctx context.Context, rec *entitys.Recognize, task *model.AiTask) (err error) {
if task.Index == "bug_optimization_submit" {
entitys.ResLoading(rec.Ch, task.Index, "需求记录中...")
// Ext 中获取 sessionId
sessionID := rec.GetSession()
// 获取dingtalk accessToken
@ -283,10 +285,12 @@ func (r *Handle) handleBot(ctx context.Context, rec *entitys.Recognize, task *mo
return errors.NewBusinessErr(422, "创建记录失败,请联系管理员")
}
entitys.ResLog(rec.Ch, task.Index, "需求记录完成")
// 构建跳转链接
detailPage := util.BuildJumpLink(r.conf.Dingtalk.TableDemand.Url, "去查看")
entitys.ResText(rec.Ch, "", fmt.Sprintf("问题已记录,正在分配相关人员处理,请您耐心等待处理结果。点击查看工单进度:%s", detailPage))
entitys.ResText(rec.Ch, task.Index, fmt.Sprintf("需求已记录,正在分配相关人员处理,请您耐心等待处理结果。点击查看工单进度:%s", detailPage))
return nil
}
@ -392,7 +396,7 @@ func (r *Handle) handleEinoWorkflow(ctx context.Context, rec *entitys.Recognize,
// 工作流内部输出
workflowId := task.Index
_, err = r.workflowManager.Invoke(ctx, workflowId, rec)
_, err = r.workflowManager.Invoke(ctx, workflowId, &runtime.WorkflowArgs{Recognize: rec})
if err != nil {
return err
}

View File

@ -8,6 +8,7 @@ import (
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/utils_vllm"
"context"
"fmt"
"strings"
"github.com/ollama/ollama/api"
@ -30,6 +31,7 @@ func (f *WithSys) CreatePrompt(ctx context.Context, rec *entitys.Recognize) (mes
if err != nil {
return nil, err
}
// 构建提示消息列表,包含系统提示、助手回复和用户内容
mes = append(prompt, api.Message{
Role: "system", // 系统角色
@ -41,7 +43,7 @@ func (f *WithSys) CreatePrompt(ctx context.Context, rec *entitys.Recognize) (mes
Role: "user", // 用户角色
Content: content.String(), // 用户输入内容
})
fmt.Printf("[意图识别]最终prompt:%v", mes)
return
}
@ -84,6 +86,7 @@ func (f *WithSys) getUserContent(ctx context.Context, rec *entitys.Recognize) (c
// 解析结果回写到file
file.FileRec = imageContent
content.WriteString(file.FileRec)
default:
content.WriteString(file.FileRec)
}

View File

@ -1,280 +0,0 @@
package dingtalk
import (
"ai_scheduler/internal/data/constants"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dingtalkcard_1_0 "github.com/alibabacloud-go/dingtalk/card_1_0"
dingtalkim_1_0 "github.com/alibabacloud-go/dingtalk/im_1_0"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
"github.com/gofiber/fiber/v2/log"
"github.com/google/uuid"
)
const DefaultInterval = 100 * time.Millisecond
const HeardBeatX = 50
type SendCardClient struct {
Auth *Auth
CardClient *sync.Map
mu sync.RWMutex // 保护 CardClient 的并发访问
logger log.AllLogger // 日志记录
botOption *Bot
}
func NewSendCardClient(auth *Auth, logger log.AllLogger) *SendCardClient {
return &SendCardClient{
Auth: auth,
CardClient: &sync.Map{},
logger: logger,
botOption: &Bot{},
}
}
// initClient 初始化或复用 DingTalk 客户端
func (s *SendCardClient) initClient(robotCode string) (*dingtalkcard_1_0.Client, error) {
if client, ok := s.CardClient.Load(robotCode); ok {
return client.(*dingtalkcard_1_0.Client), nil
}
s.botOption.BotCode = robotCode
config := &openapi.Config{
Protocol: tea.String("https"),
RegionId: tea.String("central"),
}
client, err := dingtalkcard_1_0.NewClient(config)
if err != nil {
s.logger.Error("failed to init DingTalk client")
return nil, fmt.Errorf("init client failed: %w", err)
}
s.CardClient.Store(robotCode, client)
return client, nil
}
func (s *SendCardClient) NewCard(ctx context.Context, cardSend *CardSend) error {
// 参数校验
if (len(cardSend.ContentSlice) == 0 || cardSend.ContentSlice == nil) && cardSend.ContentChannel == nil {
return errors.New("卡片内容不能为空")
}
if cardSend.UpdateInterval == 0 {
cardSend.UpdateInterval = DefaultInterval // 默认更新间隔
}
if cardSend.Title == "" {
cardSend.Title = "钉钉卡片"
}
//替换标题
cardSend.Template = constants.CardTemp(strings.Replace(string(cardSend.Template), "${title}", cardSend.Title, 1))
// 初始化客户端
client, err := s.initClient(cardSend.RobotCode)
if err != nil {
return fmt.Errorf("初始化client失败: %w", err)
}
// 生成卡片实例ID
cardInstanceId, err := uuid.NewUUID()
if err != nil {
return fmt.Errorf("创建uuid失败: %w", err)
}
// 构建初始请求
request, err := s.buildBaseRequest(cardSend, cardInstanceId.String())
if err != nil {
return fmt.Errorf("请求失败: %w", err)
}
// 发送初始卡片
if _, err := s.SendInteractiveCard(ctx, request, cardSend.RobotCode, client); err != nil {
return fmt.Errorf("发送初始卡片失败: %w", err)
}
// 处理切片内容(同步)
if len(cardSend.ContentSlice) > 0 {
if err := s.processContentSlice(ctx, cardSend, cardInstanceId.String(), client); err != nil {
return fmt.Errorf("内容同步失败: %w", err)
}
}
// 处理通道内容(异步)
if cardSend.ContentChannel != nil {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
s.processContentChannel(ctx, cardSend, cardInstanceId.String(), client)
}()
wg.Wait()
}
return nil
}
// buildBaseRequest 构建基础请求
func (s *SendCardClient) buildBaseRequest(cardSend *CardSend, cardInstanceId string) (*dingtalkcard_1_0.StreamingUpdateRequest, error) {
cardData := fmt.Sprintf(string(cardSend.Template), "") // 初始空内容
request := &dingtalkcard_1_0.StreamingUpdateRequest{
OutTrackId: tea.String("your-out-track-id"),
Guid: tea.String("0F714542-0AFC-2B0E-CF14-E2D39F5BFFE8"),
Key: tea.String("your-ai-param"),
Content: tea.String("test"),
IsFull: tea.Bool(false),
IsFinalize: tea.Bool(false),
IsError: tea.Bool(false),
}
switch cardSend.ConversationType {
case constants.ConversationTypeGroup:
request.SetOpenConversationId(cardSend.ConversationId)
case constants.ConversationTypeSingle:
receiver, err := json.Marshal(map[string]string{"userId": cardSend.SenderStaffId})
if err != nil {
return nil, fmt.Errorf("数据整理失败: %w", err)
}
request.SetSingleChatReceiver(string(receiver))
default:
return nil, errors.New("未知的聊天场景")
}
return request, nil
}
// processContentChannel 处理通道内容(异步更新)
func (s *SendCardClient) processContentChannel(ctx context.Context, cardSend *CardSend, cardInstanceId string, client *dingtalkim_1_0.Client) {
defer func() {
if r := recover(); r != nil {
s.logger.Error("panic in processContentChannel")
}
}()
ticker := time.NewTicker(cardSend.UpdateInterval)
defer ticker.Stop()
heartbeatTicker := time.NewTicker(time.Duration(HeardBeatX) * DefaultInterval)
defer heartbeatTicker.Stop()
var (
contentBuilder strings.Builder
lastUpdate time.Time
)
for {
select {
case content, ok := <-cardSend.ContentChannel:
if !ok {
// 通道关闭,发送最终内容
if contentBuilder.Len() > 0 {
if err := s.updateCardContent(ctx, cardSend, cardInstanceId, contentBuilder.String(), client); err != nil {
s.logger.Errorf("更新卡片失败1:%s", err.Error())
}
}
return
}
contentBuilder.WriteString(content)
if contentBuilder.Len() > 0 {
if err := s.updateCardContent(ctx, cardSend, cardInstanceId, contentBuilder.String(), client); err != nil {
s.logger.Errorf("更新卡片失败2%s", err.Error())
}
}
lastUpdate = time.Now()
case <-heartbeatTicker.C:
if time.Now().Unix()-lastUpdate.Unix() >= HeardBeatX {
return
}
case <-ctx.Done():
s.logger.Info("context canceled, stop channel processing")
return
}
}
}
// processContentSlice 处理切片内容(同步更新)
func (s *SendCardClient) processContentSlice(ctx context.Context, cardSend *CardSend, cardInstanceId string, client *dingtalkim_1_0.Client) error {
var contentBuilder strings.Builder
for _, content := range cardSend.ContentSlice {
contentBuilder.WriteString(content)
err := s.updateCardRequest(ctx, &UpdateCardRequest{
Template: string(cardSend.Template),
Content: contentBuilder.String(),
Client: client,
RobotCode: cardSend.RobotCode,
CardInstanceId: cardInstanceId,
})
if err != nil {
return fmt.Errorf("更新卡片失败: %w", err)
}
time.Sleep(cardSend.UpdateInterval) // 控制更新频率
}
return nil
}
// updateCardContent 封装卡片更新逻辑
func (s *SendCardClient) updateCardContent(ctx context.Context, cardSend *CardSend, cardInstanceId, content string, client *dingtalkim_1_0.Client) error {
err := s.updateCardRequest(ctx, &UpdateCardRequest{
Template: string(cardSend.Template),
Content: content,
Client: client,
RobotCode: cardSend.RobotCode,
CardInstanceId: cardInstanceId,
})
return err
}
func (s *SendCardClient) updateCardRequest(ctx context.Context, updateCardRequest *UpdateCardRequest) error {
updateRequest := &dingtalkim_1_0.UpdateRobotInteractiveCardRequest{
CardBizId: tea.String(updateCardRequest.CardInstanceId),
CardData: tea.String(fmt.Sprintf(updateCardRequest.Template, updateCardRequest.Content)),
}
_, err := s.UpdateInteractiveCard(ctx, updateRequest, updateCardRequest.RobotCode, updateCardRequest.Client)
return err
}
// UpdateInteractiveCard 更新交互卡片(封装错误处理)
func (s *SendCardClient) UpdateInteractiveCard(ctx context.Context, request *dingtalkim_1_0.UpdateRobotInteractiveCardRequest, robotCode string, client *dingtalkim_1_0.Client) (*dingtalkim_1_0.UpdateRobotInteractiveCardResponse, error) {
authInfo, err := s.Auth.GetTokenFromBotOption(ctx, WithBot(s.botOption))
if err != nil {
return nil, fmt.Errorf("get token failed: %w", err)
}
headers := &dingtalkim_1_0.UpdateRobotInteractiveCardHeaders{
XAcsDingtalkAccessToken: tea.String(authInfo.AccessToken),
}
response, err := client.UpdateRobotInteractiveCardWithOptions(request, headers, &util.RuntimeOptions{})
if err != nil {
return nil, fmt.Errorf("API call failed: %w,request:%v", err, request.String())
}
return response, nil
}
// SendInteractiveCard 发送交互卡片(封装错误处理)
func (s *SendCardClient) SendInteractiveCard(ctx context.Context, request *dingtalkim_1_0.SendRobotInteractiveCardRequest, robotCode string, client *dingtalkim_1_0.Client) (res *dingtalkim_1_0.SendRobotInteractiveCardResponse, err error) {
err = s.Auth.GetBotConfigFromModel(s.botOption)
if err != nil {
return nil, fmt.Errorf("初始化bot失败: %w", err)
}
authInfo, err := s.Auth.GetTokenFromBotOption(ctx, WithBot(s.botOption))
if err != nil {
return nil, fmt.Errorf("get token failed: %w", err)
}
headers := &dingtalkim_1_0.SendRobotInteractiveCardHeaders{
XAcsDingtalkAccessToken: tea.String(authInfo.AccessToken),
}
response, err := client.SendRobotInteractiveCardWithOptions(request, headers, &util.RuntimeOptions{})
if err != nil {
return nil, fmt.Errorf("API call failed: %w", err)
}
return response, nil
}

View File

@ -1,6 +1,7 @@
package config
import (
"ai_scheduler/pkg"
"fmt"
"time"
@ -13,17 +14,18 @@ type Config struct {
Ollama OllamaConfig `mapstructure:"ollama"`
Vllm VllmConfig `mapstructure:"vllm"`
Coze CozeConfig `mapstructure:"coze"`
LSXD LSXDConfig `mapstructure:"lsxd"`
Sys SysConfig `mapstructure:"sys"`
Tools ToolsConfig `mapstructure:"tools"`
EinoTools EinoToolsConfig `mapstructure:"eino_tools"`
Logging LoggingConfig `mapstructure:"logging"`
Redis Redis `mapstructure:"redis"`
DB DB `mapstructure:"db"`
Oss Oss `mapstructure:"oss"`
DefaultPrompt SysPrompt `mapstructure:"default_prompt"`
PermissionConfig PermissionConfig `mapstructure:"permissionConfig"`
LLM LLM `mapstructure:"llm"`
// DingTalkBots map[string]*DingTalkBot `mapstructure:"ding_talk_bots"`
Dingtalk DingtalkConfig `mapstructure:"dingtalk"`
Dingtalk DingtalkConfig `mapstructure:"dingtalk"`
}
type SysPrompt struct {
@ -115,6 +117,14 @@ type CozeConfig struct {
ApiSecret string `mapstructure:"api_secret"`
}
// LSXDConfig 统一登录配置
type LSXDConfig struct {
LoginURL string `mapstructure:"login_url"`
Phone string `mapstructure:"phone"`
Password string `mapstructure:"password"`
CheckTokenURL string `mapstructure:"check_token_url"`
}
type Redis struct {
Host string `mapstructure:"host"`
Type string `mapstructure:"type"`
@ -136,6 +146,15 @@ type DB struct {
IsDebug bool `mapstructure:"isDebug"`
}
// Oss 阿里云OSS配置
type Oss struct {
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
Domain string `mapstructure:"domain"`
Endpoint string `mapstructure:"endpoint"`
}
// ToolsConfig 工具配置
type ToolsConfig struct {
Weather ToolConfig `mapstructure:"weather"`
@ -188,6 +207,18 @@ type EinoToolsConfig struct {
HytGoodsCategorySearch ToolConfig `mapstructure:"hytGoodsCategorySearch"`
// 货易通商品品牌查询
HytGoodsBrandSearch ToolConfig `mapstructure:"hytGoodsBrandSearch"`
// 负利润分析列表、 详情
DaOursProductLoss ToolConfig `mapstructure:"daOursProductLoss"`
// 利润同比排行榜
DaProfitRanking ToolConfig `mapstructure:"daProfitRanking"`
// 销售同比分析列表
DaOfficialProduct ToolConfig `mapstructure:"daOfficialProduct"`
// 销售同比下滑详情
DaOfficialProductDecline ToolConfig `mapstructure:"daOfficialProductDecline"`
// 我们的商品统计
RechargeStatisticsOursProduct ToolConfig `mapstructure:"rechargeStatisticsOursProduct"`
// Excel 转图片
Excel2Pic ToolConfig `mapstructure:"excel2Pic"`
}
// LoggingConfig 日志配置
@ -222,10 +253,32 @@ func LoadConfig(configPath string) (*Config, error) {
}
// 解析配置
var config Config
if err := viper.Unmarshal(&config); err != nil {
var bc Config
if err := viper.Unmarshal(&bc); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &config, nil
return &bc, nil
}
func LoadConfigWithTest() (*Config, error) {
var bc Config
modularDir, err := pkg.GetModuleDir()
if err != nil {
return nil, err
}
viper.SetConfigFile(modularDir + "/config/config_test.yaml")
viper.SetConfigType("yaml")
// 读取配置文件
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// 解析配置
if err := viper.Unmarshal(&bc); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &bc, nil
}

View File

@ -17,6 +17,7 @@ const (
TaskTypeBot TaskType = 4
TaskTypeEinoWorkflow TaskType = 5 // eino 工作流
TaskTypeCozeWorkflow TaskType = 6 // coze 工作流
TaskTypeReport TaskType = 7 //报表
)
type UseFul int32

View File

@ -0,0 +1,8 @@
package constants
import "time"
const (
CACHE_KEY_LSXD_TOKEN = "ai_scheduler:lsxd_token"
EXPIRE_LSXD_TOKEN = time.Hour * 2 // 2小时
)

View File

@ -12,14 +12,15 @@ const TableNameAiBotGroup = "ai_bot_group"
// AiBotGroup mapped from table <ai_bot_group>
type AiBotGroup struct {
GroupID int32 `gorm:"column:group_id;primaryKey;autoIncrement:true" json:"group_id"`
ConversationID string `gorm:"column:conversation_id;not null;comment:会话ID" json:"conversation_id"` // 会话ID
RobotCode string `gorm:"column:robot_code;not null;comment:绑定机器人code" json:"robot_code"` // 绑定机器人code
Title string `gorm:"column:title;not null;comment:群名称" json:"title"` // 群名称
ToolList string `gorm:"column:tool_list;not null;comment:开通工具列表" json:"tool_list"` // 开通工具列表
Status int32 `gorm:"column:status;not null;default:1" json:"status"`
DeleteAt *time.Time `gorm:"column:delete_at" json:"delete_at"`
CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"`
GroupID int32 `gorm:"column:group_id;primaryKey;autoIncrement:true" json:"group_id"`
ConversationID string `gorm:"column:conversation_id;not null;comment:会话ID" json:"conversation_id"` // 会话ID
RobotCode string `gorm:"column:robot_code;not null;comment:绑定机器人code" json:"robot_code"` // 绑定机器人code
Title string `gorm:"column:title;not null;comment:群名称" json:"title"` // 群名称
ToolList string `gorm:"column:tool_list;not null;comment:开通工具列表" json:"tool_list"` // 开通工具列表
ProductName string `gorm:"column:product_name;not null;comment:针对报表商品筛选快速实现,后期优化" json:"product_name"` // 针对报表商品筛选快速实现,后期优化
Status int32 `gorm:"column:status;not null;default:1" json:"status"`
DeleteAt time.Time `gorm:"column:delete_at" json:"delete_at"`
CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"`
}
// TableName AiBotGroup's table name

View File

@ -13,16 +13,16 @@ const TableNameAiBotTool = "ai_bot_tools"
// AiBotTool mapped from table <ai_bot_tools>
type AiBotTool struct {
ToolID int32 `gorm:"column:tool_id;primaryKey;autoIncrement:true" json:"tool_id"`
PermissionType int32 `gorm:"column:permission_type;not null;comment:类型1为公共工具不需要进行权限管理反之则为2" json:"permission_type"` // 类型1为公共工具不需要进行权限管理反之则为2
Config string `gorm:"column:config;not null;comment:类型下所需路由以及参数" json:"config"` // 类型下所需路由以及参数
PermissionType int32 `gorm:"column:permission_type;not null;default:1;comment:类型1为公共工具不需要进行权限管理反之则为2" json:"permission_type"` // 类型1为公共工具不需要进行权限管理反之则为2
Config string `gorm:"column:config;comment:类型下所需路由以及参数" json:"config"` // 类型下所需路由以及参数
Type int32 `gorm:"column:type;not null;default:3" json:"type"`
Name string `gorm:"column:name;not null;default:1;comment:工具名称" json:"name"` // 工具名称
Name string `gorm:"column:name;not null;comment:工具名称" json:"name"` // 工具名称
Index string `gorm:"column:index;not null;comment:索引" json:"index"` // 索引
Desc string `gorm:"column:desc;not null;comment:工具描述" json:"desc"` // 工具描述
TempPrompt string `gorm:"column:temp_prompt;not null;comment:提示词模板" json:"temp_prompt"` // 提示词模板
CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"`
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP" json:"updated_at"`
Status int32 `gorm:"column:status;not null" json:"status"`
Status int32 `gorm:"column:status;not null;default:1" json:"status"`
DeleteAt time.Time `gorm:"column:delete_at" json:"delete_at"`
}

View File

@ -1,17 +1,21 @@
package repo
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/data/impl"
"ai_scheduler/utils"
"ai_scheduler/internal/pkg/utils_oss"
)
// Repos 聚合所有 Repository
type Repos struct {
Session SessionRepo
Session SessionRepo
OssClient *utils_oss.Client
}
func NewRepos(sessionImpl *impl.SessionImpl, rdb *utils.Rdb) *Repos {
func NewRepos(sessionImpl *impl.SessionImpl, cfg *config.Config) *Repos {
ossClient, _ := utils_oss.NewClient(cfg)
return &Repos{
Session: NewSessionAdapter(sessionImpl),
Session: NewSessionAdapter(sessionImpl),
OssClient: ossClient,
}
}

View File

@ -0,0 +1,77 @@
package excel_generator
import (
"fmt"
"github.com/go-kratos/kratos/v2/log"
"github.com/xuri/excelize/v2"
)
// Client Excel 生成器
type Client struct{}
func New() *Client {
return &Client{}
}
// Call 根据模板和数据生成 Excel 字节流
func (g *Client) Call(req *ExcelGeneratorRequest) ([]byte, error) {
if req.StartRow <= 0 {
req.StartRow = 2
}
if req.StyleRow <= 0 {
req.StyleRow = 2
}
f, err := excelize.OpenFile(req.TemplatePath)
if err != nil {
return nil, err
}
defer f.Close()
sheet := f.GetSheetName(0)
// 若提供标题,替换第一行表格标题
if len(req.Title) > 0 {
f.SetCellValue(sheet, "A1", req.Title)
}
// 获取样式和行高
styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", req.StyleRow))
if err != nil {
log.Errorf("获取样式失败: %v", err)
styleID = 0
}
rowHeight, err := f.GetRowHeight(sheet, req.StyleRow)
if err != nil {
log.Errorf("获取行高失败: %v", err)
rowHeight = 31 // 默认高度
}
row := req.StartRow
for i, item := range req.ExcelData {
currentRow := row + i
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeight)
// 填充数据
for col, value := range item {
cell := fmt.Sprintf("%c%d", 'A'+col, currentRow)
f.SetCellValue(sheet, cell, value)
}
// 设置样式
if styleID != 0 {
endCol := 'A' + len(item) - 1
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("%c%d", endCol, currentRow), styleID)
}
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@ -0,0 +1,9 @@
package excel_generator
type ExcelGeneratorRequest struct {
TemplatePath string // 模板文件路径
ExcelData [][]string // 二维字符串数组
StartRow int // 数据填充起始行 (默认 2)
StyleRow int // 样式参考行 (默认 2)
Title string // 表格标题(仅替换)
}

View File

@ -0,0 +1,66 @@
package image_converter
import (
"ai_scheduler/internal/config"
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
)
// Client 图片转换器
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 将 Excel 文件转换为图片
func (c *Client) Call(filename string, fileBytes []byte, scale int) ([]byte, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, err
}
if _, err = io.Copy(part, bytes.NewReader(fileBytes)); err != nil {
return nil, err
}
// 添加 scale 参数
if scale <= 0 {
scale = 2
}
if err = writer.WriteField("scale", fmt.Sprintf("%d", scale)); err != nil {
return nil, err
}
if err = writer.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", c.cfg.BaseURL, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("excel2pic service returned status: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}

View File

@ -0,0 +1,70 @@
package official_product
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"strings"
)
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 调用销售同比分析接口
func (c *Client) Call(ctx context.Context, req OfficialProductRequest) (*OfficialProductData, error) {
// 构建 URL 参数
var queryParams []string
if req.Page > 0 {
queryParams = append(queryParams, fmt.Sprintf("page=%d", req.Page))
}
if req.Limit > 0 {
queryParams = append(queryParams, fmt.Sprintf("limit=%d", req.Limit))
}
for _, pid := range req.OfficialProductIds {
queryParams = append(queryParams, fmt.Sprintf("official_product_id[]=%s", pid))
}
for _, t := range req.Ct {
queryParams = append(queryParams, fmt.Sprintf("ct[]=%s", strings.ReplaceAll(t, " ", "+")))
}
queryString := strings.Join(queryParams, "&")
fullURL := fmt.Sprintf("%s?%s", c.cfg.BaseURL, queryString)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
}
reqObj := l_request.Request{
Method: "GET",
Url: fullURL,
Headers: headers,
}
res, err := reqObj.Send()
if err != nil {
return nil, fmt.Errorf("请求失败err: %v", err)
}
var resData OfficialProductResponse
if err := json.Unmarshal([]byte(res.Text), &resData); err != nil {
return nil, fmt.Errorf("解析响应失败err: %v, resp: %s", err, res.Text)
}
if resData.Code != 200 {
return nil, fmt.Errorf("业务错误code: %d, msg: %s", resData.Code, resData.Msg)
}
return &resData.Data, nil
}

View File

@ -0,0 +1,31 @@
package official_product
// OfficialProductRequest 销售同比分析请求参数
type OfficialProductRequest struct {
Page int `json:"page"` // 页码
Limit int `json:"limit"` // 每页条数
OfficialProductIds []string `json:"official_product_ids"` // 官方产品ID列表
Ct []string `json:"ct"` // 时间范围 [开始时间, 结束时间]
}
// OfficialProductResponse 销售同比分析响应结构
type OfficialProductResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data OfficialProductData `json:"data"`
}
type OfficialProductData struct {
OfficialProductSum []OfficialProductItem `json:"officialProductSum"`
DataCount int `json:"dataCount"`
}
type OfficialProductItem struct {
OfficialProductId int `json:"officialProductId"`
OfficialProductName string `json:"officialProductName"`
CurrentNum int `json:"currentNum"`
HistoryOneNum int `json:"historyOneNum"`
HistoryTwoNum int `json:"historyTwoNum"`
HistoryOneDiff int `json:"historyOneDiff"`
HistoryTwoDiff int `json:"historyTwoDiff"`
}

View File

@ -0,0 +1,79 @@
package official_product_decline
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"strings"
)
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 调用销售同比下滑详情接口
func (c *Client) Call(ctx context.Context, req OfficialProductDeclineRequest) (*OfficialProductDeclineData, error) {
// 构建 URL 参数
var queryParams []string
if req.Page > 0 {
queryParams = append(queryParams, fmt.Sprintf("page=%d", req.Page))
}
if req.Limit > 0 {
queryParams = append(queryParams, fmt.Sprintf("limit=%d", req.Limit))
}
if req.DownwardValue > 0 {
queryParams = append(queryParams, fmt.Sprintf("downwardValue=%d", req.DownwardValue))
}
// showTime 可能是 0所以这里不做 > 0 判断,如果业务默认是 0 可以忽略,或者根据实际需求
// 假设始终传递该参数如果已设置
queryParams = append(queryParams, fmt.Sprintf("showTime=%d", req.ShowTime))
for _, pid := range req.OfficialProductIds {
queryParams = append(queryParams, fmt.Sprintf("official_product_id[]=%s", pid))
}
for _, t := range req.Ct {
queryParams = append(queryParams, fmt.Sprintf("ct[]=%s", strings.ReplaceAll(t, " ", "+")))
}
queryString := strings.Join(queryParams, "&")
fullURL := fmt.Sprintf("%s?%s", c.cfg.BaseURL, queryString)
headers := map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
"Accept-Language": "zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7",
}
reqObj := l_request.Request{
Method: "GET",
Url: fullURL,
Headers: headers,
}
res, err := reqObj.Send()
if err != nil {
return nil, fmt.Errorf("请求失败err: %v", err)
}
var resData OfficialProductDeclineResponse
if err := json.Unmarshal([]byte(res.Text), &resData); err != nil {
return nil, fmt.Errorf("解析响应失败err: %v, resp: %s", err, res.Text)
}
if resData.Code != 200 {
return nil, fmt.Errorf("业务错误code: %d, msg: %s", resData.Code, resData.Msg)
}
return &resData.Data, nil
}

View File

@ -0,0 +1,35 @@
package official_product_decline
// OfficialProductDeclineRequest 销售同比下滑详情请求参数
type OfficialProductDeclineRequest struct {
Page int `json:"page"` // 页码
Limit int `json:"limit"` // 每页条数
Ct []string `json:"ct"` // 时间范围 [开始时间, 结束时间]
OfficialProductIds []string `json:"official_product_ids"` // 官方产品ID列表
DownwardValue int `json:"downward_value"` // 下滑值
ShowTime int `json:"show_time"` // 是否显示时间 (0:不显示, 1:显示)
}
// OfficialProductDeclineResponse 销售同比下滑详情响应结构
type OfficialProductDeclineResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data OfficialProductDeclineData `json:"data"`
}
type OfficialProductDeclineData struct {
OfficialProductSumDecline []OfficialProductDeclineItem `json:"officialProductSumDecline"`
DataCount int `json:"dataCount"`
}
type OfficialProductDeclineItem struct {
ResellerId int `json:"resellerId"`
OfficialProductId int `json:"officialProductId"`
OfficialProductName string `json:"officialProductName"`
ResellerName string `json:"resellerName"`
CurrentNum int `json:"currentNum"`
HistoryOneNum int `json:"historyOneNum"`
HistoryTwoNum int `json:"historyTwoNum"`
HistoryOneDiff int `json:"historyOneDiff"`
HistoryTwoDiff int `json:"historyTwoDiff"`
}

View File

@ -0,0 +1,78 @@
package ours_product_loss
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"strings"
)
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 调用负利润分析接口
// 支持列表查询和详情查询
// 列表查询:提供 page, limit, ct[]
// 详情查询:提供 ct[], resellerId
func (c *Client) Call(ctx context.Context, req OursProductLossRequest) (*OursProductLossData, error) {
// 处理数组参数 ct[]
// util.StructToMap 通常不支持数组到 url query array 的转换,这里手动处理查询字符串
// 或者如果 l_request 支持 map 中的 slice 自动转换最好,假设不支持需手动拼接
// 构建 URL 参数
var queryParams []string
if req.Page > 0 {
queryParams = append(queryParams, fmt.Sprintf("page=%d", req.Page))
}
if req.Limit > 0 {
queryParams = append(queryParams, fmt.Sprintf("limit=%d", req.Limit))
}
if req.ResellerId != "" {
queryParams = append(queryParams, fmt.Sprintf("resellerId=%s", req.ResellerId))
}
for _, t := range req.Ct {
// URL 编码处理,这里简单处理,实际应使用 url.QueryEscape
// 假设输入已经是合法的格式
queryParams = append(queryParams, fmt.Sprintf("ct[]=%s", strings.ReplaceAll(t, " ", "+")))
}
queryString := strings.Join(queryParams, "&")
fullURL := fmt.Sprintf("%s?%s", c.cfg.BaseURL, queryString)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
}
reqObj := l_request.Request{
Method: "GET",
Url: fullURL,
Headers: headers,
}
res, err := reqObj.Send()
if err != nil {
return nil, fmt.Errorf("请求失败err: %v", err)
}
var resData OursProductLossResponse
if err := json.Unmarshal([]byte(res.Text), &resData); err != nil {
return nil, fmt.Errorf("解析响应失败err: %v, resp: %s", err, res.Text)
}
if resData.Code != 200 {
return nil, fmt.Errorf("业务错误code: %d, msg: %s", resData.Code, resData.Msg)
}
return &resData.Data, nil
}

View File

@ -0,0 +1,29 @@
package ours_product_loss
// OursProductLossRequest 负利润分析请求参数
type OursProductLossRequest struct {
Page int `json:"page"` // 页码
Limit int `json:"limit"` // 每页条数
Ct []string `json:"ct"` // 时间范围 [开始时间, 结束时间]
ResellerId string `json:"reseller_id"` // 经销商ID (详情查询时使用)
}
// OursProductLossResponse 负利润分析响应结构
type OursProductLossResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data OursProductLossData `json:"data"`
}
type OursProductLossData struct {
List []OursProductLossItem `json:"list"`
DataCount int `json:"dataCount"`
}
type OursProductLossItem struct {
OursProductId int `json:"oursProductId"`
OursProductName string `json:"oursProductName"`
ResellerName string `json:"resellerName"`
ResellerId int `json:"resellerId"`
Loss float64 `json:"loss"`
}

View File

@ -0,0 +1,63 @@
package profit_ranking
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"strings"
)
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 调用利润同比排行榜接口
func (c *Client) Call(ctx context.Context, req ProfitRankingRequest) (*ProfitRankingData, error) {
// 构建 URL 参数
var queryParams []string
for _, t := range req.Ct {
queryParams = append(queryParams, fmt.Sprintf("ct[]=%s", strings.ReplaceAll(t, " ", "+")))
}
for _, rid := range req.ResellerIds {
queryParams = append(queryParams, fmt.Sprintf("resellerIds[]=%s", rid))
}
queryString := strings.Join(queryParams, "&")
fullURL := fmt.Sprintf("%s?%s", c.cfg.BaseURL, queryString)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
}
reqObj := l_request.Request{
Method: "GET",
Url: fullURL,
Headers: headers,
}
res, err := reqObj.Send()
if err != nil {
return nil, fmt.Errorf("请求失败err: %v", err)
}
var resData ProfitRankingResponse
if err := json.Unmarshal([]byte(res.Text), &resData); err != nil {
return nil, fmt.Errorf("解析响应失败err: %v, resp: %s", err, res.Text)
}
if resData.Code != 200 {
return nil, fmt.Errorf("业务错误code: %d, msg: %s", resData.Code, resData.Msg)
}
return &resData.Data, nil
}

View File

@ -0,0 +1,27 @@
package profit_ranking
import (
"ai_scheduler/internal/config"
"testing"
"github.com/stretchr/testify/assert"
)
func TestClient_Call(t *testing.T) {
cfg := config.ToolConfig{
BaseURL: "http://test.analysis.com/api/dataanalytics/profitRankingSum",
APIKey: "test_jwt_token",
}
client := New(cfg)
assert.NotNil(t, client)
req := ProfitRankingRequest{
Ct: []string{"2025-01-01 00:00:00", "2025-01-01 23:59:59"},
ResellerIds: []string{"1001", "1002"},
}
t.Logf("Testing Call with req: %+v", req)
// _, err := client.Call(context.Background(), req)
// assert.Error(t, err)
}

View File

@ -0,0 +1,29 @@
package profit_ranking
// ProfitRankingRequest 利润同比排行请求参数
type ProfitRankingRequest struct {
Ct []string `json:"ct"` // 时间范围 [开始时间, 结束时间]
ResellerIds []string `json:"reseller_ids"` // 经销商ID列表
}
// ProfitRankingResponse 利润同比排行响应结构
type ProfitRankingResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data ProfitRankingData `json:"data"`
}
type ProfitRankingData struct {
List []ProfitRankingItem `json:"list"`
DataCount int `json:"dataCount"`
}
type ProfitRankingItem struct {
ResellerId string `json:"resellerId"`
ResellerName string `json:"resellerName"`
CurrentProfit float64 `json:"currentProfit"`
HistoryOneProfit float64 `json:"historyOneProfit"`
HistoryTwoProfit float64 `json:"historyTwoProfit"`
HistoryOneDiff float64 `json:"historyOneDiff"`
HistoryTwoDiff float64 `json:"historyTwoDiff"`
}

View File

@ -0,0 +1,73 @@
package statistics_ours_product
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/l_request"
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
type Client struct {
cfg config.ToolConfig
}
func New(cfg config.ToolConfig) *Client {
return &Client{
cfg: cfg,
}
}
// Call 调用我们的商品统计接口
func (c *Client) Call(ctx context.Context, req StatisticsOursProductRequest) ([]StatisticsOursProductItem, error) {
// 构建 URL 参数
var queryParams []string
if req.Page > 0 {
queryParams = append(queryParams, fmt.Sprintf("page=%d", req.Page))
}
if req.Limit > 0 {
queryParams = append(queryParams, fmt.Sprintf("limit=%d", req.Limit))
}
if req.OursProductId != "" {
queryParams = append(queryParams, fmt.Sprintf("ours_product_id=%s", req.OursProductId))
}
for _, s := range req.Serial {
queryParams = append(queryParams, fmt.Sprintf("serial[]=%s", s))
}
// 添加 timestamp
queryParams = append(queryParams, fmt.Sprintf("timestamp=%d", time.Now().UnixMilli()))
queryString := strings.Join(queryParams, "&")
fullURL := fmt.Sprintf("%s?%s", c.cfg.BaseURL, queryString)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
}
reqObj := l_request.Request{
Method: "GET",
Url: fullURL,
Headers: headers,
}
res, err := reqObj.Send()
if err != nil {
return nil, fmt.Errorf("请求失败err: %v", err)
}
var resData StatisticsOursProductResponse
if err := json.Unmarshal([]byte(res.Text), &resData); err != nil {
return nil, fmt.Errorf("解析响应失败err: %v, resp: %s", err, res.Text)
}
if resData.Code != 200 {
return nil, fmt.Errorf("业务错误code: %d, msg: %s", resData.Code, resData.Msg)
}
return resData.Data, nil
}

View File

@ -0,0 +1,33 @@
package statistics_ours_product
import (
"ai_scheduler/internal/config"
"context"
"testing"
)
func TestClient_Call(t *testing.T) {
cfg := config.ToolConfig{
BaseURL: "http://admin.1688sup.cn:8001/admin/statistics/oursProduct",
APIKey: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzY3MDc3NzcwLCJuYmYiOjE3NjcwNzU5NzAsImp0aSI6IjEiLCJQaG9uZSI6IjE4MDAwMDAwMDAwIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MSwiR3JvdXBDb2RlcyI6IlZDTF9DQVNISUVSLFZDTF9PUEVSQVRFLFZDTF9BRE1JTixWQ0xfQUFBLFZDTF9WQ0xfT1BFUkFULFZDTF9JTlZPSUNFLENSTV9BRE1JTixMSUFOTElBTl9BRE1JTixNQVJLRVRNQUcyX0FETUlOLFBIT05FQklMTF9BRE1JTixRSUFOWkhVX1NVUFBFUl9BRE0sTUFSS0VUSU5HU0FBU19TVVBFUkFETUlOLENBUkRfQ09ERSxDQVJEX1BST0NVUkVNRU5ULE1BUktFVElOR1NZU1RFTV9TVVBFUixTVEFUSVNUSUNBTFNZU1RFTV9BRE1JTixaTFRYX0FETUlOLFpMVFhfT1BFUkFURSIsIkRpbmdVc2VySWQiOiIxNjIwMjYxMjMwMjg5MzM4MzQifQ.Nuw_aR6iSPmhhh9E5rhyTxHBsgWtaTZvbnc7SFTnUBJXTQvYahnk0LyZaVpsVw6FB3cU0F5xOdX3rmGyWyaiszWO6yi-o1oxGMXwhf39fMiWT2xUI6pAn9Ync8DmZ4tOMCNUTdEk4CaQFzrTwJs0c-VR4yW6LgoPmNPvUVZ-KwmusUpnPz5j9RsJItzIWE3bpGGsfB54e2UERcZdbo9BXxCZIBbpAYKBKdl73KuI8SNaXgKvGTrJ6hEN4ESpnbisJVwT5pp_kuChJlcfjHTHFsEf4RJDjN9gTrtDbBWZyY3OmO2ukqYAM7tZPs6TXJwvQGJQsFRVZUBGxS1nD_6DzQ",
}
client := New(cfg)
req := StatisticsOursProductRequest{
Page: 1,
Limit: 100,
Serial: []string{"2025122300", "2025123123"},
}
t.Logf("Testing Call with req: %+v", req)
// 由于没有真实的后端环境和 Token这里注释掉实际调用
// 在开发环境中,你可以取消注释并填入有效的 Token 进行测试
res, err := client.Call(context.Background(), req)
if err != nil {
t.Logf("Call failed: %v", err)
} else {
t.Logf("Call success, resp: %+v", res)
}
}

View File

@ -0,0 +1,30 @@
package statistics_ours_product
// StatisticsOursProductRequest 我们的商品统计请求参数
type StatisticsOursProductRequest struct {
Page int `json:"page"` // 页码
Limit int `json:"limit"` // 每页条数
Serial []string `json:"serial"` // 流水号范围 (通常是日期格式,如 YYYYMMDDHH)
OursProductId string `json:"ours_product_id"` // 我们的商品ID (可选)
}
// StatisticsOursProductResponse 我们的商品统计响应结构
// 注意:接口直接返回数组,而不是包含在 data 字段中的对象
type StatisticsOursProductResponse struct {
Code int `json:"code"`
Msg string `json:"error"` // 接口返回字段名为 error
Data []StatisticsOursProductItem `json:"data"` // data 是一个数组
}
type StatisticsOursProductItem struct {
OursProductId int `json:"ours_product_id"`
ResellerId int `json:"reseller_id"`
TotalPrice any `json:"total_price"`
Count any `json:"count"`
SuccessCount any `json:"success_count"`
SuccessPrice any `json:"success_price"`
FailCount any `json:"fail_count"`
FailPrice any `json:"fail_price"`
Profit any `json:"profit"`
OursProductName string `json:"ours_product_name"`
}

View File

@ -2,6 +2,8 @@ package tools
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/domain/tools/common/excel_generator"
"ai_scheduler/internal/domain/tools/common/image_converter"
"ai_scheduler/internal/domain/tools/hyt/goods_add"
"ai_scheduler/internal/domain/tools/hyt/goods_brand_search"
"ai_scheduler/internal/domain/tools/hyt/goods_category_add"
@ -10,13 +12,21 @@ import (
"ai_scheduler/internal/domain/tools/hyt/product_upload"
"ai_scheduler/internal/domain/tools/hyt/supplier_search"
"ai_scheduler/internal/domain/tools/hyt/warehouse_search"
"ai_scheduler/internal/domain/tools/recharge/statistics_ours_product"
)
type Manager struct {
Hyt *HytTools
Hyt *HytTools
Recharge *RechargeTools
Common *CommonTools
// Zltx *ZltxTools
}
type CommonTools struct {
ExcelGenerator *excel_generator.Client
ImageConverter *image_converter.Client
}
type HytTools struct {
ProductUpload *product_upload.Client
SupplierSearch *supplier_search.Client
@ -28,6 +38,10 @@ type HytTools struct {
GoodsBrandSearch *goods_brand_search.Client
}
type RechargeTools struct {
StatisticsOursProduct *statistics_ours_product.Client
}
func NewManager(cfg *config.Config) *Manager {
return &Manager{
Hyt: &HytTools{
@ -40,5 +54,12 @@ func NewManager(cfg *config.Config) *Manager {
GoodsCategorySearch: goods_category_search.New(cfg.EinoTools.HytGoodsCategorySearch),
GoodsBrandSearch: goods_brand_search.New(cfg.EinoTools.HytGoodsBrandSearch),
},
Recharge: &RechargeTools{
StatisticsOursProduct: statistics_ours_product.New(cfg.EinoTools.RechargeStatisticsOursProduct),
},
Common: &CommonTools{
ExcelGenerator: excel_generator.New(),
ImageConverter: image_converter.New(cfg.EinoTools.Excel2Pic),
},
}
}

View File

@ -8,7 +8,6 @@ import (
"ai_scheduler/internal/domain/tools/hyt/goods_category_add"
"ai_scheduler/internal/domain/tools/hyt/goods_media_add"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/entitys"
"context"
"encoding/json"
"errors"
@ -42,7 +41,7 @@ type GoodsAddWorkflowInput struct {
func (o *goodsAdd) ID() string { return WorkflowIDGoodsAdd }
func (o *goodsAdd) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) {
func (o *goodsAdd) Invoke(ctx context.Context, rec *runtime.WorkflowArgs) (map[string]any, error) {
// 构建工作流
runnable, err := o.buildWorkflow(ctx)
if err != nil {

View File

@ -6,7 +6,6 @@ import (
toolManager "ai_scheduler/internal/domain/tools"
toolPu "ai_scheduler/internal/domain/tools/hyt/product_upload"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/entitys"
"context"
"encoding/json"
"errors"
@ -39,7 +38,7 @@ type ProductUploadWorkflowInput struct {
func (o *productUpload) ID() string { return WorkflowIDProductUpload }
func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) {
func (o *productUpload) Invoke(ctx context.Context, rec *runtime.WorkflowArgs) (map[string]any, error) {
// 构建工作流
runnable, err := o.buildWorkflow(ctx)
if err != nil {

View File

@ -0,0 +1,230 @@
package recharge
import (
"ai_scheduler/internal/config"
errorcode "ai_scheduler/internal/data/error"
toolManager "ai_scheduler/internal/domain/tools"
"ai_scheduler/internal/domain/tools/common/excel_generator"
"ai_scheduler/internal/domain/tools/recharge/statistics_ours_product"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/pkg/utils_oss"
"context"
"errors"
"fmt"
"math/rand"
"path/filepath"
"strconv"
"time"
"github.com/cloudwego/eino/compose"
"github.com/go-kratos/kratos/v2/log"
)
const WorkflowIDStatisticsOursProduct = "recharge.statisticsOursProduct"
func init() {
runtime.Register(WorkflowIDStatisticsOursProduct, func(d *runtime.Deps) (runtime.Workflow, error) {
return &statisticsOursProduct{cfg: d.Conf, toolManager: d.ToolManager, ossClient: d.Repos.OssClient}, nil
})
}
type statisticsOursProduct struct {
cfg *config.Config
toolManager *toolManager.Manager
ossClient *utils_oss.Client
}
type StatisticsOursProductWorkflowInput struct {
Time time.Time `json:"time"`
}
type StatisticsOursProductWorkflowOutput struct {
Path string `json:"path"`
Url string `json:"url"`
Data [][]string `json:"data"`
Desc string `json:"desc"`
}
func (w *statisticsOursProduct) ID() string { return WorkflowIDStatisticsOursProduct }
func (w *statisticsOursProduct) Invoke(ctx context.Context, args *runtime.WorkflowArgs) (map[string]any, error) {
// 构建工作流
runnable, err := w.buildWorkflow(ctx)
if err != nil {
return nil, err
}
// 获取参数时间
input := &StatisticsOursProductWorkflowInput{
Time: args.Args["now"].(time.Time),
}
// 工作流过程调用
output, err := runnable.Invoke(ctx, input)
if err != nil {
fmt.Println("Invoke err:", err)
errStr := err.Error()
if u := errors.Unwrap(err); u != nil {
errStr = u.Error()
}
return nil, errorcode.WorkflowErr(errStr)
}
return output, nil
}
type StatisticsOursProductContext struct {
Time time.Time
StartTime string
EndTime string
Title string
ProductData []statistics_ours_product.StatisticsOursProductItem
ImgUrl string
ExcelData [][]string
}
func (w *statisticsOursProduct) buildWorkflow(ctx context.Context) (compose.Runnable[*StatisticsOursProductWorkflowInput, map[string]any], error) {
c := compose.NewChain[*StatisticsOursProductWorkflowInput, map[string]any]()
// 1. 参数整理
c.AppendLambda(compose.InvokableLambda(w.formatContext))
// 2. 调用工具统计我们的商品
c.AppendLambda(compose.InvokableLambda(w.callStatisticsTool))
// 3. 生成 Excel 并转图片上传
c.AppendLambda(compose.InvokableLambda(w.generateExcelAndUpload))
// 4. 转map输出
c.AppendLambda(compose.InvokableLambda(w.convertToMap))
return c.Compile(ctx)
}
// formatContext 整理上下文参数
func (w *statisticsOursProduct) formatContext(ctx context.Context, input *StatisticsOursProductWorkflowInput) (*StatisticsOursProductContext, error) {
startTime := input.Time.Format("2006010200")
endTime := input.Time.Format("2006010215")
endTimeStr := input.Time.Format("1.2号15点")
return &StatisticsOursProductContext{
Time: time.Now(),
StartTime: startTime,
EndTime: endTime,
Title: fmt.Sprintf("截止 %s 我们的商品统计", endTimeStr),
}, nil
}
func (w *statisticsOursProduct) callStatisticsTool(ctx context.Context, state *StatisticsOursProductContext) (*StatisticsOursProductContext, error) {
req := statistics_ours_product.StatisticsOursProductRequest{
Page: 1,
Limit: 100, // 仅取前100条
Serial: []string{state.StartTime, state.EndTime},
}
dataList, err := w.toolManager.Recharge.StatisticsOursProduct.Call(ctx, req)
if err != nil {
log.Errorf("调用统计我们的商品工具失败: %v", err)
return nil, fmt.Errorf("获取统计我们的商品数据失败")
}
if len(dataList) == 0 {
return nil, fmt.Errorf("我们的商品数据为空")
}
state.ProductData = dataList
return state, nil
}
func (w *statisticsOursProduct) generateExcelAndUpload(ctx context.Context, state *StatisticsOursProductContext) (*StatisticsOursProductContext, error) {
// 1. 获取模板路径
cwd, _ := filepath.Abs(".")
templatePath := filepath.Join(cwd, "tmpl", "excel_temp", "recharge_statistics_ours_product.xlsx")
fileName := fmt.Sprintf("statistics_ours_product_%d%d", time.Now().Unix(), rand.Intn(1000))
// 2. 转换数据为 [][]string
excelData := w.convertDataToExcelFormat(state.ProductData)
// 3. 生成 Excel
req := &excel_generator.ExcelGeneratorRequest{
TemplatePath: templatePath,
ExcelData: excelData,
StartRow: 4,
StyleRow: 3,
Title: state.Title,
}
excelBytes, err := w.toolManager.Common.ExcelGenerator.Call(req)
if err != nil {
return nil, fmt.Errorf("生成 Excel 失败: %v", err)
}
// 4. Excel 转图片
picBytes, err := w.toolManager.Common.ImageConverter.Call(fileName+".xlsx", excelBytes, 2)
if err != nil {
return nil, fmt.Errorf("Excel 转图片失败: %v", err)
}
// 5. 上传 OSS
url, err := w.ossClient.UploadBytes(fileName+".png", picBytes)
if err != nil {
return nil, fmt.Errorf("上传 OSS 失败: %v", err)
}
state.ImgUrl = url
state.ExcelData = excelData
return state, nil
}
// convertDataToExcelFormat 将业务数据转换为 Excel 生成器需要的二维字符串数组
func (w *statisticsOursProduct) convertDataToExcelFormat(data []statistics_ours_product.StatisticsOursProductItem) [][]string {
var result [][]string
for _, item := range data {
var profitVal float64
// 处理 Profit 字段类型
switch v := item.Profit.(type) {
case float64:
profitVal = v
case string:
if val, err := strconv.ParseFloat(v, 64); err == nil {
profitVal = val
} else {
// 解析失败默认为0或者记录错误
profitVal = 0
}
default:
// 其他类型视为0
profitVal = 0
}
// 过滤利润小于 -100 的记录
if profitVal < -100 {
continue
}
row := []string{
item.OursProductName,
// fmt.Sprintf("%d", item.OursProductId),
fmt.Sprintf("%v", item.Count),
// item.TotalPrice,
// item.SuccessCount,
fmt.Sprintf("%v", item.SuccessPrice),
// item.FailCount,
// item.FailPrice
fmt.Sprintf("%v", item.Profit),
}
result = append(result, row)
}
return result
}
func (w *statisticsOursProduct) convertToMap(ctx context.Context, state *StatisticsOursProductContext) (map[string]any, error) {
return map[string]any{
"path": "",
"url": state.ImgUrl,
"data": state.ExcelData,
"desc": state.Title,
}, nil
}

View File

@ -15,7 +15,7 @@ import (
type Workflow interface {
ID() string
// Schema() map[string]any
Invoke(ctx context.Context, requireData *entitys.Recognize) (map[string]any, error)
Invoke(ctx context.Context, requireData *WorkflowArgs) (map[string]any, error)
}
type Deps struct {
@ -28,6 +28,11 @@ type Deps struct {
type Factory func(deps *Deps) (Workflow, error)
type WorkflowArgs struct {
*entitys.Recognize
Args map[string]any
}
var (
regMu sync.RWMutex
factories = map[string]Factory{}
@ -69,7 +74,7 @@ func Default() *Registry {
return r
}
func (r *Registry) Invoke(ctx context.Context, id string, rec *entitys.Recognize) (map[string]any, error) {
func (r *Registry) Invoke(ctx context.Context, id string, rec *WorkflowArgs) (map[string]any, error) {
regMu.RLock()
f, ok := factories[id]
regMu.RUnlock()

View File

@ -42,7 +42,7 @@ func (w *bugOptimizationSubmitBak) ID() string {
type BugOptimizationSubmitBakInput struct {
Ch chan entitys.Response
RequireData *entitys.Recognize
RequireData *runtime.WorkflowArgs
}
type BugOptimizationSubmitBakOutput struct {
@ -54,7 +54,7 @@ type contextWithTaskBak struct {
TaskID string
}
func (w *bugOptimizationSubmitBak) Invoke(ctx context.Context, recognize *entitys.Recognize) (map[string]any, error) {
func (w *bugOptimizationSubmitBak) Invoke(ctx context.Context, recognize *runtime.WorkflowArgs) (map[string]any, error) {
chain, err := w.buildWorkflow(ctx)
if err != nil {
return nil, err

View File

@ -42,7 +42,7 @@ func (w *bugOptimizationSubmit) ID() string {
type BugOptimizationSubmitInput struct {
Ch chan entitys.Response
RequireData *entitys.Recognize
RequireData *runtime.WorkflowArgs
}
type BugOptimizationSubmitOutput struct {
@ -54,7 +54,7 @@ type contextWithTask struct {
TaskID string
}
func (w *bugOptimizationSubmit) Invoke(ctx context.Context, recognize *entitys.Recognize) (map[string]any, error) {
func (w *bugOptimizationSubmit) Invoke(ctx context.Context, recognize *runtime.WorkflowArgs) (map[string]any, error) {
chain, err := w.buildWorkflow(ctx)
if err != nil {
return nil, err

View File

@ -78,7 +78,7 @@ type OrderAfterSaleResellerBatchData struct {
func (o *orderAfterSaleResellerBatch) ID() string { return "zltx.orderAfterSaleResellerBatch" }
// Invoke 调用原有编排工作流并规范化输出
func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) {
func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *runtime.WorkflowArgs) (map[string]any, error) {
// 构建工作流
chain, err := o.buildWorkflow(ctx)
if err != nil {

View File

@ -92,6 +92,10 @@ type ConfigDataTool struct {
Tool string `json:"tool"`
}
type ConfigDataReport struct {
Time string `json:"time"`
}
// Message 消息
type Message struct {
Role string `json:"role"`

View File

@ -1,75 +0,0 @@
package pkg
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/entitys"
"sync"
)
type SafeChannelPool struct {
pool chan chan entitys.ResponseData // 存储空闲 channel 的队列
bufSize int // channel 缓冲大小
mu sync.Mutex
closed bool
}
func NewSafeChannelPool(c *config.Config) (*SafeChannelPool, func()) {
pool := &SafeChannelPool{
pool: make(chan chan entitys.ResponseData, c.Sys.ChannelPoolLen),
bufSize: c.Sys.ChannelPoolSize,
}
cleanup := pool.Close
return pool, cleanup
}
// 从池中获取 channel若无空闲则创建新 channel
func (p *SafeChannelPool) Get() chan entitys.ResponseData {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return make(chan entitys.ResponseData, p.bufSize)
}
select {
case ch := <-p.pool: // 从池中取
return ch
default: // 池为空,创建新 channel
return make(chan entitys.ResponseData, p.bufSize)
}
}
// 将 channel 放回池中(必须确保 channel 已清空!)
func (p *SafeChannelPool) Put(ch chan entitys.ResponseData) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return
}
// 清空 channel防止复用时读取旧数据
go func() {
for range ch {
// 丢弃所有数据(或根据业务需求处理)
}
}()
select {
case p.pool <- ch: // 尝试放回池中
default: // 池已满,直接关闭 channel避免泄漏
close(ch)
}
return
}
// 关闭池(释放所有资源)
func (p *SafeChannelPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
close(p.pool) // 关闭池队列
// 需额外逻辑关闭所有内部 channel此处简化
}

View File

@ -6,8 +6,10 @@ import (
"errors"
"fmt"
"net/url"
"reflect"
"strconv"
"strings"
"time"
)
func JsonStringIgonErr(data interface{}) string {
@ -165,3 +167,258 @@ func SafeReplace(template string, replaceTag string, replacements ...string) (st
return template, nil
}
// 配置选项
type URLValuesOptions struct {
ArrayFormat string // 数组格式:"brackets" -> name[], "indices" -> name[0], "repeat" -> name=value1&name=value2
TimeFormat string // 时间格式
}
var defaultOptions = URLValuesOptions{
ArrayFormat: "brackets", // 默认使用括号格式
TimeFormat: time.DateTime,
}
// StructToURLValues 将结构体转换为 url.Values
func StructToURLValues(input interface{}, options ...URLValuesOptions) (url.Values, error) {
opts := defaultOptions
if len(options) > 0 {
opts = options[0]
}
values := url.Values{}
if input == nil {
return values, nil
}
v := reflect.ValueOf(input)
t := reflect.TypeOf(input)
// 如果是指针,获取其指向的值
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return values, nil
}
v = v.Elem()
t = t.Elem()
}
// 确保是结构体类型
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("input must be a struct or pointer to struct")
}
// 遍历结构体字段
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
fieldValue := v.Field(i)
// 跳过非导出字段
if !field.IsExported() {
continue
}
// 解析 JSON 标签(也可以支持 form 标签)
tag := field.Tag.Get("json")
fieldName, omitempty := parseJSONTag(tag)
if fieldName == "-" {
continue // 忽略该字段
}
if fieldName == "" {
fieldName = field.Name
}
// 处理指针类型
if fieldValue.Kind() == reflect.Ptr {
if fieldValue.IsNil() {
if omitempty {
continue
}
// 可以为 nil 指针添加空值
values.Set(fieldName, "")
continue
}
fieldValue = fieldValue.Elem()
}
// 处理切片/数组
if fieldValue.Kind() == reflect.Slice || fieldValue.Kind() == reflect.Array {
if fieldValue.Len() == 0 && omitempty {
continue
}
// 将切片转换为 URL 参数
err := addSliceToValues(values, fieldName, fieldValue, opts)
if err != nil {
return nil, err
}
continue
}
// 检查是否需要忽略空值
if omitempty && isEmptyValue(fieldValue) {
continue
}
// 转换单个值
str, err := valueToString(fieldValue, opts)
if err != nil {
return nil, err
}
values.Set(fieldName, str)
}
return values, nil
}
// 解析 JSON 标签
func parseJSONTag(tag string) (fieldName string, omitempty bool) {
if tag == "" {
return "", false
}
parts := strings.Split(tag, ",")
fieldName = parts[0]
if len(parts) > 1 {
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
}
return fieldName, omitempty
}
// 添加切片到 values
func addSliceToValues(values url.Values, fieldName string, slice reflect.Value, opts URLValuesOptions) error {
length := slice.Len()
if length == 0 {
return nil
}
switch opts.ArrayFormat {
case "brackets":
// 格式field[]=value1&field[]=value2
for i := 0; i < length; i++ {
item := slice.Index(i)
str, err := valueToString(item, opts)
if err != nil {
return err
}
values.Add(fieldName, str)
}
case "indices":
// 格式field[0]=value1&field[1]=value2
for i := 0; i < length; i++ {
item := slice.Index(i)
str, err := valueToString(item, opts)
if err != nil {
return err
}
values.Set(fmt.Sprintf("%s[%d]", fieldName, i), str)
}
case "repeat":
// 格式field=value1&field=value2
for i := 0; i < length; i++ {
item := slice.Index(i)
str, err := valueToString(item, opts)
if err != nil {
return err
}
values.Add(fieldName, str)
}
default:
// 默认使用 brackets 格式
for i := 0; i < length; i++ {
item := slice.Index(i)
str, err := valueToString(item, opts)
if err != nil {
return err
}
values.Add(fieldName+"[]", str)
}
}
return nil
}
// 将值转换为字符串
func valueToString(v reflect.Value, opts URLValuesOptions) (string, error) {
if !v.IsValid() {
return "", nil
}
// 处理不同类型
switch v.Kind() {
case reflect.String:
return v.String(), nil
case reflect.Bool:
return strconv.FormatBool(v.Bool()), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(v.Uint(), 10), nil
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(v.Float(), 'f', -1, 64), nil
case reflect.Struct:
// 特殊处理 time.Time
if t, ok := v.Interface().(time.Time); ok {
return t.Format(opts.TimeFormat), nil
}
// 其他结构体递归处理
// 这里可以扩展为递归处理嵌套结构体
default:
// 默认使用 fmt 的字符串表示
return fmt.Sprintf("%v", v.Interface()), nil
}
return fmt.Sprintf("%v", v.Interface()), nil
}
// 检查值是否为空
func isEmptyValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.String() == ""
case reflect.Bool:
return false
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Slice, reflect.Array, reflect.Map:
return v.Len() == 0
case reflect.Ptr, reflect.Interface:
return v.IsNil()
case reflect.Struct:
if t, ok := v.Interface().(time.Time); ok {
return t.IsZero()
}
return false
default:
return false
}
}
// 方便函数:直接生成查询字符串
func StructToQueryString(input interface{}, options ...URLValuesOptions) (string, error) {
values, err := StructToURLValues(input, options...)
if err != nil {
return "", err
}
return values.Encode(), nil
}

214
internal/pkg/lsxd/login.go Normal file
View File

@ -0,0 +1,214 @@
package lsxd
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/data/constants"
"ai_scheduler/utils"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/go-kratos/kratos/v2/log"
"github.com/redis/go-redis/v9"
)
type Login struct {
config *config.Config
redisCli *redis.Client
mu sync.Mutex
}
func NewLogin(config *config.Config, rdb *utils.Rdb) *Login {
return &Login{
config: config,
redisCli: rdb.Rdb,
}
}
func (l *Login) GetToken() string {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
token, err := l.getCachedToken(ctx)
if err != nil {
log.Errorf("lsxd get token from redis failed, err: %v", err)
}
if token != "" && l.checkTokenValid(ctx, token) {
return token
}
l.mu.Lock()
defer l.mu.Unlock()
token, err = l.getCachedToken(ctx)
if err != nil {
log.Errorf("lsxd get token from redis failed, err: %v", err)
}
if token != "" && l.checkTokenValid(ctx, token) {
return token
}
token, err = l.login(ctx)
if err != nil {
log.Errorf("lsxd login failed, err: %v", err)
return ""
}
if token == "" {
log.Errorf("lsxd login failed, token is empty")
return ""
}
if err := l.cacheToken(ctx, token); err != nil {
log.Errorf("lsxd cache token failed, err: %v", err)
}
return token
}
// 校验token是否有效
func (l *Login) checkTokenValid(ctx context.Context, token string) bool {
// 欢迎页校验token有效
checkTokenURL := l.config.LSXD.CheckTokenURL
if checkTokenURL == "" {
return token != ""
}
status, err := l.doRequest(ctx, http.MethodGet, checkTokenURL, token, nil)
if err != nil {
log.Errorf("lsxd check token valid failed, err: %v", err)
return true
}
if status == http.StatusOK {
return true
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return false
}
return true
}
// 调用登录接口获取token
func (l *Login) login(ctx context.Context) (string, error) {
// 1.获取配置
loginURL := l.config.LSXD.LoginURL
phone := l.config.LSXD.Phone
password := l.config.LSXD.Password
// 2.调用登录接口获取token
if loginURL == "" {
return "", errors.New("login url is empty")
}
if phone == "" || password == "" {
return "", errors.New("phone or password is empty")
}
reqBody := map[string]any{
"phone": phone,
"password": password,
"code": "123456",
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
status, respBody, err := l.doRequestWithBody(ctx, http.MethodPost, loginURL, "", "application/json", bodyBytes)
if err != nil {
return "", err
}
if status != http.StatusOK {
return "", fmt.Errorf("login status code: %d", status)
}
type loginResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Message string `json:"message"`
AccessToken string `json:"accessToken"`
Data struct {
AccessToken string `json:"accessToken"`
Token string `json:"token"`
} `json:"data"`
}
var resp loginResp
if err := json.Unmarshal(respBody, &resp); err != nil {
return "", err
}
token := resp.AccessToken
if token == "" {
token = resp.Data.AccessToken
}
if token == "" {
token = resp.Data.Token
}
if token == "" {
return "", errors.New("token is empty")
}
return token, nil
}
func (l *Login) getCachedToken(ctx context.Context) (string, error) {
token, err := l.redisCli.Get(ctx, constants.CACHE_KEY_LSXD_TOKEN).Result()
if err == nil {
return token, nil
}
if errors.Is(err, redis.Nil) {
return "", nil
}
return "", err
}
func (l *Login) cacheToken(ctx context.Context, token string) error {
if token == "" {
return errors.New("token is empty")
}
return l.redisCli.Set(ctx, constants.CACHE_KEY_LSXD_TOKEN, token, constants.EXPIRE_LSXD_TOKEN).Err()
}
func (l *Login) doRequest(ctx context.Context, method string, url string, authorization string, body []byte) (int, error) {
status, _, err := l.doRequestWithBody(ctx, method, url, authorization, "", body)
return status, err
}
func (l *Login) doRequestWithBody(ctx context.Context, method string, url string, authorization string, contentType string, body []byte) (int, []byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
defer cancel()
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(reqCtx, method, url, reader)
if err != nil {
return 0, nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if authorization != "" {
req.Header.Set("Authorization", authorization)
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
return resp.StatusCode, respBody, nil
}

View File

@ -0,0 +1,33 @@
package lsxd
import (
"ai_scheduler/internal/config"
"ai_scheduler/utils"
"testing"
)
func TestGetToken(t *testing.T) {
rdb := utils.NewRdb(&config.Config{
Redis: config.Redis{
Host: "47.97.27.195:6379",
Pass: "lansexiongdi@666",
Key: "report-api-test",
},
})
login := NewLogin(&config.Config{
LSXD: config.LSXDConfig{
LoginURL: "http://api.test.user.1688sup.com/v1/login/phone",
Phone: "0zmINhJBwsDJYTmeaxXK4A==",
Password: "LSSGoWhc63NpZc1rq3LT8g==",
CheckTokenURL: "http://api.test.user.1688sup.com/v1/user/welcome",
},
}, rdb)
token := login.GetToken()
if token == "" {
t.Errorf("token is empty")
}
t.Logf("token: %s", token)
}

View File

@ -4,6 +4,7 @@ import (
"ai_scheduler/internal/pkg/dingtalk"
"ai_scheduler/internal/pkg/utils_langchain"
"ai_scheduler/internal/pkg/utils_ollama"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/internal/pkg/utils_vllm"
"github.com/google/wire"
@ -15,8 +16,10 @@ var ProviderSetClient = wire.NewSet(
utils_langchain.NewUtilLangChain,
utils_ollama.NewClient,
utils_vllm.NewClient,
NewSafeChannelPool,
dingtalk.NewOldClient,
dingtalk.NewContactClient,
dingtalk.NewNotableClient,
utils_oss.NewClient,
)

View File

@ -0,0 +1,57 @@
package utils_oss
import (
"ai_scheduler/internal/config"
"bytes"
"fmt"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/go-kratos/kratos/v2/log"
)
type Client struct {
config config.Oss
client *oss.Client
bucket *oss.Bucket
}
// NewClient 初始化 OSS 客户端
func NewClient(cfg *config.Config) (*Client, error) {
client, err := oss.New(cfg.Oss.Endpoint, cfg.Oss.AccessKey, cfg.Oss.SecretKey)
if err != nil {
return nil, fmt.Errorf("oss new client failed: %v", err)
}
bucket, err := client.Bucket(cfg.Oss.Bucket)
if err != nil {
return nil, fmt.Errorf("oss get bucket failed: %v", err)
}
return &Client{
config: cfg.Oss,
client: client,
bucket: bucket,
}, nil
}
// UploadBytes 上传字节数组到 OSS
// objectKey: OSS 中的文件路径,例如 "ai_scheduler/test.png"
// fileBytes: 文件内容
// 返回: 文件的访问 URL
func (c *Client) UploadBytes(objectKey string, fileBytes []byte) (string, error) {
err := c.bucket.PutObject(objectKey, bytes.NewReader(fileBytes))
if err != nil {
log.Errorf("oss PutObject failed: %v", err)
return "", err
}
// 构造返回 URL
var url string
if c.config.Domain != "" {
url = fmt.Sprintf("%s/%s", c.config.Domain, objectKey)
} else {
// 这里简单处理协议头
url = fmt.Sprintf("https://%s.%s/%s", c.config.Bucket, c.config.Endpoint, objectKey)
}
return url, nil
}

93
internal/server/cron.go Normal file
View File

@ -0,0 +1,93 @@
package server
import (
"ai_scheduler/internal/services"
"context"
"github.com/gofiber/fiber/v2/log"
"github.com/robfig/cron/v3"
)
type CronServer struct {
Cron *cron.Cron
jobs []*cronJob
log log.AllLogger
cronService *services.CronService
ctx context.Context
}
type cronJob struct {
EntryId int32
Func func(context.Context) error
Name string
Schedule string
}
func NewCronServer(
log log.AllLogger,
cronService *services.CronService,
) *CronServer {
return &CronServer{
Cron: cron.New(),
log: log,
cronService: cronService,
ctx: context.Background(),
}
}
func (c *CronServer) InitJobs(ctx context.Context) {
// 创建一个可用于所有定时任务的上下文(可以取消的上下文)
c.ctx = ctx
c.jobs = []*cronJob{
{
Func: c.cronService.CronReportSend,
Name: "直连天下报表推送",
Schedule: "0 12,18,23 * * *",
},
}
}
func (c *CronServer) Run(ctx context.Context) {
// 先初始化任务
if c.jobs == nil {
c.InitJobs(ctx)
}
for i, job := range c.jobs {
// 复制变量到闭包内,避免闭包变量捕获问题
job := job
jobID := i + 1
_, err := c.Cron.AddFunc(job.Schedule, func() {
c.log.Infof("任务[%d]:%s开始执行", jobID, job.Name)
defer func() {
if r := recover(); r != nil {
c.log.Errorf("任务[%d]:%s执行时发生panic: %v", jobID, job.Name, r)
}
c.log.Infof("任务[%d]:%s执行结束", jobID, job.Name)
}()
// 为每次执行创建新的上下文
ctx := context.Background()
err := job.Func(ctx)
if err != nil {
c.log.Errorf("任务[%d]:%s执行失败: %s", jobID, job.Name, err.Error())
}
})
if err != nil {
c.log.Errorf("添加任务失败:%s", err.Error())
}
}
// 启动cron调度器
c.Cron.Start()
c.log.Info("Cron调度器已启动")
}
// Stop 停止cron调度器
func (c *CronServer) Stop() {
if c.Cron != nil {
c.Cron.Stop()
c.log.Info("Cron调度器已停止")
}
}

View File

@ -9,4 +9,5 @@ var ProviderSetServer = wire.NewSet(
NewHTTPServer,
ProvideAllDingBotServices,
NewDingTalkBotServer,
NewCronServer,
)

View File

@ -10,18 +10,14 @@ type Servers struct {
cfg *config.Config
HttpServer *fiber.App
DingBotServer *DingTalkBotServer
Cron *CronServer
}
func NewServers(cfg *config.Config, fiber *fiber.App, DingBotServer *DingTalkBotServer) *Servers {
func NewServers(cfg *config.Config, fiber *fiber.App, DingBotServer *DingTalkBotServer, cron *CronServer) *Servers {
return &Servers{
HttpServer: fiber,
cfg: cfg,
DingBotServer: DingBotServer,
Cron: cron,
}
}
//func DingBotServerInit(clientId string, clientSecret string, cfg *config.Config, handler *do.Handle, do *do.Do) (cli *client.StreamClient) {
// cli = client.NewStreamClient(client.WithAppCredential(client.NewAppCredentialConfig(clientId, clientSecret)))
// cli.RegisterChatBotCallbackRouter(services.NewDingBotService(cfg, handler, do).OnChatBotMessageReceived)
// return
//}

View File

@ -198,7 +198,7 @@ func (s *CapabilityService) ProductIngestConfirm(c *fiber.Ctx) error {
// 调用eino工作流实现商品上传到目标系统
rec := &entitys.Recognize{UserContent: &entitys.RecognizeUserContent{Text: req.Confirmed}}
res, err := s.workflowManager.Invoke(ctx, workflowId, rec)
res, err := s.workflowManager.Invoke(ctx, workflowId, &runtime.WorkflowArgs{Recognize: rec})
if err != nil {
return err
}

View File

@ -86,7 +86,7 @@ func (h *ChatService) Chat(c *websocket.Conn) {
}
// 开启心跳检测
go client.InitHeartbeat(time.Duration(h.cfg.Sys.HeartbeatInterval))
//go client.InitHeartbeat(time.Duration(h.cfg.Sys.HeartbeatInterval))
// 循环读取客户端消息
for {

44
internal/services/cron.go Normal file
View File

@ -0,0 +1,44 @@
package services
import (
"ai_scheduler/internal/biz"
"ai_scheduler/internal/config"
"context"
"github.com/gofiber/fiber/v2/log"
)
type CronService struct {
config *config.Config
dingTalkBotBiz *biz.DingTalkBotBiz
}
func NewCronService(config *config.Config, dingTalkBotBiz *biz.DingTalkBotBiz) *CronService {
return &CronService{
config: config,
dingTalkBotBiz: dingTalkBotBiz,
}
}
func (d *CronService) CronReportSend(ctx context.Context) error {
groupId := 29
groupInfo, err := d.dingTalkBotBiz.GetGroupInfo(ctx, groupId)
if err != nil {
return err
}
reports, err := d.dingTalkBotBiz.GetReportLists(ctx, &groupInfo)
if err != nil {
return err
}
//contentChan <- "截止今日23点利润亏损合计127917.0866元亏损500元以上的分销商和产品金额如下图"
//contentChan <- "![图片](https://lsxdmgoss.oss-cn-chengdu.aliyuncs.com/MarketingSaaS/image/V2/other/shanghu.png)"
for _, report := range reports {
err = d.dingTalkBotBiz.SendReport(ctx, &groupInfo, report)
if err != nil {
log.Error(err)
continue
}
}
return nil
}

View File

@ -37,6 +37,11 @@ func (d *DingBotService) OnChatBotMessageReceived(ctx context.Context, data *cha
// 启动后台任务(独立生命周期,带超时控制)
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("稍等一下,问的人太多了,有点转不过来,请稍等下再来🚀🚀🚀")
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := d.runBackgroundTasks(ctx, data, requireData); err != nil {

View File

@ -1,130 +0,0 @@
package services
import (
"ai_scheduler/internal/biz"
"log"
"sync"
"time"
"ai_scheduler/internal/config"
"ai_scheduler/internal/entitys"
"context"
"gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot"
)
type DingBotService struct {
config *config.Config
dingTalkBotBiz *biz.DingTalkBotBiz
}
func NewDingBotService(config *config.Config, DingTalkBotBiz *biz.DingTalkBotBiz) *DingBotService {
return &DingBotService{config: config, dingTalkBotBiz: DingTalkBotBiz}
}
func (d *DingBotService) GetServiceCfg() ([]entitys.DingTalkBot, error) {
return d.dingTalkBotBiz.GetDingTalkBotCfgList()
}
func (d *DingBotService) OnChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
var (
lastErr error
chat []string
streamWG sync.WaitGroup
resChan = make(chan string, 100) // 缓冲通道防止阻塞
)
// 初始化请求
requireData, err := d.dingTalkBotBiz.InitRequire(ctx, data)
if err != nil {
return nil, err
}
// 创建子上下文用于控制goroutine生命周期
subCtx, cancel := context.WithCancel(ctx)
defer cancel()
// 启动流式处理goroutine
streamWG.Add(1)
go func() {
defer streamWG.Done()
err = d.dingTalkBotBiz.HandleStreamRes(subCtx, data, resChan)
if err != nil {
return
}
}()
// 启动业务处理goroutine
done := make(chan error, 1)
go func() {
done <- d.dingTalkBotBiz.Do(subCtx, requireData)
}()
// 主处理循环
for {
select {
case <-ctx.Done():
lastErr = ctx.Err()
goto cleanup
case resp, ok := <-requireData.Ch:
if !ok {
goto cleanup
}
// 处理不同类型响应
switch resp.Type {
case entitys.ResponseLog:
// 忽略日志类型
continue
//case entitys.ResponseText, entitys.ResponseJson:
// chat = append(chat, resp.Content)
// if err := d.dingTalkBotBiz.ReplyText(ctx, data.SessionWebhook, resp.Content); err != nil {
// log.Printf("处理非流响应失败: %v", err)
// lastErr = err
// }
default:
chat = append(chat, resp.Content)
select {
case resChan <- resp.Content:
case <-ctx.Done():
lastErr = ctx.Err()
goto cleanup
}
}
}
}
cleanup:
streamWG.Wait()
// 关闭流式通道
close(resChan)
// 保存历史记录
if saveErr := d.dingTalkBotBiz.SaveHis(ctx, requireData, chat); saveErr != nil {
log.Printf("保存历史记录失败: %v", saveErr)
if lastErr == nil {
lastErr = saveErr
}
}
// 等待业务处理完成(带超时)
select {
case err := <-done:
if err != nil {
log.Printf("业务处理失败: %v", err)
if lastErr == nil {
lastErr = err
}
}
case <-time.After(3 * time.Second): // 增加超时时间
log.Println("警告等待业务处理超时可能发生goroutine泄漏")
}
if lastErr != nil {
return nil, lastErr
}
return []byte("success"), nil
}

View File

@ -0,0 +1,112 @@
package services
import (
"ai_scheduler/internal/biz"
"ai_scheduler/internal/biz/do"
dingtalk2 "ai_scheduler/internal/biz/handle/dingtalk"
"ai_scheduler/internal/biz/llm_service"
"ai_scheduler/internal/biz/tools_regis"
"ai_scheduler/internal/config"
"ai_scheduler/internal/data/impl"
"ai_scheduler/internal/domain/component"
"ai_scheduler/internal/domain/component/callback"
"ai_scheduler/internal/domain/repo"
"ai_scheduler/internal/domain/workflow"
"ai_scheduler/internal/domain/workflow/runtime"
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/dingtalk"
"ai_scheduler/internal/pkg/utils_ollama"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/internal/pkg/utils_vllm"
"ai_scheduler/internal/tools"
"ai_scheduler/utils"
"context"
"testing"
"github.com/gofiber/fiber/v2/log"
)
func Test_Report(t *testing.T) {
run()
a := cronService.CronReportSend(context.Background())
t.Log(a)
}
var (
configConfig *config.Config
err error
dingBotService *DingBotService
cronService *CronService
)
// run 函数是程序的入口函数,负责初始化和配置各个组件
func run() {
// 加载测试配置
configConfig, err = config.LoadConfigWithTest()
// 初始化数据库连接
db, _ := utils.NewGormDb(configConfig)
// 初始化各种实现层组件
sysImpl := impl.NewSysImpl(db)
taskImpl := impl.NewTaskImpl(db)
chatHisImpl := impl.NewChatHisImpl(db)
sessionImpl := impl.NewSessionImpl(db)
botConfigImpl := impl.NewBotConfigImpl(db)
botGroupImpl := impl.NewBotGroupImpl(db)
botUserImpl := impl.NewBotUserImpl(db)
// 初始化Do业务对象
doDo := do.NewDo(sysImpl, taskImpl, chatHisImpl, configConfig)
// 初始化Ollama客户端
client, _, _ := utils_ollama.NewClient(configConfig)
// 初始化vLLM客户端
utils_vllmClient, _, _ := utils_vllm.NewClient(configConfig)
// 初始化Redis数据库连接
rdb := utils.NewRdb(configConfig)
// 初始化仓库层
repos := repo.NewRepos(sessionImpl, configConfig)
// 初始化包级别的Redis连接
pkgRdb := pkg.NewRdb(configConfig)
// 初始化机器人工具实现层
botToolsImpl := impl.NewBotToolsImpl(db)
// 初始化机器人部门实现层
botDeptImpl := impl.NewBotDeptImpl(db)
// 初始化Redis管理器
redisManager := callback.NewRedisManager(pkgRdb)
// 初始化组件
components := component.NewComponents(redisManager)
// 初始化工作流注册表
registry := workflow.NewRegistry(configConfig, client, repos, components)
// 初始化钉钉旧版客户端
oldClient := dingtalk.NewOldClient(configConfig)
// 初始化Ollama服务
ollamaService := llm_service.NewOllamaGenerate(client, utils_vllmClient, configConfig, chatHisImpl)
// 初始化工具管理器
manager := tools.NewManager(configConfig, client)
// 初始化钉钉联系人客户端
contactClient, _ := dingtalk.NewContactClient(configConfig)
// 初始化钉钉记事本客户端
notableClient, _ := dingtalk.NewNotableClient(configConfig)
// 初始化工具注册
toolRegis := tools_regis.NewToolsRegis(botToolsImpl)
// 初始化机器人聊天历史实现层
botChatHisImpl := impl.NewBotChatHisImpl(db)
// 初始化钉钉认证
auth := dingtalk2.NewAuth(configConfig, rdb, botConfigImpl)
// 初始化部门服务
dept := dingtalk2.NewDept(botDeptImpl, auth)
// 初始化用户服务
user := dingtalk2.NewUser(botUserImpl, auth, dept)
// 初始化发送卡片客户端
sendCardClient := dingtalk2.NewSendCardClient(auth, log.DefaultLogger())
// 初始化处理器
handle := do.NewHandle(ollamaService, manager, configConfig, sessionImpl, registry, oldClient, contactClient, notableClient)
// 初始化钉钉机器人业务逻辑
utils_ossClient, _ := utils_oss.NewClient(configConfig)
// 初始化工作流管理器
workflowManager := runtime.NewRegistry()
dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, toolRegis, botChatHisImpl, manager, configConfig, sendCardClient, utils_ossClient, workflowManager)
// 初始化钉钉机器人服务
cronService = NewCronService(configConfig, dingTalkBotBiz)
dingBotService = NewDingBotService(configConfig, dingTalkBotBiz)
}

View File

@ -14,4 +14,5 @@ var ProviderSetServices = wire.NewSet(
NewDingBotService,
NewHistoryService,
NewCapabilityService,
NewCronService,
)

248
internal/tools/bbxt/api.go Normal file
View File

@ -0,0 +1,248 @@
package bbxt
import (
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/l_request"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
type StatisOursProductLossSumReq struct {
ResellerId int `json:"reseller_id,omitempty"`
Ct []string `json:"ct,omitempty"`
}
type StatisOursProductLossSumRes struct {
List []*StatisOursProductLossSumResponse `protobuf:"bytes,1,rep,name=list,proto3" json:"list,omitempty"`
}
type StatisOursProductLossSumResponse struct {
OursProductId int32 `json:"oursProductId,omitempty"`
OursProductName string `json:"oursProductName,omitempty"`
ResellerName string `json:"resellerName,omitempty"`
ResellerId int32 `json:"resellerId,omitempty"`
Loss float64 `json:"loss,omitempty"`
}
const Base = "https://reportapi.1688sup.com/api"
// StatisOursProductLossSumApi 负利润分析
func StatisOursProductLossSumApi(param *StatisOursProductLossSumReq) (*StatisOursProductLossSumRes, error) {
url := "/dataanalytics/statisOursProductLossSum"
var res StatisOursProductLossSumRes
if err := request(url, param, &res); err != nil {
return nil, err
}
return &res, nil
}
type GetProfitRankingSumRequest struct {
Ct []string `json:"ct,omitempty"`
Page int32 `json:"page,omitempty"`
Limit int32 `json:"limit,omitempty"`
ResellerIds []int32 `json:"reseller_ids,omitempty"`
}
type GetProfitRankingSumResponse struct {
List []*ProfitRankingSumResponse `protobuf:"bytes,1,rep,name=list,proto3" json:"list,omitempty"`
DataCount int32 `protobuf:"varint,2,opt,name=data_count,json=dataCount,proto3" json:"data_count,omitempty"`
}
type ProfitRankingSumResponse struct {
// 分销商ID
ResellerId string `protobuf:"bytes,1,opt,name=reseller_id,json=resellerId,proto3" json:"ResellerId,omitempty"`
// 分销商名称
ResellerName string `protobuf:"bytes,2,opt,name=reseller_name,json=resellerName,proto3" json:"ResellerName,omitempty"`
// 当前利润
CurrentProfit float64 `protobuf:"fixed64,3,opt,name=current_profit,json=currentProfit,proto3" json:"CurrentProfit,omitempty"`
// 昨日同比利润
HistoryOneProfit float64 `protobuf:"fixed64,4,opt,name=history_one_profit,json=historyOneProfit,proto3" json:"HistoryOneProfit,omitempty"`
// 上周同比利润
HistoryTwoProfit float64 `protobuf:"fixed64,5,opt,name=history_two_profit,json=historyTwoProfit,proto3" json:"HistoryTwoProfit,omitempty"`
// 昨日同比利润差值
HistoryOneDiff float64 `protobuf:"fixed64,6,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"HistoryOneDiff,omitempty"`
// 上周同比利润差值
HistoryTwoDiff float64 `protobuf:"fixed64,7,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"HistoryTwoDiff,omitempty"`
}
// GetProfitRankingSumApi 利润同比分销商排行榜
func GetProfitRankingSumApi(param *GetProfitRankingSumRequest) (*GetProfitRankingSumResponse, error) {
url := "/dataanalytics/profitRankingSum"
var res GetProfitRankingSumResponse
if err := request(url, param, &res); err != nil {
return nil, err
}
return &res, nil
}
type GetStatisOfficialProductSumRequest struct {
Ct []string `protobuf:"bytes,1,rep,name=ct,proto3" json:"ct,omitempty"`
DownwardValue int32 `protobuf:"varint,4,opt,name=downward_value,json=downwardValue,proto3" json:"downward_value,omitempty"`
Page int32 `protobuf:"varint,5,opt,name=page,proto3" json:"page,omitempty"`
Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"`
OfficialProductId []int32 `protobuf:"varint,7,rep,packed,name=official_product_id,json=officialProductId,proto3" json:"official_product_id,omitempty"`
}
type GetStatisOfficialProductSumResponse struct {
OfficialProductSum []*GetStatisOfficialProductSum `protobuf:"bytes,1,rep,name=OfficialProductSum,json=officialProductSum,proto3" json:"officialProductSum,omitempty"`
DataCount int32 `protobuf:"varint,2,opt,name=DataCount,json=dataCount,proto3" json:"dataCount,omitempty"`
}
type GetStatisOfficialProductSum struct {
OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"officialProductId,omitempty"`
OfficialProductName string `protobuf:"bytes,2,opt,name=official_product_name,json=officialProductName,proto3" json:"officialProductName,omitempty"`
CurrentNum int32 `protobuf:"varint,3,opt,name=current_num,json=currentNum,proto3" json:"currentNum,omitempty"`
HistoryOneNum int32 `protobuf:"varint,4,opt,name=history_one_num,json=historyOneNum,proto3" json:"historyOneNum,omitempty"`
HistoryTwoNum int32 `protobuf:"varint,5,opt,name=history_two_num,json=historyTwoNum,proto3" json:"historyTwoNum,omitempty"`
HistoryOneDiff int32 `protobuf:"varint,6,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"historyOneDiff,omitempty"`
HistoryTwoDiff int32 `protobuf:"varint,7,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"historyTwoDiff,omitempty"`
}
// GetStatisOfficialProductSumApi 销量同比分析
func GetStatisOfficialProductSumApi(param *GetStatisOfficialProductSumRequest) (*GetStatisOfficialProductSumResponse, error) {
url := "/dataanalytics/statisOfficialProduct"
var res GetStatisOfficialProductSumResponse
if err := request(url, param, &res); err != nil {
return nil, err
}
return &res, nil
}
type GetStatisOfficialProductSumDeclineResponse struct {
OfficialProductSumDecline []*GetStatisOfficialProductSumDecline `protobuf:"bytes,1,rep,name=official_product_sum_decline,json=officialProductSumDecline,proto3" json:"officialProductSumDecline,omitempty"`
DataCount int32 `protobuf:"varint,2,opt,name=data_count,json=dataCount,proto3" json:"dataCount,omitempty"`
}
type GetStatisOfficialProductSumDecline struct {
ResellerId int32 `protobuf:"varint,1,opt,name=reseller_id,json=resellerId,proto3" json:"resellerId,omitempty"`
OfficialProductId int32 `protobuf:"varint,2,opt,name=official_product_id,json=officialProductId,proto3" json:"officialProductId,omitempty"`
OfficialProductName string `protobuf:"bytes,3,opt,name=official_product_name,json=officialProductName,proto3" json:"officialProductName,omitempty"`
ResellerName string `protobuf:"bytes,4,opt,name=reseller_name,json=resellerName,proto3" json:"resellerName,omitempty"`
CurrentNum int32 `protobuf:"varint,5,opt,name=current_num,json=currentNum,proto3" json:"currentNum,omitempty"`
HistoryOneNum int32 `protobuf:"varint,6,opt,name=history_one_num,json=historyOneNum,proto3" json:"historyOneNum,omitempty"`
HistoryTwoNum int32 `protobuf:"varint,7,opt,name=history_two_num,json=historyTwoNum,proto3" json:"historyTwoNum,omitempty"`
HistoryOneDiff int32 `protobuf:"varint,8,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"historyOneDiff,omitempty"`
HistoryTwoDiff int32 `protobuf:"varint,9,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"historyTwoDiff,omitempty"`
}
// GetStatisOfficialProductSumDeclineApi 销量同比分析
func GetStatisOfficialProductSumDeclineApi(param *GetStatisOfficialProductSumRequest) (*GetStatisOfficialProductSumDeclineResponse, error) {
url := "/dataanalytics/statisOfficialProductDecline"
var res GetStatisOfficialProductSumDeclineResponse
if err := request(url, param, &res); err != nil {
return nil, err
}
return &res, nil
}
type resCode struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Error string `json:"error"`
}
type GetStatisFilterOfficialProductRequest struct {
OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"official_product_id,omitempty"`
}
type GetStatisFilterOfficialProductResponse struct {
List []*StatisFilterOfficialProductResponse `protobuf:"bytes,1,rep,name=list,proto3" json:"list,omitempty"`
}
type StatisFilterOfficialProductResponse struct {
OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"OfficialProductId,omitempty"`
OfficialProductName string `protobuf:"bytes,2,opt,name=official_product_name,json=officialProductName,proto3" json:"OfficialProductName,omitempty"`
}
// GetStatisFilterOfficialProductApi 官方商品列表
func GetStatisFilterOfficialProductApi(param *GetStatisFilterOfficialProductRequest) (*GetStatisFilterOfficialProductResponse, error) {
url := "/dataanalytics/statisFilterOfficialProduct"
var res GetStatisFilterOfficialProductResponse
if err := request(url, param, &res); err != nil {
return nil, err
}
return &res, nil
}
func request(url string, reqData interface{}, resData interface{}) error {
reqParam, err := pkg.StructToURLValues(reqData)
if err != nil {
return err
}
req := &l_request.Request{
Url: FormatPHPURL(Base+url, reqParam),
Method: http.MethodGet,
}
res, err := req.Send()
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("request failed, status code: %d,resion: %s", res.StatusCode, res.Reason)
}
var code resCode
if err = json.Unmarshal(res.Content, &code); err != nil {
return fmt.Errorf("返回结构异常:%s", string(res.Content))
}
if code.Code != 200 {
return fmt.Errorf("返回状态异常:%s", string(code.Error))
}
if err = json.Unmarshal(code.Data, resData); err != nil {
return fmt.Errorf("返回数据异常:%s", string(res.Content))
}
return nil
}
// FormatPHPURL 将 url.Values 格式化为 PHP 风格的 URL
// 输入基础URL和url.Values参数
// 输出PHP风格的URL字符串
func FormatPHPURL(baseURL string, values url.Values) string {
if values == nil || len(values) == 0 {
return baseURL
}
var queryParts []string
// 遍历所有参数
for key, paramValues := range values {
// 检查这个key是否有多个值数组参数
if len(paramValues) > 1 {
// 多值参数使用PHP数组格式key[]=value
for _, value := range paramValues {
if value != "" {
// 编码值
encodedValue := url.QueryEscape(value)
// 使用PHP数组格式
queryParts = append(queryParts, fmt.Sprintf("%s[]=%s", key, encodedValue))
}
}
} else if len(paramValues) == 1 && paramValues[0] != "" {
// 单值参数
encodedValue := url.QueryEscape(paramValues[0])
queryParts = append(queryParts, fmt.Sprintf("%s=%s", key, encodedValue))
}
}
if len(queryParts) == 0 {
return baseURL
}
// 构建查询字符串
query := strings.Join(queryParts, "&")
// 转换为PHP风格解码中括号和冒号
query = strings.ReplaceAll(query, "%5B", "[")
query = strings.ReplaceAll(query, "%5D", "]")
query = strings.ReplaceAll(query, "%3A", ":")
// 注意:保留空格为+号这是PHP的常见格式
// query = strings.ReplaceAll(query, "+", "%20") // 如果需要将+转为%20可以取消注释
return baseURL + "?" + query
}

423
internal/tools/bbxt/bbxt.go Normal file
View File

@ -0,0 +1,423 @@
package bbxt
import (
pkginner "ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/utils_oss"
"ai_scheduler/pkg"
"fmt"
"math/rand"
"slices"
"sort"
"time"
)
const (
RedStyle = "${color: FF0000;horizontal:center;vertical:center;borderColor:#000000}"
GreenStyle = "${color: 00B050;horizontal:center;vertical:center;borderColor:#000000}"
)
var (
DownWardValue int32 = 1500
SumFilter int32 = -150
)
var resellerBlackList = []string{
"悦跑",
"电商-独立",
"蓝星严选连续包月",
"通钱-2025年12月",
}
type BbxtTools struct {
cacheDir string
excelTempDir string
ossClient *utils_oss.Client
}
func NewBbxtTools() (*BbxtTools, error) {
cache, err := pkg.GetCacheDir()
if err != nil {
return nil, err
}
tempDir, err := pkg.GetTmplDir()
if err != nil {
return nil, err
}
return &BbxtTools{
cacheDir: cache,
excelTempDir: fmt.Sprintf("%s/excel_temp", tempDir),
}, nil
}
func (b *BbxtTools) DailyReport(now time.Time, downWardValue int32, productName []string, sumFilter int32, ossClient *utils_oss.Client) (reports []*ReportRes, err error) {
reports = make([]*ReportRes, 0, 4)
productLossReport, err := b.StatisOursProductLossSum(now)
if err != nil {
return
}
profitRankingSum, err := b.GetProfitRankingSum(now)
if err != nil {
return
}
statisOfficialProductSum, err := b.GetStatisOfficialProductSum(now, productName)
if err != nil {
return
}
statisOfficialProductSumDecline, err := b.GetStatisOfficialProductSumDecline(now, downWardValue, productName, sumFilter)
if err != nil {
return
}
reports = append(reports, productLossReport...)
reports = append(reports, statisOfficialProductSum, profitRankingSum, statisOfficialProductSumDecline)
if ossClient != nil {
uploader := NewUploader(ossClient)
for _, report := range reports {
_ = uploader.Run(report)
}
}
return
}
// StatisOursProductLossSum 负利润分析
func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes, err error) {
ct := []string{
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"),
adjustedTime(now), //adjustedTime(time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())),
}
data, err := StatisOursProductLossSumApi(&StatisOursProductLossSumReq{
Ct: ct,
})
if err != nil {
return
}
var (
resellerMap = make(map[int32]*ResellerLoss)
total [][]string
gt []*ResellerLoss
)
for _, info := range data.List {
// 检查经销商是否已存在
if _, ok := resellerMap[info.ResellerId]; !ok {
// 创建新的经销商记录
resellerMap[info.ResellerId] = &ResellerLoss{
ResellerId: info.ResellerId,
ResellerName: info.ResellerName,
Total: 0, // 初始化为0后续累加
ProductLoss: make(map[int32]ProductLoss), // 初始化map
}
}
// 获取当前经销商
reseller := resellerMap[info.ResellerId]
// 累加经销商总亏损
reseller.Total += info.Loss
// 检查产品是否已存在
if _, ok := reseller.ProductLoss[info.OursProductId]; !ok {
// 创建新的产品亏损记录
reseller.ProductLoss[info.OursProductId] = ProductLoss{
ProductId: info.OursProductId,
ProductName: info.OursProductName,
Loss: info.Loss, // 初始化为当前产品的亏损
}
} else {
// 已存在产品记录,累加亏损
productLoss := reseller.ProductLoss[info.OursProductId]
productLoss.Loss += info.Loss
reseller.ProductLoss[info.OursProductId] = productLoss
}
}
// 按经销商总亏损排序
resellers := make([]*ResellerLoss, 0, len(resellerMap))
for _, v := range resellerMap {
resellers = append(resellers, v)
}
sort.Slice(resellers, func(i, j int) bool {
return resellers[i].Total < resellers[j].Total
})
var (
totalSum float64
totalSum500 float64
)
// 构建分组
for _, v := range resellers {
if v.Total <= -100 && !slices.Contains(resellerBlackList, v.ResellerName) {
total = append(total, []string{
fmt.Sprintf("%s", v.ResellerName),
fmt.Sprintf("%.2f", v.Total),
})
totalSum += v.Total
}
if v.Total <= -500 && !slices.Contains(resellerBlackList, v.ResellerName) {
gt = append(gt, v)
totalSum500 += v.Total
}
}
report = make([]*ReportRes, 2)
timeCh := now.Format("1月2日15点")
//总量生成excel
if len(total) > 0 {
filePath := b.cacheDir + "/kshj_total" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total, "")
report[0] = &ReportRes{
ReportName: "负利润分析(合计表)",
Title: "截至" + timeCh + "利润累计亏损" + fmt.Sprintf("%.2f", totalSum),
Path: filePath,
Data: total,
}
}
if err != nil {
return
}
if len(gt) > 0 {
filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
title := "截至" + timeCh + "亏损500以上的分销商和产品"
err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt, title)
report[1] = &ReportRes{
ReportName: "负利润分析(亏损500以上)",
Title: "截至" + timeCh + "亏损500以上利润累计亏损" + fmt.Sprintf("%.2f", totalSum500),
Path: filePath,
Data: total,
}
}
if err != nil {
return
}
return report, nil
}
// GetProfitRankingSum 利润同比分销商排行榜
func (b *BbxtTools) GetProfitRankingSum(now time.Time) (report *ReportRes, err error) {
ct := []string{
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"),
adjustedTime(now),
}
data, err := GetProfitRankingSumApi(&GetProfitRankingSumRequest{
Ct: ct,
})
timeCh := now.Format("1月2日15点")
title := "截至" + timeCh + "利润同比分销商排行榜"
if err != nil {
return
}
//排序
sort.Slice(data.List, func(i, j int) bool {
return data.List[i].HistoryOneDiff > data.List[j].HistoryOneDiff
})
//取前20和后20
var (
total [][]string
top = data.List[:20]
bottom = data.List[len(data.List)-20:]
)
//合并前20和后20
top = append(top, bottom...)
// 构建分组
for _, v := range top {
var diff string
if v.HistoryOneDiff > 0 {
diff = fmt.Sprintf("%s↑%.4f", RedStyle, v.HistoryOneDiff)
} else {
diff = fmt.Sprintf("%s↓%.4f", GreenStyle, v.HistoryOneDiff)
}
total = append(total, []string{
fmt.Sprintf("%s", v.ResellerName),
fmt.Sprintf("%.4f", v.CurrentProfit),
fmt.Sprintf("%.4f", v.HistoryOneProfit),
diff,
})
}
//总量生成excel
if len(total) == 0 {
return
}
filePath := b.cacheDir + "/lrtb_rank" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx"
err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"lrtb_rank.xlsx", filePath, total, title)
return &ReportRes{
ReportName: "利润同比分销商排行榜",
Title: title,
Path: filePath,
Data: total,
}, err
}
// GetStatisOfficialProductSum 销量同比分析
func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []string) (report *ReportRes, err error) {
var productMap = make(map[string]int)
for k, v := range productName {
productMap[v] = k
}
ct := []string{
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"),
adjustedTime(now),
}
var ids []int32
if len(productName) > 0 {
ids, err = b.getProductIdFromProductName(productName)
if err != nil {
return
}
}
reqParam := &GetStatisOfficialProductSumRequest{
Ct: ct,
}
if len(ids) > 0 {
reqParam.OfficialProductId = ids
}
data, err := GetStatisOfficialProductSumApi(reqParam)
if err != nil {
return
}
var total = make([][]string, len(ids))
for _, v := range data.OfficialProductSum {
var (
yeterDatyDiff string
lastWeekDiff string
)
if v.HistoryOneDiff > 0 {
yeterDatyDiff = fmt.Sprintf("%s↑%d", RedStyle, v.HistoryOneDiff)
} else {
yeterDatyDiff = fmt.Sprintf("%s↓%d", GreenStyle, v.HistoryOneDiff)
}
if v.HistoryTwoDiff > 0 {
lastWeekDiff = fmt.Sprintf("%s↑%d", RedStyle, v.HistoryTwoDiff)
} else {
lastWeekDiff = fmt.Sprintf("%s↓%d", GreenStyle, v.HistoryTwoDiff)
}
total[productMap[v.OfficialProductName]] = []string{
fmt.Sprintf("%s", v.OfficialProductName),
fmt.Sprintf("%d", v.CurrentNum),
fmt.Sprintf("%d", v.HistoryOneNum),
yeterDatyDiff,
fmt.Sprintf("%d", v.HistoryTwoNum),
lastWeekDiff,
}
}
timeCh := now.Format("1月2日15点")
title := "截至" + timeCh + "销售同比分析"
//总量生成excel
if len(total) == 0 {
return
}
filePath := b.cacheDir + "/xstb_ana" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx"
err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"xstb_ana.xlsx", filePath, total, title)
return &ReportRes{
ReportName: "销售同比分析",
Title: title,
Path: filePath,
Data: total,
}, err
}
// GetStatisOfficialProductSumDecline 销量下滑明细
func (b *BbxtTools) GetStatisOfficialProductSumDecline(now time.Time, downWardValue int32, productName []string, sumFilter int32) (report *ReportRes, err error) {
var productMap = make(map[string]int)
for k, v := range productName {
productMap[v] = k
}
ct := []string{
time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"),
adjustedTime(now),
}
var ids []int32
if len(productName) > 0 {
ids, err = b.getProductIdFromProductName(productName)
if err != nil {
return
}
}
reqParam := &GetStatisOfficialProductSumRequest{
Ct: ct,
DownwardValue: downWardValue,
OfficialProductId: ids,
Page: 1,
Limit: 1000,
}
data, err := GetStatisOfficialProductSumDeclineApi(reqParam)
if err != nil {
return
}
var (
productSumMap = make(map[int32]ProductSumDecline)
)
for _, v := range data.OfficialProductSumDecline {
if _, ex := productSumMap[v.OfficialProductId]; !ex {
productSumMap[v.OfficialProductId] = ProductSumDecline{
OfficialProductName: v.OfficialProductName,
OfficialProductId: v.OfficialProductId,
ProductSumReseller: make(map[int32]ProductSumReseller),
}
}
if v.HistoryOneDiff <= sumFilter || v.HistoryTwoDiff <= sumFilter {
productSumMap[v.OfficialProductId].ProductSumReseller[v.ResellerId] = ProductSumReseller{
ResellerName: v.ResellerName,
CurrentNum: v.CurrentNum,
HistoryOneNum: v.HistoryOneNum,
HistoryOneDiff: v.HistoryOneDiff,
HistoryTwoNum: v.HistoryTwoNum,
HistoryTwoDiff: v.HistoryTwoDiff,
}
}
}
timeCh := now.Format("1月2日15点")
//title := "截至" + timeCh + "销量下滑大于" + fmt.Sprintf("%d", downWardValue) + "明细,分销商仅展示差额大于" + fmt.Sprintf("%d", -sumFilter)
title := "截至" + timeCh + "点销量下滑较大商品"
//总量生成excel
if len(productSumMap) == 0 {
return
}
filePath := b.cacheDir + "/xlxhmx" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx"
err = b.OfficialProductSumDeclineExcel(b.excelTempDir+"/"+"/xlxhmx.xlsx", filePath, productSumMap, title)
return &ReportRes{
ReportName: "销售下滑明细",
Title: title,
Path: filePath,
Desc: pkginner.JsonStringIgonErr(productSumMap),
}, err
}
func (b *BbxtTools) getProductIdFromProductName(productNames []string) ([]int32, error) {
data, err := GetStatisFilterOfficialProductApi(&GetStatisFilterOfficialProductRequest{})
if err != nil {
return nil, err
}
var product2IdMap = make(map[string]int32)
for _, v := range data.List {
product2IdMap[v.OfficialProductName] = v.OfficialProductId
}
var ids []int32
for _, v := range productNames {
if id, ok := product2IdMap[v]; ok {
ids = append(ids, id)
}
}
return ids, nil
}
func adjustedTime(t time.Time) string {
adjusted := time.Date(
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), 59, 999_000_000,
t.Location(),
)
return adjusted.Format("2006-01-02 15:04:05.999")
}

View File

@ -0,0 +1,81 @@
package bbxt
import (
"ai_scheduler/internal/config"
"ai_scheduler/internal/pkg/utils_oss"
"strings"
"testing"
"time"
)
func Test_StatisOursProductLossSumApiTotal(t *testing.T) {
var config = &config.Config{
Oss: config.Oss{
AccessKey: "LTAI5tGGZzjf3tvqWk8SQj2G",
SecretKey: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq",
Bucket: "attachment-public",
Domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com",
Endpoint: "https://oss-cn-hangzhou.aliyuncs.com",
},
}
ossClient, err := utils_oss.NewClient(config)
if err != nil {
panic(err)
}
o, err := NewBbxtTools()
if err != nil {
panic(err)
}
reports, err := o.DailyReport(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}, ossClient)
t.Log(reports, err)
}
func Test_StatisOursProductLossSum(t *testing.T) {
o, err := NewBbxtTools()
if err != nil {
panic(err)
}
report, err := o.StatisOursProductLossSum(time.Now())
t.Log(report, err)
}
func Test_GetProfitRankingSum(t *testing.T) {
o, err := NewBbxtTools()
if err != nil {
panic(err)
}
report, err := o.GetProfitRankingSum(time.Now())
t.Log(report, err)
}
func Test_GetStatisOfficialProductSumDecline(t *testing.T) {
o, err := NewBbxtTools()
if err != nil {
panic(err)
}
s := "官方--美团外卖红包5元,官方--美团外卖红包10元,官方--饿了么超级会员月卡,官方--网易云黑胶vip月卡,官方--喜马拉雅巅峰会员月卡,官方--芒果-PC季卡,官方--芒果-PC月卡,官方--芒果-PC周卡,官方--腾讯-周卡,官方--优酷周卡,官方--QQ音乐-绿钻月卡,官方--爱奇艺-周卡,官方--腾讯-月卡,官方--腾讯-季卡,官方--腾讯-年卡,官方--优酷月卡,官方--优酷季卡,官方--优酷年卡,官方--爱奇艺-月卡,官方--爱奇艺-季卡,官方--爱奇艺-年卡"
//s := "官方--QQ音乐-绿钻月卡"
report, err := o.GetStatisOfficialProductSumDecline(time.Now(), 1000, strings.Split(s, ","), -150)
t.Log(report, err)
}
func Test_GetStatisOfficialProductSum(t *testing.T) {
o, err := NewBbxtTools()
if err != nil {
panic(err)
}
s := "官方--美团外卖红包5元,官方--美团外卖红包10元,官方--饿了么超级会员月卡,官方--网易云黑胶vip月卡,官方--喜马拉雅巅峰会员月卡,官方--芒果-PC季卡,官方--芒果-PC月卡,官方--芒果-PC周卡,官方--腾讯-周卡,官方--优酷周卡,官方--QQ音乐-绿钻月卡,官方--爱奇艺-周卡,官方--腾讯-月卡,官方--腾讯-季卡,官方--腾讯-年卡,官方--优酷月卡,官方--优酷季卡,官方--优酷年卡,官方--爱奇艺-月卡,官方--爱奇艺-季卡,官方--爱奇艺-年卡"
report, err := o.GetStatisOfficialProductSum(time.Now(), strings.Split(s, ","))
t.Log(report, err)
}

View File

@ -0,0 +1,38 @@
package bbxt
type ResellerLoss struct {
ResellerId int32
ResellerName string
Total float64
ProductLoss map[int32]ProductLoss
}
type ProductLoss struct {
ProductId int32
ProductName string
Loss float64
}
type ReportRes struct {
ReportName string
Title string
Path string
Url string
Data [][]string
Desc string
}
type ProductSumDecline struct {
OfficialProductId int32
OfficialProductName string
ProductSumReseller map[int32]ProductSumReseller
}
type ProductSumReseller struct {
ResellerName string
CurrentNum int32 //今日成功数量
HistoryOneNum int32 //昨日成功数量
HistoryOneDiff int32 //同比昨日当前增减量
HistoryTwoNum int32 //上周成功数量
HistoryTwoDiff int32 //同比上周当前增减量
}

View File

@ -0,0 +1,312 @@
package bbxt
import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"github.com/go-kratos/kratos/v2/log"
"github.com/shopspring/decimal"
"github.com/xuri/excelize/v2"
)
func (b *BbxtTools) SimpleFillExcelWithTitle(templatePath, outputPath string, dataSlice interface{}, title string) error {
// 打开模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
startLen := 2
if len(title) > 0 {
// 写入标题
f.SetCellValue(sheet, "A1", title)
startLen = 3
}
// 获取模板样式
templateRow := startLen
styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", templateRow))
if err != nil {
log.Errorf("获取模板样式失败: %v", err)
styleID = 0
}
// 获取模板行高
rowHeight, err := f.GetRowHeight(sheet, templateRow)
if err != nil {
log.Errorf("获取模板行高失败: %v", err)
rowHeight = 31 // 默认高度
}
// 反射获取切片数据
v := reflect.ValueOf(dataSlice)
if v.Kind() != reflect.Slice {
return fmt.Errorf("dataSlice must be a slice")
}
if v.Len() == 0 {
return nil
}
// 从第三行开始填充数据(第二行留空或作为标题行)
startRow := startLen
pattern := `\$\{(.*?)\}`
re := regexp.MustCompile(pattern)
for i := 0; i < v.Len(); i++ {
currentRow := startRow + i
// 获取当前行数据
item := v.Index(i)
// 处理不同类型的切片
var rowData []interface{}
if item.Kind() == reflect.Slice || item.Kind() == reflect.Array {
// 处理 []string 或 [][]string 中的一行
for j := 0; j < item.Len(); j++ {
if item.Index(j).CanInterface() {
rowData = append(rowData, item.Index(j).Interface())
}
}
} else if item.Kind() == reflect.Interface {
// 处理 interface{} 类型
if actualValue, ok := item.Interface().([]string); ok {
for _, val := range actualValue {
rowData = append(rowData, val)
}
} else {
rowData = []interface{}{item.Interface()}
}
} else {
rowData = []interface{}{item.Interface()}
}
// 4.1 设置行高
f.SetRowHeight(sheet, currentRow, rowHeight)
// 应用模板样式到整行(根据实际列数)
if styleID != 0 && len(rowData) > 0 {
startCol := "A"
endCol := fmt.Sprintf("%c", 'A'+len(rowData)-1)
endCell := fmt.Sprintf("%s%d", endCol, currentRow)
f.SetCellStyle(sheet, fmt.Sprintf("%s%d", startCol, currentRow),
endCell, styleID)
}
// 填充数据到Excel
for col, value := range rowData {
cell := fmt.Sprintf("%c%d", 'A'+col, currentRow)
switch value.(type) {
case string:
var style = value.(string)
if re.MatchString(style) {
matches := re.FindStringSubmatch(style)
styleMap := make(map[string]string)
//matches = strings.Replace(matches, "$", "", 1)
if len(matches) != 2 {
continue
}
for _, kv := range strings.Split(matches[1], ";") {
kvParts := strings.Split(kv, ":")
if len(kvParts) == 2 {
styleMap[strings.TrimSpace(kvParts[0])] = strings.TrimSpace(kvParts[1])
}
}
fontStyleID, _err := SetStyle(styleMap, f)
if _err == nil {
f.SetCellStyle(sheet, cell, cell, fontStyleID)
}
value = re.ReplaceAllString(style, "")
}
f.SetCellValue(sheet, cell, value)
default:
}
}
}
// 保存
return f.SaveAs(outputPath)
}
func SetStyle(styleMap map[string]string, f *excelize.File) (int, error) {
var style = &excelize.Style{}
// 设置字体颜色
if colorHex, exists := styleMap["color"]; exists {
style.Font = &excelize.Font{
Color: colorHex,
}
}
// 设置水平对齐
if horizontal, exists := styleMap["horizontal"]; exists {
if style.Alignment == nil {
style.Alignment = &excelize.Alignment{}
}
style.Alignment.Horizontal = horizontal
}
// 设置垂直对齐
if vertical, exists := styleMap["vertical"]; exists {
if style.Alignment == nil {
style.Alignment = &excelize.Alignment{}
}
style.Alignment.Vertical = vertical
}
// 设置边框(新增)
if borderColor, exists := styleMap["borderColor"]; exists {
style.Border = []excelize.Border{
{Type: "left", Color: borderColor, Style: 1}, // 左边框
{Type: "right", Color: borderColor, Style: 1}, // 右边框
{Type: "top", Color: borderColor, Style: 1}, // 上边框
{Type: "bottom", Color: borderColor, Style: 1}, // 下边框
}
}
return f.NewStyle(style)
}
// 分销商负利润详情填充excel-V2
// 1.使用模板文件作为输出文件,从第二行开始填充
// 2.整体为3列1.分销商名称以ResellerName为分组分销商名称列使用的样式为 2.商品名称p.ProductName 3.亏损金额p.Loss
// 3.分销商名称列使用的样式为 A2商品名称、亏损金额使用的样式为 B2、C2样式包括宽高、背景、颜色等
// 4.以ResellerName分组合并单元格
// 5.在文件末尾使用“合计”,合计行样式为模板第四行
// 6.保存为新文件
func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, dataSlice []*ResellerLoss, title string) error {
// 1. 读取模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
if len(title) > 0 {
// 写入标题
f.SetCellValue(sheet, "A1", title)
}
// ---------------- 样式获取 ----------------
// 模板第2行数据行样式
tplRowData := 2
styleA2, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowData))
if err != nil {
styleA2 = 0
}
// B2和C2通常样式一致这里取B2作为明细列样式
styleB2, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowData))
if err != nil {
styleB2 = 0
}
styleC2, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowData))
if err != nil {
styleC2 = 0
}
rowHeightData, err := f.GetRowHeight(sheet, tplRowData)
if err != nil {
rowHeightData = 20
}
// 模板第4行合计行样式
tplRowTotal := 4
styleTotalA, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowTotal))
if err != nil {
styleTotalA = 0
}
styleTotalB, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowTotal))
if err != nil {
styleTotalB = 0
}
styleTotalC, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowTotal))
if err != nil {
styleTotalC = 0
}
rowHeightTotal, err := f.GetRowHeight(sheet, tplRowTotal)
if err != nil {
rowHeightTotal = 30
}
// ----------------------------------------
currentRow := 2
totalLoss := 0.0
for _, reseller := range dataSlice {
// 排序 ProductLoss
var products []ProductLoss
for _, p := range reseller.ProductLoss {
products = append(products, p)
}
sort.Slice(products, func(i, j int) bool {
return products[i].Loss < products[j].Loss
})
startRow := currentRow
// 填充该经销商的所有产品
for _, p := range products {
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightData)
// 设置值
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName)
f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.ProductName)
f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), p.Loss)
// 设置样式
if styleA2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleA2)
}
if styleB2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleB2)
}
if styleC2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleC2)
}
totalLoss += p.Loss
currentRow++
}
endRow := currentRow - 1
// 合并单元格 (如果多于1行)
if endRow > startRow {
f.MergeCell(sheet, fmt.Sprintf("A%d", startRow), fmt.Sprintf("A%d", endRow))
}
}
// ---------------- 填充合计行 ----------------
// 四舍五入保留四位小数
totalLoss, _ = decimal.NewFromFloat(totalLoss).Round(4).Float64()
// 设置行高
f.SetRowHeight(sheet, currentRow, rowHeightTotal)
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), "合计")
// B列留空C列填充总亏损
f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), totalLoss)
// 设置合计行样式
if styleTotalA != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleTotalA)
}
if styleTotalB != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleTotalB)
}
if styleTotalC != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleTotalC)
}
// 取消合并合计行的A、B列
// f.MergeCell(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow))
// 6. 保存
return f.SaveAs(outputPath)
}
// OfficialProductSumDeclineExcel

View File

@ -0,0 +1,334 @@
package bbxt
import (
"ai_scheduler/internal/pkg/utils_oss"
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/gofiber/fiber/v2/log"
"github.com/xuri/excelize/v2"
)
type Uploader struct {
ossClient *utils_oss.Client
}
const RequestUrl = "http://192.168.6.109:8010/api/v1/convert"
func NewUploader(oss *utils_oss.Client) *Uploader {
return &Uploader{
ossClient: oss,
}
}
func (u *Uploader) Run(report *ReportRes) (err error) {
if len(report.Path) == 0 {
return
}
f, err := excelize.OpenFile(report.Path)
if err != nil {
return err
}
defer f.Close()
excelBytes, err := f.WriteToBuffer()
if err != nil {
return fmt.Errorf("write to bytes failed: %v", err)
}
picBytes, err := u.excel2picPy(report.Path, excelBytes.Bytes(), 2)
if err != nil {
return fmt.Errorf("excel2picPy failed: %v", err)
}
// b.savePic("temp.png", picBytes) // 本地生成图片,仅测试
// outputPath 提取文件名(不包含扩展名)
filename := filepath.Base(report.Path)
filename = strings.TrimSuffix(filename, filepath.Ext(filename))
report.Url = u.uploadToOSS(filename, picBytes)
log.Infof("imgUrl: %s", report.Url)
return
}
// excel2picPy 将excel转换为图片python
// python 接口如下:
// curl --location --request POST 'http://192.168.6.109:8010/api/v1/convert' \
// --header 'Content-Type: multipart/form-data; boundary=--------------------------952147881043913664015069' \
// --form 'file=@"C:\\Users\\Administrator\\Downloads\\销售同比分析2025-12-29 0-12点.xlsx"' \
// --form 'sheet_name="销售同比分析"'
func (u *Uploader) excel2picPy(templatePath string, excelBytes []byte, scale int) ([]byte, error) {
// 1. 获取 Sheet Name
// 尝试从 excelBytes 解析,如果失败则使用默认值 "Sheet1"
sheetName := "Sheet1"
f, err := excelize.OpenReader(bytes.NewReader(excelBytes))
if err == nil {
sheetName = f.GetSheetName(0)
if sheetName == "" {
sheetName = "Sheet1"
}
f.Close()
}
// 2. 构造 Multipart 请求
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加文件字段
// 使用 templatePath 的文件名作为上传文件名,如果没有则用 default.xlsx
filename := "default.xlsx"
if templatePath != "" {
filename = filepath.Base(templatePath)
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, fmt.Errorf("create form file failed: %v", err)
}
if _, err = part.Write(excelBytes); err != nil {
return nil, fmt.Errorf("write file part failed: %v", err)
}
// 添加 sheet_name 字段
if err = writer.WriteField("sheet_name", sheetName); err != nil {
return nil, fmt.Errorf("write field sheet_name failed: %v", err)
}
// 添加 scale 字段
if scale <= 0 {
scale = 2
}
if err = writer.WriteField("scale", fmt.Sprintf("%d", scale)); err != nil {
return nil, fmt.Errorf("write field scale failed: %v", err)
}
if err = writer.Close(); err != nil {
return nil, fmt.Errorf("close writer failed: %v", err)
}
// 3. 发送 HTTP POST 请求
req, err := http.NewRequest("POST", RequestUrl, body)
if err != nil {
return nil, fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api request failed with status: %d, body: %s", resp.StatusCode, string(respBody))
}
// 4. 读取响应 Body (图片内容)
picBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body failed: %v", err)
}
return picBytes, nil
}
// savePic 保存图片到本地
func (u *Uploader) savePic(outputPath string, picBytes []byte) error {
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory failed: %v", err)
}
return os.WriteFile(outputPath, picBytes, 0644)
}
// uploadToOSS 上传至 oss 返回图片url
func (u *Uploader) uploadToOSS(fileName string, fileBytes []byte) string {
objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName)
url, err := u.ossClient.UploadBytes(objectKey, fileBytes)
if err != nil {
log.Errorf("oss upload failed: %v", err)
return ""
}
return url
}
//// uploadToOSS 上传至 oss 返回图片url
//func (r *ReportRes) To(fileName string, fileBytes []byte) string {
// objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName)
// url, err := u.ossClient.UploadBytes(objectKey, fileBytes)
// if err != nil {
// log.Errorf("oss upload failed: %v", err)
// return ""
// }
// return url
//}
func (b *BbxtTools) OfficialProductSumDeclineExcel(templatePath, outputPath string, sumMap map[int32]ProductSumDecline, title string) error {
// 1. 读取模板
f, err := excelize.OpenFile(templatePath)
if err != nil {
return err
}
defer f.Close()
sheet := f.GetSheetName(0)
if len(title) > 0 {
// 写入标题
f.SetCellValue(sheet, "A1", title)
}
// ---------------- 样式获取 ----------------
// 模板第2行数据行样式
tplRowData := 3
styleA2, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowData))
if err != nil {
styleA2 = 0
}
// B2和C2通常样式一致这里取B2作为明细列样式
styleB2, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowData))
if err != nil {
styleB2 = 0
}
styleC2, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowData))
if err != nil {
styleC2 = 0
}
styleD2, err := f.GetCellStyle(sheet, fmt.Sprintf("D%d", tplRowData))
if err != nil {
styleC2 = 0
}
//styleE2, err := f.GetCellStyle(sheet, fmt.Sprintf("E%d", tplRowData))
//if err != nil {
// styleC2 = 0
//}
styleF2, err := f.GetCellStyle(sheet, fmt.Sprintf("F%d", tplRowData))
if err != nil {
styleC2 = 0
}
//styleG2, err := f.GetCellStyle(sheet, fmt.Sprintf("G%d", tplRowData))
//if err != nil {
// styleC2 = 0
//}
rowHeightData, err := f.GetRowHeight(sheet, tplRowData)
if err != nil {
rowHeightData = 20
}
currentRow := 3
pattern := `\$\{(.*?)\}`
re := regexp.MustCompile(pattern)
for _, product := range sumMap {
// 排序 ProductLoss
var reseller []ProductSumReseller
for _, p := range product.ProductSumReseller {
reseller = append(reseller, p)
}
sort.Slice(reseller, func(i, j int) bool {
return reseller[i].HistoryOneDiff < reseller[j].HistoryOneDiff
})
startRow := currentRow
// 填充该经销商的所有产品
for _, p := range reseller {
// 设置行高
var (
oneDiff string
twoDiff string
)
f.SetRowHeight(sheet, currentRow, rowHeightData)
if p.HistoryOneDiff >= 0 {
oneDiff = fmt.Sprintf("%s↑%d", RedStyle, p.HistoryOneDiff)
} else {
oneDiff = fmt.Sprintf("%s↓%d", GreenStyle, p.HistoryOneDiff)
}
if p.HistoryTwoDiff >= 0 {
twoDiff = fmt.Sprintf("%s↑%d", RedStyle, p.HistoryTwoDiff)
} else {
twoDiff = fmt.Sprintf("%s↓%d", GreenStyle, p.HistoryTwoDiff)
}
matchesE := re.FindStringSubmatch(oneDiff)
styleMap := make(map[string]string)
if len(matchesE) != 2 {
continue
}
for _, kv := range strings.Split(matchesE[1], ";") {
kvParts := strings.Split(kv, ":")
if len(kvParts) == 2 {
styleMap[strings.TrimSpace(kvParts[0])] = strings.TrimSpace(kvParts[1])
}
}
fontStyleIDE, _err := SetStyle(styleMap, f)
if _err != nil {
log.Errorf("set style failed: %v", _err)
}
f.SetCellStyle(sheet, fmt.Sprintf("E%d", currentRow), fmt.Sprintf("E%d", currentRow), fontStyleIDE)
oneDiffValue := re.ReplaceAllString(oneDiff, "")
matchesG := re.FindStringSubmatch(twoDiff)
styleMapG := make(map[string]string)
if len(matchesG) != 2 {
continue
}
for _, kv := range strings.Split(matchesG[1], ";") {
kvParts := strings.Split(kv, ":")
if len(kvParts) == 2 {
styleMapG[strings.TrimSpace(kvParts[0])] = strings.TrimSpace(kvParts[1])
}
}
fontStyleIDG, _err := SetStyle(styleMapG, f)
if _err != nil {
log.Errorf("set style failed: %v", _err)
}
twoDiffValue := re.ReplaceAllString(twoDiff, "")
f.SetCellStyle(sheet, fmt.Sprintf("G%d", currentRow), fmt.Sprintf("G%d", currentRow), fontStyleIDG)
// 设置值
f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), product.OfficialProductName)
f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.ResellerName)
f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), p.CurrentNum)
f.SetCellValue(sheet, fmt.Sprintf("D%d", currentRow), p.HistoryOneNum)
f.SetCellValue(sheet, fmt.Sprintf("E%d", currentRow), oneDiffValue)
f.SetCellValue(sheet, fmt.Sprintf("F%d", currentRow), p.HistoryTwoNum)
f.SetCellValue(sheet, fmt.Sprintf("G%d", currentRow), twoDiffValue)
// 设置样式
if styleA2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleA2)
}
if styleB2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleB2)
}
if styleC2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleC2)
}
if styleD2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("D%d", currentRow), fmt.Sprintf("D%d", currentRow), styleC2)
}
if styleF2 != 0 {
f.SetCellStyle(sheet, fmt.Sprintf("F%d", currentRow), fmt.Sprintf("F%d", currentRow), styleC2)
}
currentRow++
}
endRow := currentRow - 1
// 合并单元格 (如果多于1行)
if endRow > startRow {
f.MergeCell(sheet, fmt.Sprintf("A%d", startRow), fmt.Sprintf("A%d", endRow))
}
}
// 6. 保存
return f.SaveAs(outputPath)
}

View File

@ -0,0 +1,19 @@
package zltx
import (
config2 "ai_scheduler/internal/config"
"ai_scheduler/internal/entitys"
"context"
"testing"
)
func Test_task(t *testing.T) {
c := NewZltxOrderDetailTool(config2.ToolConfig{}, nil)
err :=
c.Execute(context.Background(), &entitys.Recognize{
Match: &entitys.Match{
Parameters: `{"order_number": 859393216068067329}`,
},
})
t.Log(err)
}

View File

@ -11,6 +11,7 @@ import (
"fmt"
"gitea.cdlsxd.cn/self-tools/l_request"
"github.com/gofiber/fiber/v2/log"
"github.com/ollama/ollama/api"
)
@ -58,7 +59,7 @@ func (w *ZltxOrderDetailTool) Definition() entitys.ToolDefinition {
// ZltxOrderDetailRequest 直连天下订单详情请求参数
type ZltxOrderDetailRequest struct {
OrderNumber string `json:"order_number"`
OrderNumber interface{} `json:"order_number"`
}
// ZltxOrderDetailResponse 直连天下订单详情响应
@ -96,14 +97,27 @@ func (w *ZltxOrderDetailTool) Execute(ctx context.Context, rec *entitys.Recogniz
}
// getMockZltxOrderDetail 获取模拟直连天下订单详情数据
func (w *ZltxOrderDetailTool) getZltxOrderDetail(rec *entitys.Recognize, number string) (err error) {
func (w *ZltxOrderDetailTool) getZltxOrderDetail(rec *entitys.Recognize, number interface{}) (err error) {
log.Infof("订单编号:%v,类型:%v")
var orderNum string
switch number.(type) {
case int, int32, int64:
orderNum = fmt.Sprintf("%d", number)
case float64:
orderNum = fmt.Sprintf("%d", int(number.(float64)))
case string:
orderNum = number.(string)
default:
orderNum = fmt.Sprintf("%v", number)
}
ext, err := rec_extra.GetTaskRecExt(rec)
if err != nil {
return
}
//查询订单详情
req := l_request.Request{
Url: fmt.Sprintf(w.config.BaseURL, number),
Url: fmt.Sprintf(w.config.BaseURL, orderNum),
Headers: map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", ext.Auth),
},

View File

@ -45,7 +45,7 @@ func (z ZltxOrderStatisticsTool) Definition() entitys.ToolDefinition {
}
type ZltxOrderStatisticsRequest struct {
Number string `json:"number"`
Number interface{} `json:"number"`
}
func (z ZltxOrderStatisticsTool) Execute(ctx context.Context, rec *entitys.Recognize) error {
@ -53,7 +53,7 @@ func (z ZltxOrderStatisticsTool) Execute(ctx context.Context, rec *entitys.Recog
if err := json.Unmarshal([]byte(rec.Match.Parameters), &req); err != nil {
return err
}
if req.Number == "" {
if req.Number == nil {
return fmt.Errorf("number is required")
}
return z.getZltxOrderStatistics(req.Number, rec)
@ -76,14 +76,13 @@ type ZltxOrderStatisticsData struct {
Total int `json:"total"`
}
func (z ZltxOrderStatisticsTool) getZltxOrderStatistics(number string, rec *entitys.Recognize) error {
func (z ZltxOrderStatisticsTool) getZltxOrderStatistics(number interface{}, rec *entitys.Recognize) error {
ext, err := rec_extra.GetTaskRecExt(rec)
if err != nil {
return err
}
//查询订单详情
url := fmt.Sprintf("%s%s", z.config.BaseURL, number)
url := fmt.Sprintf("%s%s", z.config.BaseURL, fmt.Sprintf("%v", number))
req := l_request.Request{
Url: url,
Headers: map[string]string{

73
pkg/func.go Normal file
View File

@ -0,0 +1,73 @@
package pkg
import (
"fmt"
"os"
"path/filepath"
)
func GetModuleDir() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
modPath := filepath.Join(dir, "go.mod")
if _, err := os.Stat(modPath); err == nil {
return dir, nil // 找到 go.mod
}
// 向上查找父目录
parent := filepath.Dir(dir)
if parent == dir {
break // 到达根目录,未找到
}
dir = parent
}
return "", fmt.Errorf("go.mod not found in current directory or parents")
}
// GetCacheDir 用于获取缓存目录路径
// 如果缓存目录不存在,则会自动创建
// 返回值:
// - string: 缓存目录的路径
// - error: 如果获取模块目录失败或创建缓存目录失败,则返回错误信息
func GetCacheDir() (string, error) {
// 获取模块目录
modDir, err := GetModuleDir()
if err != nil {
return "", err
}
// 拼接缓存目录路径
path := fmt.Sprintf("%s/cache", modDir)
// 创建目录包括所有必要的父目录权限设置为0755
err = os.MkdirAll(path, 0755)
if err != nil {
return "", fmt.Errorf("创建目录失败: %w", err)
}
// 返回成功创建的缓存目录路径
return path, nil
}
func GetTmplDir() (string, error) {
modDir, err := GetModuleDir()
if err != nil {
return "", err
}
path := fmt.Sprintf("%s/tmpl", modDir)
err = os.MkdirAll(path, 0755)
if err != nil {
return "", fmt.Errorf("创建目录失败: %w", err)
}
return path, nil
}
func ReverseSliceNew[T any](s []T) []T {
result := make([]T, len(s))
for i := 0; i < len(s); i++ {
result[i] = s[len(s)-1-i]
}
return result
}

View File

@ -189,3 +189,9 @@ func (k DataTemp) UpdateByCond(cond *builder.Cond, data interface{}) (err error)
err = model.Where(query).Updates(data).Error
return
}
func (k DataTemp) UpdateById(id int32, data interface{}) (err error) {
err = k.Db.Model(k.Model).Where("id = ?", id).Updates(data).Error
return
}

Binary file not shown.

BIN
tmpl/excel_temp/kshj_total.xlsx Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tmpl/excel_temp/xlxhmx.xlsx Normal file

Binary file not shown.

Binary file not shown.