diff --git a/.gitignore b/.gitignore index ea6e235..c953a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ docs cmd/server/wire_gen.go __debug* -.bin/ \ No newline at end of file +.bin/ +.idea/ +cache/ \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index d765735..baca30d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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))) } diff --git a/config/config_env.yaml b/config/config_env.yaml index 9757b80..83bbfd6 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -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: diff --git a/config/config_test.yaml b/config/config_test.yaml index 7ad9e71..63b4b66 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -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: diff --git a/go.mod b/go.mod index 76bed20..374a7f3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a6e9a9c..f101537 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index b8976df..0cc4090 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -470,6 +470,24 @@ 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) (contentChan chan string, err error) { + contentChan = make(chan string, 10) + defer close(contentChan) + contentChan <- "截止今日23点利润亏损合计:127917.0866元,亏损500元以上的分销商和产品金额如下图:" + contentChan <- "![图片](https://lsxdmgoss.oss-cn-chengdu.aliyuncs.com/MarketingSaaS/image/V2/other/shanghu.png)" + + 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 } diff --git a/internal/biz/do/prompt.go b/internal/biz/do/prompt.go index cada763..a27d698 100644 --- a/internal/biz/do/prompt.go +++ b/internal/biz/do/prompt.go @@ -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) } diff --git a/internal/biz/handle/dingtalk/send_card.go.bak1 b/internal/biz/handle/dingtalk/send_card.go.bak1 deleted file mode 100644 index 9fb1e8d..0000000 --- a/internal/biz/handle/dingtalk/send_card.go.bak1 +++ /dev/null @@ -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 -} diff --git a/internal/config/config.go b/internal/config/config.go index 713663f..83115a3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "ai_scheduler/pkg" "fmt" "time" @@ -19,6 +20,7 @@ type Config struct { 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"` @@ -136,6 +138,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"` @@ -230,10 +241,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 + } diff --git a/internal/pkg/channel_pool.go b/internal/pkg/channel_pool.go deleted file mode 100644 index eda85fa..0000000 --- a/internal/pkg/channel_pool.go +++ /dev/null @@ -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(此处简化) -} diff --git a/internal/pkg/func.go b/internal/pkg/func.go index 32c404b..4d9232b 100644 --- a/internal/pkg/func.go +++ b/internal/pkg/func.go @@ -6,8 +6,12 @@ import ( "errors" "fmt" "net/url" + "reflect" "strconv" "strings" + "time" + + jsoniter "github.com/json-iterator/go" ) func JsonStringIgonErr(data interface{}) string { @@ -165,3 +169,132 @@ func SafeReplace(template string, replaceTag string, replacements ...string) (st return template, nil } + +func StructToMapUsingJsoniter(obj interface{}) (map[string]string, error) { + var json = jsoniter.ConfigCompatibleWithStandardLibrary + + // 转换为JSON + jsonBytes, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + // 解析为map[string]interface{} + var tempMap map[string]interface{} + err = json.Unmarshal(jsonBytes, &tempMap) + if err != nil { + return nil, err + } + + // 转换为map[string]string + result := make(map[string]string) + for k, v := range tempMap { + result[k] = fmt.Sprintf("%v", v) + } + + return result, nil +} + +// 通用结构体转 Query 参数 +func StructToQuery(obj interface{}) (url.Values, error) { + values := url.Values{} + v := reflect.ValueOf(obj) + t := reflect.TypeOf(obj) + + // 如果是指针,获取指向的值 + if v.Kind() == reflect.Ptr { + v = v.Elem() + t = t.Elem() + } + + // 确保是结构体 + if v.Kind() != reflect.Struct { + return values, fmt.Errorf("expected struct, got %v", v.Kind()) + } + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + + // 跳过零值字段(omitempty) + tag := fieldType.Tag.Get("json") + if strings.Contains(tag, "omitempty") && field.IsZero() { + continue + } + + // 获取字段名 + fieldName := getFieldName(fieldType) + if fieldName == "" { + continue + } + + // 处理不同类型的字段 + addFieldToValues(values, fieldName, field) + } + + return values, nil +} + +func getFieldName(field reflect.StructField) string { + tag := field.Tag.Get("json") + if tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "-" && parts[0] != "" { + return parts[0] + } + if parts[0] == "-" { + return "" // 跳过该字段 + } + } + return field.Name +} + +func addFieldToValues(values url.Values, name string, field reflect.Value) { + if !field.IsValid() || field.IsZero() { + return + } + + switch field.Kind() { + case reflect.String: + values.Add(name, field.String()) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + values.Add(name, strconv.FormatInt(field.Int(), 10)) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + values.Add(name, strconv.FormatUint(field.Uint(), 10)) + + case reflect.Float32, reflect.Float64: + values.Add(name, strconv.FormatFloat(field.Float(), 'f', -1, 64)) + + case reflect.Bool: + values.Add(name, strconv.FormatBool(field.Bool())) + + case reflect.Slice: + // 处理切片,特别是 []string + if field.Type().Elem().Kind() == reflect.String { + for i := 0; i < field.Len(); i++ { + item := field.Index(i).String() + // 特殊处理 ct 字段 + if name == "ct" { + formatted := strings.Replace(item, " ", "+", 1) + if i == 1 && field.Len() >= 2 { + formatted = formatted + ".999" + } + values.Add("ct[]", formatted) + } else { + values.Add(fmt.Sprintf("%s[]", name), item) + } + } + } + + case reflect.Struct: + // 处理 time.Time + if t, ok := field.Interface().(time.Time); ok { + values.Add(name, t.Format("2006-01-02+15:04:05")) + } + + default: + values.Add(name, fmt.Sprintf("%v", field.Interface())) + } +} diff --git a/internal/pkg/oss/client.go b/internal/pkg/oss/client.go new file mode 100644 index 0000000..225e8d9 --- /dev/null +++ b/internal/pkg/oss/client.go @@ -0,0 +1,57 @@ +package 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.Oss) (*Client, error) { + client, err := oss.New(cfg.Endpoint, cfg.AccessKey, cfg.SecretKey) + if err != nil { + return nil, fmt.Errorf("oss new client failed: %v", err) + } + + bucket, err := client.Bucket(cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("oss get bucket failed: %v", err) + } + + return &Client{ + config: cfg, + 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 +} diff --git a/internal/pkg/provider_set.go b/internal/pkg/provider_set.go index f8fadac..1e8bdb4 100644 --- a/internal/pkg/provider_set.go +++ b/internal/pkg/provider_set.go @@ -2,6 +2,7 @@ package pkg import ( "ai_scheduler/internal/pkg/dingtalk" + "ai_scheduler/internal/pkg/oss" "ai_scheduler/internal/pkg/utils_langchain" "ai_scheduler/internal/pkg/utils_ollama" "ai_scheduler/internal/pkg/utils_vllm" @@ -15,8 +16,10 @@ var ProviderSetClient = wire.NewSet( utils_langchain.NewUtilLangChain, utils_ollama.NewClient, utils_vllm.NewClient, - NewSafeChannelPool, + dingtalk.NewOldClient, dingtalk.NewContactClient, dingtalk.NewNotableClient, + + oss.NewClient, ) diff --git a/internal/server/cron.go b/internal/server/cron.go new file mode 100644 index 0000000..76c5739 --- /dev/null +++ b/internal/server/cron.go @@ -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: "@every 60s", + }, + } +} + +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调度器已停止") + } +} diff --git a/internal/server/provider_set.go b/internal/server/provider_set.go index d5cef3d..08bc37b 100644 --- a/internal/server/provider_set.go +++ b/internal/server/provider_set.go @@ -9,4 +9,5 @@ var ProviderSetServer = wire.NewSet( NewHTTPServer, ProvideAllDingBotServices, NewDingTalkBotServer, + NewCronServer, ) diff --git a/internal/server/server.go b/internal/server/server.go index 02c8f84..b455eb4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 -//} diff --git a/internal/services/cron.go b/internal/services/cron.go new file mode 100644 index 0000000..5f624d1 --- /dev/null +++ b/internal/services/cron.go @@ -0,0 +1,43 @@ +package services + +import ( + "ai_scheduler/internal/biz" + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" + "context" + + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" +) + +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 { + reportChan, err := d.dingTalkBotBiz.GetReportLists(ctx) + if err != nil { + return err + } + groupId := 23 + groupInfo, err := d.dingTalkBotBiz.GetGroupInfo(ctx, groupId) + if err != nil { + return err + } + err = d.dingTalkBotBiz.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{ + RobotCode: groupInfo.RobotCode, + ConversationType: constants.ConversationTypeGroup, + ConversationId: groupInfo.ConversationID, + Text: chatbot.BotCallbackDataTextModel{ + Content: "报表", + }, + }, reportChan) + return nil +} diff --git a/internal/services/dtalk_bot.go b/internal/services/dtalk_bot.go index b71e40b..4635b63 100644 --- a/internal/services/dtalk_bot.go +++ b/internal/services/dtalk_bot.go @@ -3,6 +3,7 @@ package services import ( "ai_scheduler/internal/biz" "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" "ai_scheduler/internal/entitys" "context" "log" @@ -135,3 +136,24 @@ func (d *DingBotService) runBackgroundTasks(ctx context.Context, data *chatbot.B return nil } + +func (d *DingBotService) CronReportSend(ctx context.Context) error { + reportChan, err := d.dingTalkBotBiz.GetReportLists(ctx) + if err != nil { + return err + } + groupId := 23 + groupInfo, err := d.dingTalkBotBiz.GetGroupInfo(ctx, groupId) + if err != nil { + return err + } + err = d.dingTalkBotBiz.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{ + RobotCode: groupInfo.RobotCode, + ConversationType: constants.ConversationTypeGroup, + ConversationId: groupInfo.ConversationID, + Text: chatbot.BotCallbackDataTextModel{ + Content: "报表", + }, + }, reportChan) + return nil +} diff --git a/internal/services/dtalk_bot.go.bak b/internal/services/dtalk_bot.go.bak deleted file mode 100644 index 75c2c7f..0000000 --- a/internal/services/dtalk_bot.go.bak +++ /dev/null @@ -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 -} diff --git a/internal/services/dtalk_bot_test.go b/internal/services/dtalk_bot_test.go new file mode 100644 index 0000000..2b8223f --- /dev/null +++ b/internal/services/dtalk_bot_test.go @@ -0,0 +1,105 @@ +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/pkg" + "ai_scheduler/internal/pkg/dingtalk" + "ai_scheduler/internal/pkg/utils_ollama" + "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 := dingBotService.CronReportSend(context.Background()) + t.Log(a) +} + +var ( + configConfig *config.Config + err error + dingBotService *DingBotService +) + +// 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, rdb) + // 初始化包级别的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) + // 初始化钉钉机器人业务逻辑 + dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, toolRegis, botChatHisImpl, manager, configConfig, sendCardClient) + // 初始化钉钉机器人服务 + dingBotService = NewDingBotService(configConfig, dingTalkBotBiz) +} diff --git a/internal/services/provider_set.go b/internal/services/provider_set.go index 375a886..55eed7a 100644 --- a/internal/services/provider_set.go +++ b/internal/services/provider_set.go @@ -14,4 +14,5 @@ var ProviderSetServices = wire.NewSet( NewDingBotService, NewHistoryService, NewCapabilityService, + NewCronService, ) diff --git a/internal/tools/bbxt/api.go b/internal/tools/bbxt/api.go new file mode 100644 index 0000000..b07ce1e --- /dev/null +++ b/internal/tools/bbxt/api.go @@ -0,0 +1,207 @@ +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 `protobuf:"bytes,1,rep,name=ct,proto3" json:"ct,omitempty"` + Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"` + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + ResellerIds []int32 `protobuf:"varint,5,rep,packed,name=reseller_ids,json=resellerIds,proto3" 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=official_product_sum,json=officialProductSum,proto3" json:"official_product_sum,omitempty"` + DataCount int32 `protobuf:"varint,2,opt,name=data_count,json=dataCount,proto3" json:"data_count,omitempty"` +} + +type GetStatisOfficialProductSum struct { + OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"official_product_id,omitempty"` + OfficialProductName string `protobuf:"bytes,2,opt,name=official_product_name,json=officialProductName,proto3" json:"official_product_name,omitempty"` + CurrentNum int32 `protobuf:"varint,3,opt,name=current_num,json=currentNum,proto3" json:"current_num,omitempty"` + HistoryOneNum int32 `protobuf:"varint,4,opt,name=history_one_num,json=historyOneNum,proto3" json:"history_one_num,omitempty"` + HistoryTwoNum int32 `protobuf:"varint,5,opt,name=history_two_num,json=historyTwoNum,proto3" json:"history_two_num,omitempty"` + HistoryOneDiff int32 `protobuf:"varint,6,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"history_one_diff,omitempty"` + HistoryTwoDiff int32 `protobuf:"varint,7,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"history_two_diff,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:"official_product_sum_decline,omitempty"` + DataCount int32 `protobuf:"varint,2,opt,name=data_count,json=dataCount,proto3" json:"data_count,omitempty"` +} + +type GetStatisOfficialProductSumDecline struct { + ResellerId int32 `protobuf:"varint,1,opt,name=reseller_id,json=resellerId,proto3" json:"reseller_id,omitempty"` + OfficialProductId int32 `protobuf:"varint,2,opt,name=official_product_id,json=officialProductId,proto3" json:"official_product_id,omitempty"` + OfficialProductName string `protobuf:"bytes,3,opt,name=official_product_name,json=officialProductName,proto3" json:"official_product_name,omitempty"` + ResellerName string `protobuf:"bytes,4,opt,name=reseller_name,json=resellerName,proto3" json:"reseller_name,omitempty"` + CurrentNum int32 `protobuf:"varint,5,opt,name=current_num,json=currentNum,proto3" json:"current_num,omitempty"` + HistoryOneNum int32 `protobuf:"varint,6,opt,name=history_one_num,json=historyOneNum,proto3" json:"history_one_num,omitempty"` + HistoryTwoNum int32 `protobuf:"varint,7,opt,name=history_two_num,json=historyTwoNum,proto3" json:"history_two_num,omitempty"` + HistoryOneDiff int32 `protobuf:"varint,8,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"history_one_diff,omitempty"` + HistoryTwoDiff int32 `protobuf:"varint,9,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"history_two_diff,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.StructToQuery(reqData) + if err != nil { + return err + } + + req := &l_request.Request{ + Url: Base + url + "?" + customEncode(reqParam), + Method: http.MethodGet, + } + res, err := req.Send() + 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 +} + +func customEncode(params url.Values) string { + encoded := params.Encode() + + // 解码我们想要保留的字符 + encoded = strings.ReplaceAll(encoded, "%5B", "[") // 恢复 [ + encoded = strings.ReplaceAll(encoded, "%5D", "]") // 恢复 ] + encoded = strings.ReplaceAll(encoded, "%2B", "+") // 恢复 + + + return encoded +} diff --git a/internal/tools/bbxt/bbxt.go b/internal/tools/bbxt/bbxt.go new file mode 100644 index 0000000..a5c2aa3 --- /dev/null +++ b/internal/tools/bbxt/bbxt.go @@ -0,0 +1,410 @@ +package bbxt + +import ( + "ai_scheduler/internal/pkg/oss" + "ai_scheduler/pkg" + "fmt" + "reflect" + "regexp" + "math/rand" + "sort" + "strings" + "time" + + "github.com/go-kratos/kratos/v2/log" + "github.com/xuri/excelize/v2" +) + +type BbxtTools struct { + cacheDir string + excelTempDir string + ossClient *oss.Client +} + +func NewBbxtTools(ossClient *oss.Client) (*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), + ossClient: ossClient, + }, nil +} + +func (b *BbxtTools) DailyReport(now time.Time) (err error) { + + err = b.StatisOursProductLossSum([]string{ + time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"), + time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format("2006-01-02 15:04:05"), + }) + if err != nil { + return + } + return +} + +// StatisOursProductLossSumTotal 负利润分析 +func (b *BbxtTools) StatisOursProductLossSum(ct []string) (err error) { + 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 + }) + + // 构建分组 + for _, v := range resellers { + if v.Total <= -100 { + total = append(total, []string{ + fmt.Sprintf("%s", v.ResellerName), + fmt.Sprintf("%.2f", v.Total), + }) + } + if v.Total <= -500 { + gt = append(gt, v) + } + } + //总量生成excel + if len(total) > 0 { + filePath := b.cacheDir + "/kshj_total" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx" + err = b.SimpleFillExcel(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total) + } + + if len(gt) > 0 { + filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx" + // err = b.resellerDetailFillExcel(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) + err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) + } + return err +} + +// GetProfitRankingSum 利润同比分销商排行榜 +func (b *BbxtTools) GetProfitRankingSum(now time.Time) (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"), + now.Format(time.DateTime), + } + + 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("${color: FF0000;horizontal:center;vertical:center}↑%.4f", v.HistoryOneDiff) + } else { + diff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%.4f", 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 { + filePath := b.cacheDir + "/lrtb_rank" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" + err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"lrtb_rank.xlsx", filePath, total, title) + } + return err +} + +// GetStatisOfficialProductSum 利润同比分销商排行榜 +func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []string) (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"), + now.Format(time.DateTime), + } + 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 [][]string + for _, v := range data.OfficialProductSum { + var ( + yeterDatyDiff string + lastWeekDiff string + ) + if v.HistoryOneDiff > 0 { + yeterDatyDiff = fmt.Sprintf("${color: FF0000;horizontal:center;vertical:center}↑%d", v.HistoryOneDiff) + } else { + yeterDatyDiff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%d", v.HistoryOneDiff) + } + if v.HistoryTwoDiff > 0 { + lastWeekDiff = fmt.Sprintf("${color: FF0000;horizontal:center;vertical:center}↑%d", v.HistoryTwoDiff) + } else { + lastWeekDiff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%d", v.HistoryTwoDiff) + } + total = append(total, []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 { + filePath := b.cacheDir + "/xstb_ana" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" + err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"xstb_ana.xlsx", filePath, total, title) + } + return 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 (b *BbxtTools) SimpleFillExcelWithTitle(templatePath, outputPath string, dataSlice interface{}, title string) error { + // 1. 打开模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 1.1 获取第三行模板样式 + templateRow := 3 + styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", templateRow)) + if err != nil { + log.Errorf("获取模板样式失败: %v", err) + styleID = 0 + } + + // 1.2 获取模板行高 + rowHeight, err := f.GetRowHeight(sheet, templateRow) + if err != nil { + log.Errorf("获取模板行高失败: %v", err) + rowHeight = 31 // 默认高度 + } + + // 2. 写入标题到第一行 + f.SetCellValue(sheet, "A1", title) + + // 3. 反射获取切片数据 + v := reflect.ValueOf(dataSlice) + if v.Kind() != reflect.Slice { + return fmt.Errorf("dataSlice must be a slice") + } + + if v.Len() == 0 { + return nil + } + + // 4. 从第三行开始填充数据(第二行留空或作为标题行) + startRow := 3 + 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) + + // 5. 填充数据到Excel + for col, value := range rowData { + cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) + // 5.1 应用模板样式到整行(根据实际列数) + 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) + } + 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: + + } + } + + } + + // 6. 保存 + 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 + } + + return f.NewStyle(style) +} diff --git a/internal/tools/bbxt/bbxt_test.go b/internal/tools/bbxt/bbxt_test.go new file mode 100644 index 0000000..e3cfb73 --- /dev/null +++ b/internal/tools/bbxt/bbxt_test.go @@ -0,0 +1,52 @@ +package bbxt + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/oss" + "testing" + "time" +) + +func Test_StatisOursProductLossSumApiTotal(t *testing.T) { + ossClient, err := oss.NewClient(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", + }) + if err != nil { + panic(err) + } + + o, err := NewBbxtTools(ossClient) + if err != nil { + panic(err) + } + err = o.DailyReport(time.Date(2025, 12, 30, 0, 0, 0, 0, time.Local)) + + t.Log(err) + +} + +func Test_GetProfitRankingSum(t *testing.T) { + o, err := NewBbxtTools() + if err != nil { + panic(err) + } + err = o.GetProfitRankingSum(time.Now()) + + t.Log(err) + +} + +func Test_GetStatisOfficialProductSum(t *testing.T) { + o, err := NewBbxtTools() + if err != nil { + panic(err) + } + err = o.GetStatisOfficialProductSum(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}) + + t.Log(err) + +} diff --git a/internal/tools/bbxt/entitys.go b/internal/tools/bbxt/entitys.go new file mode 100644 index 0000000..a3dc743 --- /dev/null +++ b/internal/tools/bbxt/entitys.go @@ -0,0 +1,14 @@ +package bbxt + +type ResellerLoss struct { + ResellerId int32 + ResellerName string + Total float64 + ProductLoss map[int32]ProductLoss +} + +type ProductLoss struct { + ProductId int32 + ProductName string + Loss float64 +} diff --git a/internal/tools/bbxt/excel.go b/internal/tools/bbxt/excel.go new file mode 100644 index 0000000..f5b52bf --- /dev/null +++ b/internal/tools/bbxt/excel.go @@ -0,0 +1,444 @@ +package bbxt + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/go-kratos/kratos/v2/log" + "github.com/shopspring/decimal" + "github.com/xuri/excelize/v2" +) + +// 最简单的通用函数 +func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice interface{}) error { + // 1. 打开模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 1.1 获取第二行模板样式 + resellerTplRow := 2 + styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) + if err != nil { + log.Errorf("获取分销商总计样式失败: %v", err) + styleIDReseller = 0 + } + // 1.2 获取分销商总计行高 + rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) + if err != nil { + log.Errorf("获取分销商总计行高失败: %v", err) + rowHeightReseller = 31 // 默认高度 + } + + // 2. 反射获取切片数据 + v := reflect.ValueOf(dataSlice) + if v.Kind() != reflect.Slice { + return fmt.Errorf("dataSlice must be a slice") + } + + // 3. 从第2行开始填充 + row := 2 + for i := 0; i < v.Len(); i++ { + item := v.Index(i).Interface() + currentRow := row + i + + // 4. 将item转换为一行数据 + var rowData []interface{} + + // 如果是切片 + if reflect.TypeOf(item).Kind() == reflect.Slice { + itemV := reflect.ValueOf(item) + for j := 0; j < itemV.Len(); j++ { + rowData = append(rowData, itemV.Index(j).Interface()) + } + } else if reflect.TypeOf(item).Kind() == reflect.Struct { + itemV := reflect.ValueOf(item) + for j := 0; j < itemV.NumField(); j++ { + if itemV.Field(j).CanInterface() { + rowData = append(rowData, itemV.Field(j).Interface()) + } + } + } else { + rowData = []interface{}{item} + } + // 4.1 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightReseller) + + // 5. 填充到Excel + for col, value := range rowData { + cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) + f.SetCellValue(sheet, cell, value) + } + + // 5.1 使用第二行模板样式 + if styleIDReseller != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) + } + } + + excelBytes, err := f.WriteToBuffer() + if err != nil { + return fmt.Errorf("write to bytes failed: %v", err) + } + + picBytes, err := b.excel2picPy(templatePath, excelBytes.Bytes()) + if err != nil { + return fmt.Errorf("excel2picPy failed: %v", err) + } + // b.savePic("temp.png", picBytes) // 本地生成图片,仅测试 + // outputPath 提取文件名(不包含扩展名) + filename := filepath.Base(outputPath) + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + imgUrl := b.uploadToOSS(filename, picBytes) + log.Infof("imgUrl: %s", imgUrl) + + // 6. 保存 + return f.SaveAs(outputPath) +} + +// 分销商负利润详情填充excel +// 1.使用模板文件作为输出文件 +// 2.分销商总计使用第二行样式(宽高、背景、颜色等) +// 3.商品详情使用第三行样式(宽高、背景、颜色等) +// 4.保存为新文件 +func (b *BbxtTools) resellerDetailFillExcel(templatePath, outputPath string, dataSlice []*ResellerLoss) error { + // 1. 读取模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 获取模板样式1:第二行-分销商总计 + resellerTplRow := 2 + styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) + if err != nil { + log.Errorf("获取分销商总计样式失败: %v", err) + styleIDReseller = 0 + } + rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) + if err != nil { + log.Errorf("获取分销商总计行高失败: %v", err) + rowHeightReseller = 31 // 默认高度 + } + // 获取模板样式2:第三行-产品亏损明细 + productTplRow := 3 + styleIDProduct, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", productTplRow)) + if err != nil { + log.Errorf("获取商品详情样式失败: %v", err) + styleIDProduct = 0 + } + rowHeightProduct, err := f.GetRowHeight(sheet, productTplRow) + if err != nil { + log.Errorf("获取商品详情行高失败: %v", err) + rowHeightProduct = 25 // 默认高度 + } + + currentRow := 2 + + for _, reseller := range dataSlice { + // 3. 填充经销商数据 (ResellerName, Total) + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightReseller) + + // 设置单元格值 + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName) + f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), reseller.Total) + + // 应用样式 + if styleIDReseller != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) + } + + currentRow++ + + // 4. 填充产品亏损明细 + // 先对 ProductLoss 进行排序 + var products []ProductLoss + for _, p := range reseller.ProductLoss { + products = append(products, p) + } + // 按 Loss 升序排序 (亏损越多越靠前,负数越小) + sort.Slice(products, func(i, j int) bool { + return products[i].Loss < products[j].Loss + }) + + for _, p := range products { + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightProduct) + + // 设置单元格值 + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("·%s", p.ProductName)) + f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.Loss) + + // 应用样式 + if styleIDProduct != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDProduct) + } + + currentRow++ + } + } + + // 6. 保存 + return f.SaveAs(outputPath) +} + +// 分销商负利润详情填充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) error { + // 1. 读取模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // ---------------- 样式获取 ---------------- + // 模板第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)) + + excelBytes, err := f.WriteToBuffer() + if err != nil { + return fmt.Errorf("write to bytes failed: %v", err) + } + + picBytes, err := b.excel2picPy(templatePath, excelBytes.Bytes()) + if err != nil { + return fmt.Errorf("excel2picPy failed: %v", err) + } + // b.savePic("temp.png", picBytes) // 本地生成图片,仅测试 + // outputPath 提取文件名(不包含扩展名) + filename := filepath.Base(outputPath) + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + imgUrl := b.uploadToOSS(filename, picBytes) + log.Infof("imgUrl: %s", imgUrl) + + // 6. 保存 + return f.SaveAs(outputPath) +} + +// 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 (b *BbxtTools) excel2picPy(templatePath string, excelBytes []byte) ([]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) + } + + if err = writer.Close(); err != nil { + return nil, fmt.Errorf("close writer failed: %v", err) + } + + // 3. 发送 HTTP POST 请求 + url := "http://192.168.6.109:8010/api/v1/convert" + req, err := http.NewRequest("POST", url, 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 (b *BbxtTools) 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 (b *BbxtTools) uploadToOSS(fileName string, fileBytes []byte) string { + objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName) + url, err := b.ossClient.UploadBytes(objectKey, fileBytes) + if err != nil { + log.Errorf("oss upload failed: %v", err) + return "" + } + return url +} diff --git a/internal/tools/zltx/excute_test.go b/internal/tools/zltx/excute_test.go new file mode 100644 index 0000000..3f8460d --- /dev/null +++ b/internal/tools/zltx/excute_test.go @@ -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) +} diff --git a/internal/tools/zltx/zltx_order_detail.go b/internal/tools/zltx/zltx_order_detail.go index 235d40c..47574a3 100644 --- a/internal/tools/zltx/zltx_order_detail.go +++ b/internal/tools/zltx/zltx_order_detail.go @@ -98,11 +98,17 @@ func (w *ZltxOrderDetailTool) Execute(ctx context.Context, rec *entitys.Recogniz // getMockZltxOrderDetail 获取模拟直连天下订单详情数据 func (w *ZltxOrderDetailTool) getZltxOrderDetail(rec *entitys.Recognize, number interface{}) (err error) { - log.Infof("订单编号:%v", number) + log.Infof("订单编号:%v,类型:%v") + var orderNum string switch number.(type) { case int, int32, int64: - number = fmt.Sprintf("%d", number) + 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) @@ -111,7 +117,7 @@ func (w *ZltxOrderDetailTool) getZltxOrderDetail(rec *entitys.Recognize, number } //查询订单详情 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), }, diff --git a/internal/tools/zltx/zltx_statistics.go b/internal/tools/zltx/zltx_statistics.go index 5d71a9b..29e143a 100644 --- a/internal/tools/zltx/zltx_statistics.go +++ b/internal/tools/zltx/zltx_statistics.go @@ -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{ diff --git a/pkg/func.go b/pkg/func.go new file mode 100644 index 0000000..9b4f89b --- /dev/null +++ b/pkg/func.go @@ -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 +} diff --git a/tmpl/excel_temp/kshj_gt.xlsx b/tmpl/excel_temp/kshj_gt.xlsx new file mode 100755 index 0000000..2b27bfa Binary files /dev/null and b/tmpl/excel_temp/kshj_gt.xlsx differ diff --git a/tmpl/excel_temp/kshj_total.xlsx b/tmpl/excel_temp/kshj_total.xlsx new file mode 100755 index 0000000..b29ae47 Binary files /dev/null and b/tmpl/excel_temp/kshj_total.xlsx differ diff --git a/tmpl/excel_temp/lrtb_rank.xlsx b/tmpl/excel_temp/lrtb_rank.xlsx new file mode 100644 index 0000000..d3ed484 Binary files /dev/null and b/tmpl/excel_temp/lrtb_rank.xlsx differ diff --git a/tmpl/excel_temp/xstb_ana.xlsx b/tmpl/excel_temp/xstb_ana.xlsx new file mode 100644 index 0000000..54fb056 Binary files /dev/null and b/tmpl/excel_temp/xstb_ana.xlsx differ