commit ca828818cfffc665dcc3e618d598a6ab30f3e604 Author: hy001 Date: Wed Apr 15 19:22:42 2026 +0800 feat: initialize golang invite service project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2564bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +target/ diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..846e448 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "chatapp3-golang/internal/config" + invitehttp "chatapp3-golang/internal/http" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/service" + "context" + "log" + "time" +) + +func main() { + cfg := config.Load() + repository, err := repo.New(cfg) + if err != nil { + log.Fatalf("init repository failed: %v", err) + } + + if cfg.AutoMigrate { + if err := repository.AutoMigrate(); err != nil { + log.Fatalf("auto migrate failed: %v", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := repository.Ping(ctx); err != nil { + log.Fatalf("ping dependencies failed: %v", err) + } + + javaClient := integration.New(cfg) + inviteService := service.NewInviteService(cfg, repository, javaClient) + baishunService := service.NewBaishunService(cfg, repository, javaClient) + router := invitehttp.NewRouter(cfg, repository, javaClient, inviteService, baishunService) + + log.Printf("chatapp3-golang invite api listening on %s", cfg.ListenAddr) + if err := router.Run(cfg.ListenAddr); err != nil { + log.Fatalf("run server failed: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3db11a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module chatapp3-golang + +go 1.24 + +require ( + github.com/bwmarrin/snowflake v0.3.0 + github.com/gin-gonic/gin v1.10.0 + github.com/redis/go-redis/v9 v9.7.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // 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/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fa1519 --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +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= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d28c5b4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,102 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + ListenAddr string + PublicBaseURL string + MySQLDSN string + RedisAddr string + RedisPassword string + RedisDB int + AutoMigrate bool + JavaAuthBaseURL string + JavaDeviceBaseURL string + JavaAppBaseURL string + JavaBridgeBaseURL string + JavaOtherBaseURL string + JavaWalletBaseURL string + BaishunPlatformBaseURL string + BaishunAppID int64 + BaishunAppChannel string + BaishunAppKey string + BaishunGSP int + BaishunLaunchCodeTTLSeconds int + BaishunSSTokenTTLSeconds int + InternalCallbackSecret string + Timeout time.Duration +} + +func Load() Config { + return Config{ + ListenAddr: getEnv("INVITE_HTTP_ADDR", ":2900"), + PublicBaseURL: getEnv("INVITE_PUBLIC_BASE_URL", "http://localhost:2900"), + MySQLDSN: getEnv("INVITE_MYSQL_DSN", "root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local"), + RedisAddr: getEnv("INVITE_REDIS_ADDR", "127.0.0.1:6379"), + RedisPassword: getEnv("INVITE_REDIS_PASSWORD", ""), + RedisDB: getEnvInt("INVITE_REDIS_DB", 0), + AutoMigrate: getEnvBool("INVITE_AUTO_MIGRATE", false), + JavaAuthBaseURL: getEnv("INVITE_JAVA_AUTH_BASE_URL", "http://127.0.0.1:2100"), + JavaDeviceBaseURL: getEnv("INVITE_JAVA_DEVICE_BASE_URL", "http://127.0.0.1:2400"), + JavaAppBaseURL: getEnv("INVITE_JAVA_APP_BASE_URL", "http://127.0.0.1:2400"), + JavaBridgeBaseURL: getEnv("INVITE_JAVA_BRIDGE_BASE_URL", "http://127.0.0.1:2200"), + JavaOtherBaseURL: getEnv("GAME_JAVA_OTHER_BASE_URL", getEnv("INVITE_JAVA_APP_BASE_URL", "http://127.0.0.1:2400")), + JavaWalletBaseURL: getEnv("GAME_JAVA_WALLET_BASE_URL", "http://127.0.0.1:2300"), + BaishunPlatformBaseURL: getEnv("BAISHUN_PLATFORM_BASE_URL", ""), + BaishunAppID: getEnvInt64("BAISHUN_APP_ID", 0), + BaishunAppChannel: getEnv("BAISHUN_APP_CHANNEL", ""), + BaishunAppKey: getEnv("BAISHUN_APP_KEY", ""), + BaishunGSP: getEnvInt("BAISHUN_GSP", 101), + BaishunLaunchCodeTTLSeconds: getEnvInt("BAISHUN_LAUNCH_CODE_TTL_SECONDS", 300), + BaishunSSTokenTTLSeconds: getEnvInt("BAISHUN_SS_TOKEN_TTL_SECONDS", 86400), + InternalCallbackSecret: getEnv("GAME_INTERNAL_CALLBACK_SECRET", ""), + Timeout: time.Duration(getEnvInt("INVITE_HTTP_TIMEOUT_SECONDS", 8)) * time.Second, + } +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func getEnvInt64(key string, fallback int64) int64 { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fallback + } + return parsed +} + +func getEnvBool(key string, fallback bool) bool { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/http/baishun_handler.go b/internal/http/baishun_handler.go new file mode 100644 index 0000000..b5bdd4c --- /dev/null +++ b/internal/http/baishun_handler.go @@ -0,0 +1,277 @@ +package http + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/service" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +func registerBaishunRoutes( + router *gin.Engine, + cfg config.Config, + javaClient *integration.Client, + baishunService *service.BaishunService, +) { + appGroup := router.Group("/app/game") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/room/shortcut", func(c *gin.Context) { + user := mustAuthUser(c) + resp, err := baishunService.ListShortcutGames(c.Request.Context(), user, c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/room/list", func(c *gin.Context) { + user := mustAuthUser(c) + resp, err := baishunService.ListRoomGames(c.Request.Context(), user, c.Query("roomId"), c.Query("category")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/baishun/state", func(c *gin.Context) { + user := mustAuthUser(c) + resp, err := baishunService.GetRoomState(c.Request.Context(), user, c.Query("roomId")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/baishun/launch", func(c *gin.Context) { + user := mustAuthUser(c) + var req service.BaishunLaunchAppRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.LaunchGame(c.Request.Context(), user, req, resolveClientIP(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/baishun/close", func(c *gin.Context) { + user := mustAuthUser(c) + var req service.BaishunCloseAppRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.CloseGame(c.Request.Context(), user, req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + internalGroup := router.Group("/internal/game/baishun") + internalGroup.Use(internalSecretMiddleware(cfg.InternalCallbackSecret)) + internalGroup.POST("/sync-catalog", func(c *gin.Context) { + var req service.SyncCatalogRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.SyncCatalog(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + internalGroup.POST("/refresh-room-state", func(c *gin.Context) { + var req struct { + SysOrigin string `json:"sysOrigin"` + RoomID string `json:"roomId"` + } + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := baishunService.RefreshRoomState(c.Request.Context(), req.SysOrigin, req.RoomID) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + callbackGroup := router.Group("/game/baishun") + callbackGroup.POST("/token", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleToken(c.Request.Context(), mapToTokenRequest(payload), raw)) + }) + callbackGroup.POST("/profile", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleProfile(c.Request.Context(), mapToProfileRequest(payload), raw)) + }) + callbackGroup.POST("/update-token", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleUpdateToken(c.Request.Context(), mapToUpdateTokenRequest(payload), raw)) + }) + callbackGroup.POST("/change-balance", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleChangeBalance(c.Request.Context(), mapToChangeBalanceRequest(payload), raw)) + }) + callbackGroup.POST("/report", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleReport(c.Request.Context(), payload, raw)) + }) + callbackGroup.POST("/balance-info", func(c *gin.Context) { + raw, payload := readRawPayload(c) + writeBaishunJSON(c, http.StatusOK, baishunService.HandleBalanceInfo(c.Request.Context(), mapToBalanceInfoRequest(payload), raw)) + }) +} + +func internalSecretMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + if strings.TrimSpace(secret) == "" { + c.Next() + return + } + token := strings.TrimSpace(c.GetHeader("X-Internal-Token")) + if token != secret { + writeError(c, service.NewAppError(http.StatusUnauthorized, "invalid_internal_token", "internal token is invalid")) + c.Abort() + return + } + c.Next() + } +} + +func writeBaishunJSON(c *gin.Context, status int, payload any) { + c.JSON(status, payload) +} + +func readRawPayload(c *gin.Context) (string, map[string]any) { + rawBytes, _ := c.GetRawData() + raw := string(rawBytes) + payload := map[string]any{} + if len(rawBytes) > 0 { + _ = json.Unmarshal(rawBytes, &payload) + } + return raw, payload +} + +func mapToTokenRequest(payload map[string]any) service.BaishunTokenRequest { + return service.BaishunTokenRequest{ + AppID: asInt64(payload["app_id"]), + UserID: asString(payload["user_id"]), + Code: asString(payload["code"]), + Signature: asString(payload["signature"]), + SignatureNonce: asString(payload["signature_nonce"]), + Timestamp: asInt64(payload["timestamp"]), + } +} + +func mapToProfileRequest(payload map[string]any) service.BaishunProfileRequest { + return service.BaishunProfileRequest{ + AppID: asInt64(payload["app_id"]), + UserID: asString(payload["user_id"]), + SSToken: asString(payload["ss_token"]), + ClientIP: asString(payload["client_ip"]), + GameID: int(asInt64(payload["game_id"])), + Signature: asString(payload["signature"]), + SignatureNonce: asString(payload["signature_nonce"]), + Timestamp: asInt64(payload["timestamp"]), + } +} + +func mapToUpdateTokenRequest(payload map[string]any) service.BaishunUpdateTokenRequest { + return service.BaishunUpdateTokenRequest{ + AppID: asInt64(payload["app_id"]), + UserID: asString(payload["user_id"]), + SSToken: asString(payload["ss_token"]), + GameID: int(asInt64(payload["game_id"])), + Signature: asString(payload["signature"]), + SignatureNonce: asString(payload["signature_nonce"]), + Timestamp: asInt64(payload["timestamp"]), + } +} + +func mapToChangeBalanceRequest(payload map[string]any) service.BaishunChangeBalanceRequest { + var currencyType *int + if rawValue, exists := payload["currency_type"]; exists && rawValue != nil { + parsed := int(asInt64(rawValue)) + currencyType = &parsed + } + return service.BaishunChangeBalanceRequest{ + AppID: asInt64(payload["app_id"]), + UserID: asString(payload["user_id"]), + SSToken: asString(payload["ss_token"]), + CurrencyDiff: asInt64(payload["currency_diff"]), + DiffMsg: asString(payload["diff_msg"]), + GameID: int(asInt64(payload["game_id"])), + GameRoundID: asString(payload["game_round_id"]), + RoomID: asString(payload["room_id"]), + ChangeTimeAt: asInt64(payload["change_time_at"]), + OrderID: asString(payload["order_id"]), + Extend: asString(payload["extend"]), + MsgType: asString(payload["msg_type"]), + CurrencyType: currencyType, + Signature: asString(payload["signature"]), + SignatureNonce: asString(payload["signature_nonce"]), + Timestamp: asInt64(payload["timestamp"]), + } +} + +func mapToBalanceInfoRequest(payload map[string]any) service.BaishunBalanceInfoRequest { + return service.BaishunBalanceInfoRequest{ + UserID: asString(payload["user_id"]), + AppID: asInt64(payload["app_id"]), + AppChannel: asString(payload["app_channel"]), + Signature: asString(payload["signature"]), + SignatureNonce: asString(payload["signature_nonce"]), + Timestamp: asInt64(payload["timestamp"]), + } +} + +func asString(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +func asInt64(value any) int64 { + if value == nil { + return 0 + } + switch typed := value.(type) { + case int64: + return typed + case int: + return int64(typed) + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed + default: + parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64) + return parsed + } +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..77e3fa9 --- /dev/null +++ b/internal/http/router.go @@ -0,0 +1,204 @@ +package http + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/service" + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const authUserContextKey = "auth_user" + +func NewRouter( + cfg config.Config, + repository *repo.Repository, + javaClient *integration.Client, + inviteService *service.InviteService, + baishunService *service.BaishunService, +) *gin.Engine { + router := gin.New() + router.Use(gin.Logger(), gin.Recovery()) + + router.GET("/health", func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout) + defer cancel() + if err := repository.Ping(ctx); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "code": "dependency_unavailable", + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success"}) + }) + + router.GET("/public/h5/invite-campaign/landing", func(c *gin.Context) { + resp, err := inviteService.GetLanding(c.Request.Context(), c.Query("code")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + internal := router.Group("/internal/invite-campaign") + internal.POST("/recharge-success", func(c *gin.Context) { + var req service.RechargeSuccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.HandleRechargeSuccess(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + authenticated := router.Group("/app/h5/invite-campaign") + authenticated.Use(authMiddleware(javaClient)) + authenticated.GET("/home", func(c *gin.Context) { + user := mustAuthUser(c) + resp, err := inviteService.GetHome(c.Request.Context(), user) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + authenticated.POST("/bind-code", func(c *gin.Context) { + user := mustAuthUser(c) + var req service.BindCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.BindCode(c.Request.Context(), user, resolveClientIP(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + authenticated.POST("/tasks/claim", func(c *gin.Context) { + user := mustAuthUser(c) + var req service.TaskClaimRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, service.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := inviteService.ClaimTask(c.Request.Context(), user, req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + registerBaishunRoutes(router, cfg, javaClient, baishunService) + + return router +} + +func authMiddleware(javaClient *integration.Client) gin.HandlerFunc { + return func(c *gin.Context) { + authorization := strings.TrimSpace(c.GetHeader("Authorization")) + if authorization == "" { + writeError(c, service.NewAppError(http.StatusUnauthorized, "missing_authorization", "authorization is required")) + c.Abort() + return + } + token := trimBearer(authorization) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + credential, err := javaClient.AuthenticateToken(ctx, token) + if err != nil { + writeError(c, service.NewAppError(http.StatusUnauthorized, "invalid_token", err.Error())) + c.Abort() + return + } + c.Set(authUserContextKey, service.AuthUser{ + UserID: int64(credential.UserID), + SysOrigin: credential.SysOrigin, + Token: token, + Authorization: authorization, + }) + c.Next() + } +} + +func mustAuthUser(c *gin.Context) service.AuthUser { + value, exists := c.Get(authUserContextKey) + if !exists { + return service.AuthUser{} + } + user, _ := value.(service.AuthUser) + return user +} + +func writeOK(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "status": true, + "errorCode": 0, + "errorMsg": "success", + "body": data, + "code": "success", + "message": "success", + "data": data, + "time": time.Now().UnixMilli(), + }) +} + +func writeError(c *gin.Context, err error) { + var appErr *service.AppError + if errors.As(err, &appErr) { + c.JSON(appErr.Status, gin.H{ + "status": false, + "errorCode": appErr.Status, + "errorCodeName": appErr.Code, + "errorMsg": appErr.Message, + "body": nil, + "code": appErr.Code, + "message": appErr.Message, + "data": nil, + "time": time.Now().UnixMilli(), + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{ + "status": false, + "errorCode": http.StatusInternalServerError, + "errorCodeName": "internal_error", + "errorMsg": err.Error(), + "body": nil, + "code": "internal_error", + "message": err.Error(), + "data": nil, + "time": time.Now().UnixMilli(), + }) +} + +func trimBearer(authorization string) string { + if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { + return strings.TrimSpace(authorization[7:]) + } + return authorization +} + +func resolveClientIP(c *gin.Context) string { + if forwarded := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + return strings.TrimSpace(c.ClientIP()) +} diff --git a/internal/integration/java.go b/internal/integration/java.go new file mode 100644 index 0000000..cfd03fe --- /dev/null +++ b/internal/integration/java.go @@ -0,0 +1,304 @@ +package integration + +import ( + "bytes" + "chatapp3-golang/internal/config" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +type Client struct { + cfg config.Config + httpClient *http.Client +} + +type UserCredential struct { + UserID Int64Value `json:"userId"` + SysOrigin string `json:"sysOrigin"` +} + +type UserProfile struct { + ID Int64Value `json:"id"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` +} + +type GoldReceiptCommand struct { + ReceiptType string `json:"receiptType"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + EventID string `json:"eventId"` + Remark string `json:"remark,omitempty"` + Amount int64 `json:"amount"` + CloseDelayAsset bool `json:"closeDelayAsset"` + OpUserType string `json:"opUserType"` + CustomizeOrigin string `json:"customizeOrigin,omitempty"` + CustomizeOriginDesc string `json:"customizeOriginDesc,omitempty"` +} + +type TestGoldReceiptCommand struct { + ReceiptType string `json:"receiptType"` + UserID int64 `json:"userId"` + EventType string `json:"eventType"` + EventDesc string `json:"eventDesc"` + EventID string `json:"eventId"` + SysOrigin string `json:"sysOrigin"` + Remark string `json:"remark,omitempty"` + Amount int64 `json:"amount"` + CloseDelayAsset bool `json:"closeDelayAsset"` +} + +type InviteCode struct { + UserID Int64Value `json:"userId"` + InviteCode string `json:"inviteCode"` + HasBound bool `json:"hasBound"` +} + +type GrantGoldRequest struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + EventID string `json:"eventId"` + Origin string `json:"origin"` + GoldAmount int64 `json:"goldAmount"` + Remark string `json:"remark"` +} + +type GrantPropsRequest struct { + TrackID int64 `json:"trackId"` + AcceptUserID int64 `json:"acceptUserId"` + SysOrigin string `json:"sysOrigin"` + Origin string `json:"origin"` + SourceGroupID int64 `json:"sourceGroupId"` +} + +type resultResponse[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Body T `json:"body"` + Success *bool `json:"success"` +} + +type Int64Value int64 + +func (v *Int64Value) UnmarshalJSON(data []byte) error { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + *v = 0 + return nil + } + if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { + raw = strings.Trim(raw, "\"") + } + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return err + } + *v = Int64Value(parsed) + return nil +} + +func New(cfg config.Config) *Client { + return &Client{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + } +} + +func (c *Client) AuthenticateToken(ctx context.Context, token string) (UserCredential, error) { + endpoint := c.cfg.JavaAuthBaseURL + "/auth/client/getUserCredentialByToken?token=" + url.QueryEscape(token) + var resp resultResponse[UserCredential] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return UserCredential{}, err + } + if int64(resp.Body.UserID) == 0 { + return UserCredential{}, fmt.Errorf("empty credential response") + } + return resp.Body, nil +} + +func (c *Client) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { + endpoint := c.cfg.JavaDeviceBaseURL + "/user/device/fingerprint?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[string] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return "", err + } + return strings.TrimSpace(resp.Body), nil +} + +func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { + endpoint := c.cfg.JavaAppBaseURL + "/activity/invite/user/my/invite/code" + headers := http.Header{} + if authorization != "" { + headers.Set("Authorization", authorization) + } + var resp InviteCode + if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { + return InviteCode{}, err + } + if strings.TrimSpace(resp.InviteCode) == "" { + return InviteCode{}, fmt.Errorf("invite code response is empty") + } + return resp, nil +} + +func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error { + return c.postJSON(ctx, c.cfg.JavaBridgeBaseURL+"/internal/resident-activity/invite/grant-gold", req, nil, nil) +} + +func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error { + return c.postJSON(ctx, c.cfg.JavaBridgeBaseURL+"/internal/resident-activity/invite/grant-props", req, nil, nil) +} + +func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { + endpoint := c.cfg.JavaOtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[UserProfile] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return UserProfile{}, err + } + if int64(resp.Body.ID) == 0 { + return UserProfile{}, fmt.Errorf("empty user profile response") + } + return resp.Body, nil +} + +func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, error) { + endpoint := c.cfg.JavaOtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + var resp resultResponse[string] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return "", err + } + return strings.TrimSpace(resp.Body), nil +} + +func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/mapBalance" + var resp resultResponse[map[string]int64] + if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil { + return nil, err + } + result := make(map[int64]int64, len(resp.Body)) + for key, value := range resp.Body { + userID, err := strconv.ParseInt(key, 10, 64) + if err != nil { + continue + } + result[userID] = value + } + return result, nil +} + +func (c *Client) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { + endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/existsEventId?eventId=" + url.QueryEscape(eventID) + var resp resultResponse[bool] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return false, err + } + return resp.Body, nil +} + +func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { + endpoint := c.cfg.JavaWalletBaseURL + "/wallet/gold/client/balance/change" + if err := c.postJSON(ctx, endpoint, cmd, nil, nil); err == nil { + return nil + } else if !shouldFallbackToGoldTestEndpoint(err) { + return err + } + + // Some local environments only expose the lighter test endpoint payload. + testCmd := TestGoldReceiptCommand{ + ReceiptType: cmd.ReceiptType, + UserID: cmd.UserID, + EventType: cmd.CustomizeOrigin, + EventDesc: cmd.CustomizeOriginDesc, + EventID: cmd.EventID, + SysOrigin: cmd.SysOrigin, + Remark: cmd.Remark, + Amount: cmd.Amount, + CloseDelayAsset: cmd.CloseDelayAsset, + } + return c.postJSON(ctx, c.cfg.JavaWalletBaseURL+"/wallet/gold/client/balance/change/test", testCmd, nil, nil) +} + +func shouldFallbackToGoldTestEndpoint(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + switch { + case strings.Contains(message, "status=404"), + strings.Contains(message, "status=405"), + strings.Contains(message, "status=501"), + strings.Contains(message, "connection refused"), + strings.Contains(message, "no such host"), + strings.Contains(message, "unsupported protocol"): + return true + default: + return false + } +} + +func (c *Client) getJSON(ctx context.Context, endpoint string, headers http.Header, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + applyHeaders(req.Header, headers) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return decodeResponse(resp, out) +} + +func (c *Client) postJSON(ctx context.Context, endpoint string, payload any, headers http.Header, out any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + applyHeaders(req.Header, headers) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return decodeResponse(resp, out) +} + +func decodeResponse(resp *http.Response, out any) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("java api failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if out == nil || len(body) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode response failed: %w body=%s", err, strings.TrimSpace(string(body))) + } + return nil +} + +func applyHeaders(dst http.Header, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} diff --git a/internal/model/baishun_models.go b/internal/model/baishun_models.go new file mode 100644 index 0000000..fe0f15a --- /dev/null +++ b/internal/model/baishun_models.go @@ -0,0 +1,162 @@ +package model + +import "time" + +type SysGameListConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + GameOrigin string `gorm:"column:game_origin;size:32"` + GameID string `gorm:"column:game_id;size:64"` + Name string `gorm:"column:name;size:128"` + Category string `gorm:"column:category;size:64"` + GameCode string `gorm:"column:game_code;size:512"` + Cover string `gorm:"column:cover;size:1024"` + Showcase bool `gorm:"column:is_showcase"` + Sort int64 `gorm:"column:sort"` + FullScreen bool `gorm:"column:full_screen"` + ClientOrigin string `gorm:"column:client_origin;size:32"` + Regions string `gorm:"column:regions;size:255"` + GameMode string `gorm:"column:game_mode;size:255"` +} + +func (SysGameListConfig) TableName() string { return "sys_game_list_config" } + +type SysGameListVendorExt struct { + ID int64 `gorm:"column:id;primaryKey"` + GameListConfigID int64 `gorm:"column:game_list_config_id;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:1"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_game_vendor_ext_sys_vendor,priority:1"` + VendorType string `gorm:"column:vendor_type;size:32;uniqueIndex:uk_game_vendor_ext_cfg_vendor,priority:2;index:idx_game_vendor_ext_sys_vendor,priority:2"` + VendorGameID string `gorm:"column:vendor_game_id;size:64"` + LaunchMode string `gorm:"column:launch_mode;size:32"` + PackageVersion string `gorm:"column:package_version;size:32"` + PackageURL string `gorm:"column:package_url;size:1024"` + PreviewURL string `gorm:"column:preview_url;size:1024"` + Orientation *int `gorm:"column:orientation"` + SafeHeight int `gorm:"column:safe_height"` + GSP string `gorm:"column:gsp;size:64"` + BridgeSchemaVersion string `gorm:"column:bridge_schema_version;size:16"` + CurrencyType *int `gorm:"column:currency_type"` + CurrencyIcon string `gorm:"column:currency_icon;size:1024"` + ExtraJSON string `gorm:"column:extra_json;type:text"` + Enabled bool `gorm:"column:enabled;index:idx_game_vendor_ext_sys_vendor,priority:3"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (SysGameListVendorExt) TableName() string { return "sys_game_list_vendor_ext" } + +type BaishunGameCatalog struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:1;index:idx_baishun_catalog_internal_game,priority:1"` + InternalGameID string `gorm:"column:internal_game_id;size:64;index:idx_baishun_catalog_internal_game,priority:2"` + VendorGameID int `gorm:"column:vendor_game_id;uniqueIndex:uk_baishun_catalog_sys_vendor_game,priority:2"` + Name string `gorm:"column:name;size:128"` + Cover string `gorm:"column:cover;size:1024"` + PreviewURL string `gorm:"column:preview_url;size:1024"` + DownloadURL string `gorm:"column:download_url;size:1024"` + PackageVersion string `gorm:"column:package_version;size:32"` + GameModeJSON string `gorm:"column:game_mode_json;size:64"` + Orientation *int `gorm:"column:orientation"` + SafeHeight int `gorm:"column:safe_height"` + VenueLevelJSON string `gorm:"column:venue_level_json;size:64"` + GSP string `gorm:"column:gsp;size:64"` + RawJSON string `gorm:"column:raw_json;type:longtext"` + Status string `gorm:"column:status;size:32;index:idx_baishun_catalog_internal_game,priority:3"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (BaishunGameCatalog) TableName() string { return "baishun_game_catalog" } + +type BaishunLaunchSession struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_baishun_session_room_user,priority:1"` + RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_session_room_user,priority:2"` + UserID int64 `gorm:"column:user_id;index:idx_baishun_session_room_user,priority:3"` + InternalGameID string `gorm:"column:internal_game_id;size:64"` + VendorGameID int `gorm:"column:vendor_game_id"` + GameSessionID string `gorm:"column:game_session_id;size:64;uniqueIndex:uk_baishun_game_session"` + LaunchCode string `gorm:"column:launch_code;size:128;uniqueIndex:uk_baishun_launch_code"` + LaunchCodeExpireTime time.Time `gorm:"column:launch_code_expire_time"` + SSToken *string `gorm:"column:ss_token;size:255;uniqueIndex:uk_baishun_ss_token"` + SSTokenExpireTime *time.Time `gorm:"column:ss_token_expire_time;index:idx_baishun_session_token_expire"` + Language string `gorm:"column:language;size:16"` + GSP int `gorm:"column:gsp"` + CurrencyType int `gorm:"column:currency_type"` + CurrencyIcon string `gorm:"column:currency_icon;size:1024"` + SceneMode int `gorm:"column:scene_mode"` + GameMode int `gorm:"column:game_mode"` + PackageVersion string `gorm:"column:package_version;size:32"` + ClientIP string `gorm:"column:client_ip;size:64"` + ClientOrigin string `gorm:"column:client_origin;size:16"` + Status string `gorm:"column:status;size:32;index:idx_baishun_session_room_user,priority:4"` + ClosedReason string `gorm:"column:closed_reason;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (BaishunLaunchSession) TableName() string { return "baishun_launch_session" } + +type BaishunOrderIdempotency struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_order,priority:1;index:idx_baishun_order_user_time,priority:1"` + OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_baishun_order,priority:2"` + GameRoundID string `gorm:"column:game_round_id;size:128;index:idx_baishun_round"` + RoomID string `gorm:"column:room_id;size:64"` + UserID int64 `gorm:"column:user_id;index:idx_baishun_order_user_time,priority:2"` + VendorGameID int `gorm:"column:vendor_game_id"` + CurrencyDiff int64 `gorm:"column:currency_diff"` + DiffMsg string `gorm:"column:diff_msg;size:32"` + MsgType string `gorm:"column:msg_type;size:64"` + CurrencyType *int `gorm:"column:currency_type"` + WalletEventID string `gorm:"column:wallet_event_id;size:160;uniqueIndex:uk_baishun_wallet_event"` + WalletAssetRecordID *int64 `gorm:"column:wallet_asset_record_id"` + Status string `gorm:"column:status;size:32"` + RequestJSON string `gorm:"column:request_json;type:longtext"` + ResponseJSON string `gorm:"column:response_json;type:longtext"` + CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_order_user_time,priority:3"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (BaishunOrderIdempotency) TableName() string { return "baishun_order_idempotency" } + +type BaishunCallbackLog struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + RequestType string `gorm:"column:request_type;size:32;index:idx_baishun_log_type_time,priority:1"` + OrderID string `gorm:"column:order_id;size:128;index:idx_baishun_log_order"` + GameRoundID string `gorm:"column:game_round_id;size:128"` + RoomID string `gorm:"column:room_id;size:64;index:idx_baishun_log_room_user,priority:1"` + UserID *int64 `gorm:"column:user_id;index:idx_baishun_log_room_user,priority:2"` + VendorGameID *int `gorm:"column:vendor_game_id"` + RequestJSON string `gorm:"column:request_json;type:longtext"` + ResponseJSON string `gorm:"column:response_json;type:longtext"` + BizCode string `gorm:"column:biz_code;size:32"` + BizMessage string `gorm:"column:biz_message;size:255"` + Status string `gorm:"column:status;size:32"` + CreateTime time.Time `gorm:"column:create_time;index:idx_baishun_log_type_time,priority:2"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (BaishunCallbackLog) TableName() string { return "baishun_callback_log" } + +type BaishunRoomState struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_baishun_room_state,priority:1"` + RoomID string `gorm:"column:room_id;size:64;uniqueIndex:uk_baishun_room_state,priority:2"` + HostUserID int64 `gorm:"column:host_user_id"` + CurrentGameID string `gorm:"column:current_game_id;size:64"` + CurrentVendorGameID *int `gorm:"column:current_vendor_game_id"` + CurrentGameName string `gorm:"column:current_game_name;size:128"` + CurrentGameCover string `gorm:"column:current_game_cover;size:1024"` + GameSessionID string `gorm:"column:game_session_id;size:64;index:idx_baishun_room_state_session"` + LaunchUserID *int64 `gorm:"column:launch_user_id"` + State string `gorm:"column:state;size:32"` + ExtraJSON string `gorm:"column:extra_json;type:text"` + StartTime *time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (BaishunRoomState) TableName() string { return "baishun_room_state" } diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..bcd8c99 --- /dev/null +++ b/internal/model/models.go @@ -0,0 +1,149 @@ +package model + +import "time" + +type InviteCodeMapping struct { + UserID int64 `gorm:"column:user_id;primaryKey"` + InviteCode string `gorm:"column:invite_code;size:32;uniqueIndex:uk_invite_code"` + Account string `gorm:"column:account;size:64"` +} + +func (InviteCodeMapping) TableName() string { return "invite_code_mapping" } + +type UserBaseInfo struct { + ID int64 `gorm:"column:id;primaryKey"` + Account string `gorm:"column:account;size:64"` + UserAvatar string `gorm:"column:user_avatar;size:255"` + UserNickname string `gorm:"column:user_nickname;size:255"` + OriginSys string `gorm:"column:origin_sys;size:32"` + CreateTime time.Time `gorm:"column:create_time"` +} + +func (UserBaseInfo) TableName() string { return "user_base_info" } + +type InviteCampaignConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_campaign_sys_origin"` + Enabled bool `gorm:"column:enabled"` + DownloadURL string `gorm:"column:download_url;size:512"` + ShareTitle string `gorm:"column:share_title;size:255"` + ShareDesc string `gorm:"column:share_desc;size:255"` + ServiceContact string `gorm:"column:service_contact;size:255"` + Timezone string `gorm:"column:timezone;size:64"` + AntiCheatSameIPOnce bool `gorm:"column:anti_cheat_same_ip_once"` + AntiCheatSameDeviceOnce bool `gorm:"column:anti_cheat_same_device_once"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignConfig) TableName() string { return "invite_campaign_config" } + +type InviteCampaignRewardRule struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_rule_sys_type_sort,priority:1"` + RuleType string `gorm:"column:rule_type;size:32;index:idx_invite_rule_sys_type_sort,priority:2"` + ThresholdValue int64 `gorm:"column:threshold_value"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + Enabled bool `gorm:"column:enabled"` + Sort int `gorm:"column:sort;index:idx_invite_rule_sys_type_sort,priority:3"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignRewardRule) TableName() string { return "invite_campaign_reward_rule" } + +type InviteCampaignRelation struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_relation_sys_inviter,priority:1;index:uk_invite_relation_invitee,priority:1,unique"` + InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invite_relation_sys_inviter,priority:2"` + InviteeUserID int64 `gorm:"column:invitee_user_id;index:uk_invite_relation_invitee,priority:2,unique"` + InviteCode string `gorm:"column:invite_code;size:32"` + BindIP string `gorm:"column:bind_ip;size:128;index:idx_invite_relation_ip"` + DeviceFingerprint string `gorm:"column:device_fingerprint;size:255;index:idx_invite_relation_device"` + EligibleStatus string `gorm:"column:eligible_status;size:32"` + BlockReason string `gorm:"column:block_reason;size:255"` + BindTime time.Time `gorm:"column:bind_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignRelation) TableName() string { return "invite_campaign_relation" } + +type InviteCampaignInviteeProgress struct { + ID int64 `gorm:"column:id;primaryKey"` + RelationID int64 `gorm:"column:relation_id;uniqueIndex:uk_invitee_progress_relation"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + InviterUserID int64 `gorm:"column:inviter_user_id;index:idx_invitee_progress_inviter"` + InviteeUserID int64 `gorm:"column:invitee_user_id;uniqueIndex:uk_invitee_progress_invitee"` + TotalRecharge int64 `gorm:"column:total_recharge_coins"` + ValidUser bool `gorm:"column:valid_user"` + ValidTime *time.Time `gorm:"column:valid_time"` + RewardedRuleIDs string `gorm:"column:rewarded_rule_ids;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignInviteeProgress) TableName() string { return "invite_campaign_invitee_progress" } + +type InviteCampaignMonthlyProgress struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_monthly_progress,priority:1"` + InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_monthly_progress,priority:2"` + MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_monthly_progress,priority:3"` + InviteCount int64 `gorm:"column:invite_count"` + ValidUserCount int64 `gorm:"column:valid_user_count"` + TotalRecharge int64 `gorm:"column:total_recharge_coins"` + RewardGoldCoins int64 `gorm:"column:reward_gold_coins"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignMonthlyProgress) TableName() string { return "invite_campaign_monthly_progress" } + +type InviteCampaignTaskClaim struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_invite_task_claim,priority:1"` + InviterUserID int64 `gorm:"column:inviter_user_id;uniqueIndex:uk_invite_task_claim,priority:2"` + MonthKey string `gorm:"column:month_key;size:6;uniqueIndex:uk_invite_task_claim,priority:3"` + RuleID int64 `gorm:"column:rule_id;uniqueIndex:uk_invite_task_claim,priority:4"` + ClaimTime time.Time `gorm:"column:claim_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignTaskClaim) TableName() string { return "invite_campaign_task_claim" } + +type InviteCampaignRewardLog struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_invite_reward_target"` + RelationID *int64 `gorm:"column:relation_id"` + InviterUserID *int64 `gorm:"column:inviter_user_id"` + InviteeUserID *int64 `gorm:"column:invitee_user_id"` + TargetUserID int64 `gorm:"column:target_user_id;index:idx_invite_reward_target"` + RuleID *int64 `gorm:"column:rule_id"` + RewardScene string `gorm:"column:reward_scene;size:32"` + BusinessKey string `gorm:"column:business_key;size:128;uniqueIndex:uk_invite_reward_business"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + Status string `gorm:"column:status;size:32"` + ErrorMessage string `gorm:"column:error_message;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignRewardLog) TableName() string { return "invite_campaign_reward_log" } + +type InviteCampaignRechargeEvent struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32"` + OrderID string `gorm:"column:order_id;size:128;uniqueIndex:uk_invite_recharge_order"` + InviterUserID *int64 `gorm:"column:inviter_user_id"` + InviteeUserID int64 `gorm:"column:invitee_user_id"` + RechargeCoins int64 `gorm:"column:recharge_coins"` + PayTime time.Time `gorm:"column:pay_time"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (InviteCampaignRechargeEvent) TableName() string { return "invite_campaign_recharge_event" } diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..90c2871 --- /dev/null +++ b/internal/repo/repo.go @@ -0,0 +1,62 @@ +package repo + +import ( + "context" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type Repository struct { + DB *gorm.DB + Redis *redis.Client +} + +func New(cfg config.Config) (*Repository, error) { + db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{}) + if err != nil { + return nil, err + } + + client := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + + return &Repository{DB: db, Redis: client}, nil +} + +func (r *Repository) Ping(ctx context.Context) error { + if err := r.Redis.Ping(ctx).Err(); err != nil { + return err + } + sqlDB, err := r.DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} + +func (r *Repository) AutoMigrate() error { + return r.DB.AutoMigrate( + &model.InviteCampaignConfig{}, + &model.InviteCampaignRewardRule{}, + &model.InviteCampaignRelation{}, + &model.InviteCampaignInviteeProgress{}, + &model.InviteCampaignMonthlyProgress{}, + &model.InviteCampaignTaskClaim{}, + &model.InviteCampaignRewardLog{}, + &model.InviteCampaignRechargeEvent{}, + &model.SysGameListVendorExt{}, + &model.BaishunGameCatalog{}, + &model.BaishunLaunchSession{}, + &model.BaishunOrderIdempotency{}, + &model.BaishunCallbackLog{}, + &model.BaishunRoomState{}, + ) +} diff --git a/internal/service/baishun.go b/internal/service/baishun.go new file mode 100644 index 0000000..f9d8db8 --- /dev/null +++ b/internal/service/baishun.go @@ -0,0 +1,1403 @@ +package service + +import ( + "bytes" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/util" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + baishunVendorType = "BAISHUN" + baishunLaunchModeRemote = "H5_REMOTE" + baishunLaunchModeLocalZip = "H5_ZIP_LOCAL" + baishunCatalogEnabled = "ENABLED" + baishunRoomStateIdle = "IDLE" + baishunRoomStatePlaying = "PLAYING" + baishunSessionInit = "INIT" + baishunSessionCodeIssued = "CODE_ISSUED" + baishunSessionTokenIssued = "TOKEN_ISSUED" + baishunSessionActive = "ACTIVE" + baishunSessionClosed = "CLOSED" + baishunSessionExpired = "EXPIRED" + baishunOrderStatusInit = "INIT" + baishunOrderStatusSuccess = "SUCCESS" + baishunOrderStatusFailed = "FAILED" + baishunCallbackStatusSuccess = "SUCCESS" + baishunCallbackStatusFailed = "FAILED" + baishunCodeInsufficientAssets = 1008 + baishunCodeTokenExpired = 1009 + baishunCodeSignatureError = 1010 + baishunCodeRepeatUnknown = 1011 + baishunCodeServerError = 1999 +) + +type BaishunService struct { + cfg config.Config + repo *repo.Repository + java *integration.Client + httpClient *http.Client +} + +func NewBaishunService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *BaishunService { + return &BaishunService{ + cfg: cfg, + repo: repository, + java: javaClient, + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + } +} + +type RoomGameListItem struct { + GameID string `json:"gameId"` + VendorType string `json:"vendorType"` + VendorGameID int `json:"vendorGameId"` + Name string `json:"name"` + Cover string `json:"cover"` + Category string `json:"category,omitempty"` + Sort int64 `json:"sort,omitempty"` + LaunchMode string `json:"launchMode"` + FullScreen bool `json:"fullScreen"` + GameMode int `json:"gameMode"` + SafeHeight int `json:"safeHeight"` + Orientation int `json:"orientation"` + PackageVersion string `json:"packageVersion,omitempty"` + Status string `json:"status,omitempty"` +} + +type RoomGameListResponse struct { + Items []RoomGameListItem `json:"items"` +} + +type BaishunRoomStateResponse struct { + RoomID string `json:"roomId"` + State string `json:"state"` + GameSessionID string `json:"gameSessionId,omitempty"` + CurrentGameID string `json:"currentGameId,omitempty"` + CurrentVendorGameID int `json:"currentVendorGameId,omitempty"` + CurrentGameName string `json:"currentGameName,omitempty"` + CurrentGameCover string `json:"currentGameCover,omitempty"` + HostUserID int64 `json:"hostUserId,omitempty"` +} + +type BaishunLaunchAppRequest struct { + RoomID string `json:"roomId"` + GameID string `json:"gameId"` + SceneMode int `json:"sceneMode"` + ClientOrigin string `json:"clientOrigin"` +} + +type BaishunCloseAppRequest struct { + RoomID string `json:"roomId"` + GameSessionID string `json:"gameSessionId"` + Reason string `json:"reason"` +} + +type BaishunGameConfig struct { + SceneMode int `json:"sceneMode"` + CurrencyIcon string `json:"currencyIcon"` +} + +type BaishunBridgeConfig struct { + AppChannel string `json:"appChannel"` + AppID int64 `json:"appId"` + UserID string `json:"userId"` + Code string `json:"code"` + RoomID string `json:"roomId"` + GameMode string `json:"gameMode"` + Language string `json:"language"` + GSP int `json:"gsp"` + GameConfig BaishunGameConfig `json:"gameConfig"` +} + +type BaishunLaunchEntry struct { + LaunchMode string `json:"launchMode"` + EntryURL string `json:"entryUrl"` + PreviewURL string `json:"previewUrl"` + DownloadURL string `json:"downloadUrl"` + PackageVersion string `json:"packageVersion"` + Orientation int `json:"orientation"` + SafeHeight int `json:"safeHeight"` +} + +type BaishunLaunchAppResponse struct { + GameSessionID string `json:"gameSessionId"` + VendorType string `json:"vendorType"` + GameID string `json:"gameId"` + VendorGameID int `json:"vendorGameId"` + Entry BaishunLaunchEntry `json:"entry"` + BridgeConfig BaishunBridgeConfig `json:"bridgeConfig"` + RoomState BaishunRoomStateResponse `json:"roomState"` +} + +type SyncCatalogRequest struct { + SysOrigin string `json:"sysOrigin"` + VendorGameIDs []int `json:"vendorGameIds"` + Force bool `json:"force"` +} + +type SyncCatalogResponse struct { + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` +} + +type BaishunTokenRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + Code string `json:"code"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +type BaishunProfileRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + ClientIP string `json:"client_ip"` + GameID int `json:"game_id"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +type BaishunUpdateTokenRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + GameID int `json:"game_id"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +type BaishunChangeBalanceRequest struct { + AppID int64 `json:"app_id"` + UserID string `json:"user_id"` + SSToken string `json:"ss_token"` + CurrencyDiff int64 `json:"currency_diff"` + DiffMsg string `json:"diff_msg"` + GameID int `json:"game_id"` + GameRoundID string `json:"game_round_id"` + RoomID string `json:"room_id"` + ChangeTimeAt int64 `json:"change_time_at"` + OrderID string `json:"order_id"` + Extend string `json:"extend"` + MsgType string `json:"msg_type"` + CurrencyType *int `json:"currency_type"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +type BaishunBalanceInfoRequest struct { + UserID string `json:"user_id"` + AppID int64 `json:"app_id"` + AppChannel string `json:"app_channel"` + Signature string `json:"signature"` + SignatureNonce string `json:"signature_nonce"` + Timestamp int64 `json:"timestamp"` +} + +type baishunStandardResponse struct { + Code int `json:"code"` + Message string `json:"message"` + UniqueID string `json:"unique_id,omitempty"` + Data interface{} `json:"data,omitempty"` + UserInfo interface{} `json:"user_info,omitempty"` +} + +type baishunBalanceInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +type gameListRow struct { + ConfigID int64 + SysOrigin string + InternalGameID string + Name string + Category string + Cover string + Sort int64 + FullScreen bool + GameModeRaw string + VendorType string + VendorGameID string + LaunchMode string + ExtPackageVersion string + ExtPreviewURL string + PackageURL string + ExtOrientation *int + ExtSafeHeight int + CurrencyIcon string + ExtGSP string + CatalogName string + CatalogCover string + CatalogPreviewURL string + CatalogDownloadURL string + CatalogPackageVersion string + CatalogOrientation *int + CatalogSafeHeight int + CatalogStatus string +} + +type baishunPlatformResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data []baishunPlatformGame `json:"data"` +} + +type baishunPlatformOneGameResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data baishunPlatformGame `json:"data"` +} + +type baishunPlatformGame struct { + GameID int `json:"game_id"` + Name string `json:"name"` + PreviewURL string `json:"preview_url"` + GameVersion string `json:"game_version"` + DownloadURL string `json:"download_url"` + GameMode []int `json:"game_mode"` + GameOrientation int `json:"game_orientation"` + SafeHeight int `json:"safe_height"` + VenueLevel []int `json:"venue_level"` +} + +func (s *BaishunService) ListShortcutGames(ctx context.Context, user AuthUser, roomID string) ([]RoomGameListItem, error) { + items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, "") + if err != nil { + return nil, err + } + if len(items) > 5 { + items = items[:5] + } + return items, nil +} + +func (s *BaishunService) ListRoomGames(ctx context.Context, user AuthUser, roomID string, category string) (*RoomGameListResponse, error) { + items, err := s.listRoomGames(ctx, user.SysOrigin, roomID, category) + if err != nil { + return nil, err + } + return &RoomGameListResponse{Items: items}, nil +} + +func (s *BaishunService) GetRoomState(ctx context.Context, user AuthUser, roomID string) (*BaishunRoomStateResponse, error) { + var state model.BaishunRoomState + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ?", user.SysOrigin, roomID). + First(&state).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil + } + return nil, err + } + resp := &BaishunRoomStateResponse{ + RoomID: roomID, + State: defaultIfBlank(state.State, baishunRoomStateIdle), + GameSessionID: state.GameSessionID, + CurrentGameID: state.CurrentGameID, + CurrentGameName: state.CurrentGameName, + CurrentGameCover: state.CurrentGameCover, + HostUserID: state.HostUserID, + } + if state.CurrentVendorGameID != nil { + resp.CurrentVendorGameID = *state.CurrentVendorGameID + } + return resp, nil +} + +func (s *BaishunService) LaunchGame(ctx context.Context, user AuthUser, req BaishunLaunchAppRequest, clientIP string) (*BaishunLaunchAppResponse, error) { + if strings.TrimSpace(req.RoomID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + if strings.TrimSpace(req.GameID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_game_id", "gameId is required") + } + + row, err := s.findGameRow(ctx, user.SysOrigin, req.GameID) + if err != nil { + return nil, err + } + + sessionID, err := util.NextID() + if err != nil { + return nil, err + } + sessionDBID, err := util.NextID() + if err != nil { + return nil, err + } + roomStateID, err := util.NextID() + if err != nil { + return nil, err + } + launchCode, err := s.randomToken(24) + if err != nil { + return nil, err + } + + now := time.Now() + session := model.BaishunLaunchSession{ + ID: sessionDBID, + SysOrigin: user.SysOrigin, + RoomID: req.RoomID, + UserID: user.UserID, + InternalGameID: row.InternalGameID, + VendorGameID: row.vendorGameIDInt(), + GameSessionID: fmt.Sprintf("bs_%s_%d", sanitizeRoomID(req.RoomID), sessionID), + LaunchCode: launchCode, + LaunchCodeExpireTime: now.Add(time.Duration(s.cfg.BaishunLaunchCodeTTLSeconds) * time.Second), + Language: s.resolveLanguage(ctx, user.UserID), + GSP: s.resolveGSP(row), + CurrencyType: 0, + CurrencyIcon: row.currencyIcon(), + SceneMode: req.SceneMode, + GameMode: row.gameMode(), + PackageVersion: row.packageVersion(), + ClientIP: clientIP, + ClientOrigin: defaultIfBlank(req.ClientOrigin, "COMMON"), + Status: baishunSessionCodeIssued, + CreateTime: now, + UpdateTime: now, + } + + roomState := model.BaishunRoomState{ + ID: roomStateID, + SysOrigin: user.SysOrigin, + RoomID: req.RoomID, + HostUserID: user.UserID, + CurrentGameID: row.InternalGameID, + CurrentGameName: row.name(), + CurrentGameCover: row.cover(), + GameSessionID: session.GameSessionID, + LaunchUserID: &user.UserID, + State: baishunRoomStatePlaying, + StartTime: &now, + CreateTime: now, + UpdateTime: now, + } + vendorGameID := row.vendorGameIDInt() + roomState.CurrentVendorGameID = &vendorGameID + + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&session).Error; err != nil { + return err + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "room_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "host_user_id", "current_game_id", "current_vendor_game_id", "current_game_name", + "current_game_cover", "game_session_id", "launch_user_id", "state", "start_time", + "end_time", "update_time", + }), + }).Create(&roomState).Error + }); err != nil { + return nil, err + } + + return &BaishunLaunchAppResponse{ + GameSessionID: session.GameSessionID, + VendorType: baishunVendorType, + GameID: row.InternalGameID, + VendorGameID: row.vendorGameIDInt(), + Entry: BaishunLaunchEntry{ + LaunchMode: row.launchMode(), + EntryURL: row.entryURL(), + PreviewURL: row.previewURL(), + DownloadURL: row.downloadURL(), + PackageVersion: row.packageVersion(), + Orientation: row.orientation(), + SafeHeight: row.safeHeight(), + }, + BridgeConfig: BaishunBridgeConfig{ + AppChannel: defaultIfBlank(s.cfg.BaishunAppChannel, "skychat"), + AppID: s.cfg.BaishunAppID, + UserID: strconv.FormatInt(user.UserID, 10), + Code: launchCode, + RoomID: req.RoomID, + GameMode: strconv.Itoa(row.gameMode()), + Language: session.Language, + GSP: session.GSP, + GameConfig: BaishunGameConfig{ + SceneMode: req.SceneMode, + CurrencyIcon: session.CurrencyIcon, + }, + }, + RoomState: BaishunRoomStateResponse{ + RoomID: req.RoomID, + State: baishunRoomStatePlaying, + GameSessionID: session.GameSessionID, + CurrentGameID: row.InternalGameID, + CurrentVendorGameID: row.vendorGameIDInt(), + CurrentGameName: row.name(), + CurrentGameCover: row.cover(), + HostUserID: user.UserID, + }, + }, nil +} + +func (s *BaishunService) CloseGame(ctx context.Context, user AuthUser, req BaishunCloseAppRequest) (*BaishunRoomStateResponse, error) { + if strings.TrimSpace(req.RoomID) == "" { + return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") + } + now := time.Now() + if strings.TrimSpace(req.GameSessionID) != "" { + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("sys_origin = ? AND room_id = ? AND game_session_id = ?", user.SysOrigin, req.RoomID, req.GameSessionID). + Updates(map[string]any{ + "status": baishunSessionClosed, + "closed_reason": defaultIfBlank(req.Reason, "user_exit"), + "update_time": now, + }).Error; err != nil { + return nil, err + } + } + + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunRoomState{}). + Where("sys_origin = ? AND room_id = ?", user.SysOrigin, req.RoomID). + Updates(map[string]any{ + "state": baishunRoomStateIdle, + "end_time": now, + "game_session_id": "", + "current_game_id": "", + "current_vendor_game_id": nil, + "current_game_name": "", + "current_game_cover": "", + "update_time": now, + }).Error; err != nil { + return nil, err + } + + return &BaishunRoomStateResponse{ + RoomID: req.RoomID, + State: baishunRoomStateIdle, + }, nil +} + +func (s *BaishunService) SyncCatalog(ctx context.Context, req SyncCatalogRequest) (*SyncCatalogResponse, error) { + if strings.TrimSpace(s.cfg.BaishunPlatformBaseURL) == "" || s.cfg.BaishunAppID == 0 { + return nil, NewAppError(http.StatusBadRequest, "baishun_config_missing", "baishun platform config is missing") + } + sysOrigin := defaultIfBlank(req.SysOrigin, "LIKEI") + if strings.TrimSpace(sysOrigin) == "" { + sysOrigin = "LIKEI" + } + + games, err := s.fetchPlatformGames(ctx) + if err != nil { + return nil, err + } + filter := make(map[int]struct{}, len(req.VendorGameIDs)) + for _, id := range req.VendorGameIDs { + filter[id] = struct{}{} + } + + resp := &SyncCatalogResponse{} + for _, game := range games { + if len(filter) > 0 { + if _, ok := filter[game.GameID]; !ok { + resp.Skipped++ + continue + } + } + id, err := util.NextID() + if err != nil { + return nil, err + } + record := model.BaishunGameCatalog{ + ID: id, + SysOrigin: sysOrigin, + InternalGameID: fmt.Sprintf("bs_%d", game.GameID), + VendorGameID: game.GameID, + Name: game.Name, + Cover: defaultIfBlank(game.PreviewURL, game.DownloadURL), + PreviewURL: game.PreviewURL, + DownloadURL: game.DownloadURL, + PackageVersion: game.GameVersion, + GameModeJSON: mustJSONString(game.GameMode), + Orientation: intPtr(game.GameOrientation), + SafeHeight: game.SafeHeight, + VenueLevelJSON: mustJSONString(game.VenueLevel), + GSP: strconv.Itoa(s.cfg.BaishunGSP), + RawJSON: mustJSONString(game), + Status: baishunCatalogEnabled, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + err = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "vendor_game_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "internal_game_id", "name", "cover", "preview_url", "download_url", + "package_version", "game_mode_json", "orientation", "safe_height", + "venue_level_json", "gsp", "raw_json", "status", "update_time", + }), + }).Create(&record).Error + if err != nil { + return nil, err + } + if req.Force { + resp.Updated++ + } else { + resp.Inserted++ + } + } + return resp, nil +} + +func (s *BaishunService) RefreshRoomState(ctx context.Context, sysOrigin, roomID string) (*BaishunRoomStateResponse, error) { + var state model.BaishunRoomState + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND room_id = ?", sysOrigin, roomID). + First(&state).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return &BaishunRoomStateResponse{RoomID: roomID, State: baishunRoomStateIdle}, nil + } + return nil, err + } + if strings.TrimSpace(state.GameSessionID) == "" { + state.State = baishunRoomStateIdle + _ = s.repo.DB.WithContext(ctx).Model(&model.BaishunRoomState{}). + Where("id = ?", state.ID). + Update("state", baishunRoomStateIdle).Error + } + resp := &BaishunRoomStateResponse{ + RoomID: roomID, + State: defaultIfBlank(state.State, baishunRoomStateIdle), + GameSessionID: state.GameSessionID, + CurrentGameID: state.CurrentGameID, + CurrentGameName: state.CurrentGameName, + CurrentGameCover: state.CurrentGameCover, + HostUserID: state.HostUserID, + } + if state.CurrentVendorGameID != nil { + resp.CurrentVendorGameID = *state.CurrentVendorGameID + } + return resp, nil +} + +func (s *BaishunService) HandleToken(ctx context.Context, req BaishunTokenRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + var session model.BaishunLaunchSession + if err := s.repo.DB.WithContext(ctx). + Where("launch_code = ?", strings.TrimSpace(req.Code)). + First(&session).Error; err != nil { + resp := s.failStandard(baishunCodeTokenExpired, "code invalid") + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if session.Status != baishunSessionCodeIssued || time.Now().After(session.LaunchCodeExpireTime) { + resp := s.failStandard(baishunCodeTokenExpired, "code expired") + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if strings.TrimSpace(req.UserID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(req.UserID) { + resp := s.failStandard(baishunCodeTokenExpired, "code invalid") + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + ssToken, err := s.randomToken(32) + if err != nil { + resp := s.failStandard(baishunCodeServerError, "token create failed") + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second) + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{ + "ss_token": ssToken, + "ss_token_expire_time": expire, + "status": baishunSessionTokenIssued, + "update_time": time.Now(), + }).Error; err != nil { + resp := s.failStandard(baishunCodeServerError, "session update failed") + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + userInfo := s.readUserInfo(ctx, session.UserID) + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "ss_token": ssToken, + "expire_date": expire.UnixMilli(), + }, + UserInfo: map[string]any{ + "user_id": req.UserID, + "user_name": userInfo.UserNickname, + "user_avatar": userInfo.UserAvatar, + "balance": balance, + }, + } + s.saveCallbackLog(ctx, "token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +func (s *BaishunService) HandleProfile(ctx context.Context, req BaishunProfileRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + userInfo := s.readUserInfo(ctx, session.UserID) + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "user_id": req.UserID, + "user_name": userInfo.UserNickname, + "user_avatar": userInfo.UserAvatar, + "balance": balance, + "balance_list": []map[string]any{{"name": "Gold", "currency_type": 0, "currency_amount": balance}}, + "user_type": 1, + }, + } + _ = s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{"status": baishunSessionActive, "update_time": time.Now()}).Error + s.saveCallbackLog(ctx, "profile", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +func (s *BaishunService) HandleUpdateToken(ctx context.Context, req BaishunUpdateTokenRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + newToken, err := s.randomToken(32) + if err != nil { + resp := s.failStandard(baishunCodeServerError, "token create failed") + s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + expire := time.Now().Add(time.Duration(s.cfg.BaishunSSTokenTTLSeconds) * time.Second) + if err := s.repo.DB.WithContext(ctx). + Model(&model.BaishunLaunchSession{}). + Where("id = ?", session.ID). + Updates(map[string]any{ + "ss_token": newToken, + "ss_token_expire_time": expire, + "status": baishunSessionActive, + "update_time": time.Now(), + }).Error; err != nil { + resp := s.failStandard(baishunCodeServerError, "session update failed") + s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "ss_token": newToken, + "expire_date": expire.UnixMilli(), + }, + } + s.saveCallbackLog(ctx, "update-token", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +func (s *BaishunService) HandleChangeBalance(ctx context.Context, req BaishunChangeBalanceRequest, rawJSON string) baishunStandardResponse { + if !s.validateCommonSignature(ctx, req.AppID, "", req.Signature, req.SignatureNonce, req.Timestamp) { + return s.failStandard(baishunCodeSignatureError, "signature error") + } + session, ok := s.findSessionByToken(ctx, req.SSToken, req.UserID) + if !ok { + resp := s.failStandard(baishunCodeTokenExpired, "ss_token invalid") + s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), "", req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + if req.CurrencyDiff == 0 { + resp := s.failStandard(baishunCodeServerError, "currency_diff invalid") + s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + + lockKey := fmt.Sprintf("bs:lock:user:%s:%s", session.SysOrigin, req.UserID) + lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", 5*time.Second).Result() + if err == nil && lockOK { + defer s.repo.Redis.Del(context.Background(), lockKey) + } + + var order model.BaishunOrderIdempotency + err = s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND order_id = ?", session.SysOrigin, req.OrderID). + First(&order).Error + if err == nil && order.Status == baishunOrderStatusSuccess && strings.TrimSpace(order.ResponseJSON) != "" { + var resp baishunStandardResponse + if json.Unmarshal([]byte(order.ResponseJSON), &resp) == nil { + s.saveCallbackLog(ctx, "change-balance", rawJSON, order.ResponseJSON, session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) + return resp + } + } + + walletEventID := fmt.Sprintf("BAISHUN:%s:%s", session.SysOrigin, req.OrderID) + exists, existsErr := s.java.ExistsGoldEvent(ctx, walletEventID) + if existsErr == nil && !exists { + changeErr := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: receiptTypeFromDiff(req.CurrencyDiff), + UserID: session.UserID, + SysOrigin: session.SysOrigin, + EventID: walletEventID, + Remark: defaultIfBlank(req.DiffMsg, req.MsgType), + Amount: abs64(req.CurrencyDiff), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: fmt.Sprintf("BAISHUN_GAME_%d", req.GameID), + CustomizeOriginDesc: fmt.Sprintf("BAISHUN GAME[%d]", req.GameID), + }) + if changeErr != nil { + message := changeErr.Error() + code := baishunCodeServerError + if strings.Contains(strings.ToLower(message), "insufficient") || strings.Contains(message, "5000") { + code = baishunCodeInsufficientAssets + } + resp := s.failStandard(code, message) + s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusFailed, rawJSON, mustJSONString(resp), nil) + s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusFailed, resp.Code, resp.Message) + return resp + } + } + + balance := s.readUserBalance(ctx, session.UserID) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{ + "currency_balance": balance, + }, + } + s.persistOrderRecord(ctx, session.SysOrigin, req, walletEventID, baishunOrderStatusSuccess, rawJSON, mustJSONString(resp), nil) + s.saveCallbackLog(ctx, "change-balance", rawJSON, mustJSONString(resp), session.SysOrigin, req.UserID, &req.OrderID, &req.GameRoundID, baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +func (s *BaishunService) HandleReport(ctx context.Context, payload map[string]any, rawJSON string) baishunStandardResponse { + userID := toString(payload["user_id"]) + orderID := toString(payload["order_id"]) + gameRoundID := toString(payload["game_round_id"]) + resp := baishunStandardResponse{ + Code: 0, + Message: "succeed", + UniqueID: s.uniqueID(), + Data: map[string]any{}, + } + s.saveCallbackLog(ctx, "report", rawJSON, mustJSONString(resp), "", userID, stringPtr(orderID), stringPtr(gameRoundID), baishunCallbackStatusSuccess, 0, resp.Message) + return resp +} + +func (s *BaishunService) HandleBalanceInfo(ctx context.Context, req BaishunBalanceInfoRequest, rawJSON string) baishunBalanceInfoResponse { + if !s.validateCommonSignature(ctx, req.AppID, req.AppChannel, req.Signature, req.SignatureNonce, req.Timestamp) { + return baishunBalanceInfoResponse{Code: baishunCodeSignatureError, Msg: "signature error"} + } + userID, _ := strconv.ParseInt(req.UserID, 10, 64) + balance := s.readUserBalance(ctx, userID) + resp := baishunBalanceInfoResponse{ + Code: 0, + Msg: "success", + Data: map[string]any{"cur_coin": balance}, + } + s.saveCallbackLog(ctx, "balance-info", rawJSON, mustJSONString(resp), "", req.UserID, nil, nil, baishunCallbackStatusSuccess, 0, resp.Msg) + return resp +} + +func (s *BaishunService) listRoomGames(ctx context.Context, sysOrigin, roomID, category string) ([]RoomGameListItem, error) { + var rows []gameListRow + query := s.repo.DB.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + cfg.game_id AS internal_game_id, + cfg.name AS name, + cfg.category AS category, + cfg.cover AS cover, + cfg.sort AS sort, + cfg.full_screen AS full_screen, + cfg.game_mode AS game_mode_raw, + ext.vendor_type AS vendor_type, + ext.vendor_game_id AS vendor_game_id, + ext.launch_mode AS launch_mode, + ext.package_version AS ext_package_version, + ext.preview_url AS ext_preview_url, + ext.package_url AS package_url, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + cat.name AS catalog_name, + cat.cover AS catalog_cover, + cat.preview_url AS catalog_preview_url, + cat.download_url AS catalog_download_url, + cat.package_version AS catalog_package_version, + cat.orientation AS catalog_orientation, + cat.safe_height AS catalog_safe_height, + cat.status AS catalog_status + `). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). + Where("cfg.sys_origin = ? AND cfg.is_showcase = 1 AND ext.vendor_type = ?", sysOrigin, baishunVendorType) + if strings.TrimSpace(category) != "" { + query = query.Where("cfg.category = ?", category) + } + if strings.TrimSpace(roomID) != "" { + query = query.Where("cfg.game_mode LIKE ?", "%CHAT_ROOM%") + } + if err := query.Order("cfg.sort DESC").Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]RoomGameListItem, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toListItem()) + } + if len(items) > 0 { + return items, nil + } + + var catalogs []model.BaishunGameCatalog + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND status = ?", sysOrigin, baishunCatalogEnabled). + Order("vendor_game_id ASC"). + Find(&catalogs).Error; err != nil { + return nil, err + } + for _, catalog := range catalogs { + gameID := defaultIfBlank(catalog.InternalGameID, fmt.Sprintf("bs_%d", catalog.VendorGameID)) + items = append(items, RoomGameListItem{ + GameID: gameID, + VendorType: baishunVendorType, + VendorGameID: catalog.VendorGameID, + Name: catalog.Name, + Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), + Category: "CHAT_ROOM", + Sort: int64(catalog.VendorGameID), + LaunchMode: baishunLaunchModeRemote, + FullScreen: true, + GameMode: pickFirstInt(catalog.GameModeJSON, 3), + SafeHeight: catalog.SafeHeight, + Orientation: derefInt(catalog.Orientation), + PackageVersion: catalog.PackageVersion, + Status: catalog.Status, + }) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Sort == items[j].Sort { + return items[i].VendorGameID < items[j].VendorGameID + } + return items[i].Sort > items[j].Sort + }) + return items, nil +} + +func (s *BaishunService) findGameRow(ctx context.Context, sysOrigin, gameID string) (gameListRow, error) { + var row gameListRow + err := s.repo.DB.WithContext(ctx). + Table("sys_game_list_config AS cfg"). + Select(` + cfg.id AS config_id, + cfg.sys_origin AS sys_origin, + cfg.game_id AS internal_game_id, + cfg.name AS name, + cfg.category AS category, + cfg.cover AS cover, + cfg.sort AS sort, + cfg.full_screen AS full_screen, + cfg.game_mode AS game_mode_raw, + ext.vendor_type AS vendor_type, + ext.vendor_game_id AS vendor_game_id, + ext.launch_mode AS launch_mode, + ext.package_version AS ext_package_version, + ext.preview_url AS ext_preview_url, + ext.package_url AS package_url, + ext.orientation AS ext_orientation, + ext.safe_height AS ext_safe_height, + ext.currency_icon AS currency_icon, + ext.gsp AS ext_gsp, + cat.name AS catalog_name, + cat.cover AS catalog_cover, + cat.preview_url AS catalog_preview_url, + cat.download_url AS catalog_download_url, + cat.package_version AS catalog_package_version, + cat.orientation AS catalog_orientation, + cat.safe_height AS catalog_safe_height, + cat.status AS catalog_status + `). + Joins("JOIN sys_game_list_vendor_ext ext ON ext.game_list_config_id = cfg.id AND ext.enabled = 1"). + Joins("LEFT JOIN baishun_game_catalog cat ON cat.sys_origin = cfg.sys_origin AND CAST(cat.vendor_game_id AS CHAR) = ext.vendor_game_id"). + Where("cfg.sys_origin = ? AND cfg.game_id = ? AND ext.vendor_type = ?", sysOrigin, gameID, baishunVendorType). + Limit(1). + Scan(&row).Error + if err != nil { + return gameListRow{}, err + } + if strings.TrimSpace(row.InternalGameID) != "" { + return row, nil + } + + var catalog model.BaishunGameCatalog + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND internal_game_id = ?", sysOrigin, gameID). + First(&catalog).Error; err == nil { + return gameListRow{ + SysOrigin: sysOrigin, + InternalGameID: catalog.InternalGameID, + Name: catalog.Name, + Cover: catalog.Cover, + VendorType: baishunVendorType, + VendorGameID: strconv.Itoa(catalog.VendorGameID), + LaunchMode: baishunLaunchModeRemote, + CatalogCover: catalog.Cover, + CatalogPreviewURL: catalog.PreviewURL, + CatalogDownloadURL: catalog.DownloadURL, + CatalogPackageVersion: catalog.PackageVersion, + CatalogSafeHeight: catalog.SafeHeight, + CatalogOrientation: catalog.Orientation, + CatalogStatus: catalog.Status, + GameModeRaw: catalog.GameModeJSON, + }, nil + } + return gameListRow{}, NewAppError(http.StatusNotFound, "game_not_found", "room game config not found") +} + +func (s *BaishunService) fetchPlatformGames(ctx context.Context) ([]baishunPlatformGame, error) { + timestamp := time.Now().Unix() + nonce := s.randomNonce() + payload := map[string]any{ + "app_channel": s.cfg.BaishunAppChannel, + "app_id": s.cfg.BaishunAppID, + "signature": s.signBAISHUN(timestamp, nonce), + "signature_nonce": nonce, + "timestamp": timestamp, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.BaishunPlatformBaseURL, "/")+"/v1/api/gamelist", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("baishun platform failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var result baishunPlatformResponse + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + if result.Code != 0 { + return nil, fmt.Errorf("baishun platform failed: code=%d message=%s", result.Code, result.Message) + } + return result.Data, nil +} + +func (s *BaishunService) validateCommonSignature(ctx context.Context, appID int64, appChannel, signature, nonce string, timestamp int64) bool { + if s.cfg.BaishunAppID != 0 && appID != 0 && appID != s.cfg.BaishunAppID { + return false + } + if strings.TrimSpace(appChannel) != "" && s.cfg.BaishunAppChannel != "" && appChannel != s.cfg.BaishunAppChannel { + return false + } + if strings.TrimSpace(signature) == "" || strings.TrimSpace(nonce) == "" || timestamp == 0 || strings.TrimSpace(s.cfg.BaishunAppKey) == "" { + return false + } + if abs64(time.Now().Unix()-timestamp) > 15 { + return false + } + expected := s.signBAISHUN(timestamp, nonce) + if !strings.EqualFold(expected, signature) { + return false + } + return s.rememberNonce(ctx, nonce) +} + +func (s *BaishunService) rememberNonce(ctx context.Context, nonce string) bool { + ok, err := s.repo.Redis.SetNX(ctx, "bs:sign:nonce:"+nonce, "1", 15*time.Second).Result() + return err == nil && ok +} + +func (s *BaishunService) signBAISHUN(timestamp int64, nonce string) string { + sum := md5.Sum([]byte(nonce + s.cfg.BaishunAppKey + strconv.FormatInt(timestamp, 10))) + return hex.EncodeToString(sum[:]) +} + +func (s *BaishunService) randomNonce() string { + token, err := s.randomToken(8) + if err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return token +} + +func (s *BaishunService) randomToken(size int) (string, error) { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func (s *BaishunService) uniqueID() string { + id, err := util.NextID() + if err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return strconv.FormatInt(id, 10) +} + +func (s *BaishunService) readUserInfo(ctx context.Context, userID int64) integration.UserProfile { + profile, err := s.java.GetUserProfile(ctx, userID) + if err != nil { + return integration.UserProfile{ID: integration.Int64Value(userID)} + } + return profile +} + +func (s *BaishunService) readUserBalance(ctx context.Context, userID int64) int64 { + balanceMap, err := s.java.MapGoldBalance(ctx, []int64{userID}) + if err != nil { + return 0 + } + return balanceMap[userID] +} + +func (s *BaishunService) resolveLanguage(ctx context.Context, userID int64) string { + language, err := s.java.GetUserLanguage(ctx, userID) + if err != nil || strings.TrimSpace(language) == "" { + return "0" + } + return language +} + +func (s *BaishunService) findSessionByToken(ctx context.Context, ssToken, userID string) (model.BaishunLaunchSession, bool) { + var session model.BaishunLaunchSession + err := s.repo.DB.WithContext(ctx). + Where("ss_token = ?", strings.TrimSpace(ssToken)). + First(&session).Error + if err != nil || time.Now().After(derefTime(session.SSTokenExpireTime)) { + return model.BaishunLaunchSession{}, false + } + if strings.TrimSpace(userID) != "" && strconv.FormatInt(session.UserID, 10) != strings.TrimSpace(userID) { + return model.BaishunLaunchSession{}, false + } + return session, true +} + +func (s *BaishunService) persistOrderRecord(ctx context.Context, sysOrigin string, req BaishunChangeBalanceRequest, walletEventID, status, requestJSON, responseJSON string, assetRecordID *int64) { + id, err := util.NextID() + if err != nil { + return + } + record := model.BaishunOrderIdempotency{ + ID: id, + SysOrigin: sysOrigin, + OrderID: req.OrderID, + GameRoundID: req.GameRoundID, + RoomID: req.RoomID, + UserID: parseInt64Default(req.UserID), + VendorGameID: req.GameID, + CurrencyDiff: req.CurrencyDiff, + DiffMsg: req.DiffMsg, + MsgType: req.MsgType, + CurrencyType: req.CurrencyType, + WalletEventID: walletEventID, + WalletAssetRecordID: assetRecordID, + Status: status, + RequestJSON: requestJSON, + ResponseJSON: responseJSON, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + _ = s.repo.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "order_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "wallet_event_id", "wallet_asset_record_id", "status", "request_json", "response_json", "update_time", + }), + }).Create(&record).Error +} + +func (s *BaishunService) saveCallbackLog(ctx context.Context, requestType, requestJSON, responseJSON, sysOrigin, userID string, orderID, gameRoundID *string, status string, bizCode int, bizMessage string) { + id, err := util.NextID() + if err != nil { + return + } + logRecord := model.BaishunCallbackLog{ + ID: id, + SysOrigin: sysOrigin, + RequestType: requestType, + RequestJSON: requestJSON, + ResponseJSON: responseJSON, + BizCode: strconv.Itoa(bizCode), + BizMessage: bizMessage, + Status: status, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if orderID != nil { + logRecord.OrderID = *orderID + } + if gameRoundID != nil { + logRecord.GameRoundID = *gameRoundID + } + if strings.TrimSpace(userID) != "" { + parsed := parseInt64Default(userID) + logRecord.UserID = &parsed + } + _ = s.repo.DB.WithContext(ctx).Create(&logRecord).Error +} + +func (s *BaishunService) failStandard(code int, message string) baishunStandardResponse { + return baishunStandardResponse{ + Code: code, + Message: defaultIfBlank(message, "failed"), + UniqueID: s.uniqueID(), + } +} + +func (r gameListRow) toListItem() RoomGameListItem { + return RoomGameListItem{ + GameID: r.InternalGameID, + VendorType: defaultIfBlank(r.VendorType, baishunVendorType), + VendorGameID: r.vendorGameIDInt(), + Name: r.name(), + Cover: r.cover(), + Category: r.Category, + Sort: r.Sort, + LaunchMode: r.launchMode(), + FullScreen: r.FullScreen, + GameMode: r.gameMode(), + SafeHeight: r.safeHeight(), + Orientation: r.orientation(), + PackageVersion: r.packageVersion(), + Status: defaultIfBlank(r.CatalogStatus, baishunCatalogEnabled), + } +} + +func (r gameListRow) name() string { + return defaultIfBlank(r.Name, r.CatalogName) +} + +func (r gameListRow) cover() string { + return defaultIfBlank(r.Cover, defaultIfBlank(r.CatalogCover, r.previewURL())) +} + +func (r gameListRow) previewURL() string { + return defaultIfBlank(r.ExtPreviewURL, r.CatalogPreviewURL) +} + +func (r gameListRow) downloadURL() string { + return defaultIfBlank(r.PackageURL, r.CatalogDownloadURL) +} + +func (r gameListRow) entryURL() string { + switch r.launchMode() { + case baishunLaunchModeLocalZip: + if strings.TrimSpace(r.PackageURL) != "" { + return r.PackageURL + } + } + if strings.TrimSpace(r.CatalogDownloadURL) != "" { + return r.CatalogDownloadURL + } + return defaultIfBlank(r.PackageURL, defaultIfBlank(r.ExtPreviewURL, "mock://baishun")) +} + +func (r gameListRow) packageVersion() string { + return defaultIfBlank(r.ExtPackageVersion, r.CatalogPackageVersion) +} + +func (r gameListRow) safeHeight() int { + if r.ExtSafeHeight > 0 { + return r.ExtSafeHeight + } + return r.CatalogSafeHeight +} + +func (r gameListRow) orientation() int { + if r.ExtOrientation != nil { + return *r.ExtOrientation + } + return derefInt(r.CatalogOrientation) +} + +func (r gameListRow) launchMode() string { + return defaultIfBlank(r.LaunchMode, baishunLaunchModeRemote) +} + +func (r gameListRow) gameMode() int { + if parsed := pickFirstInt(r.GameModeRaw, 3); parsed > 0 { + return parsed + } + return 3 +} + +func (r gameListRow) vendorGameIDInt() int { + parsed, _ := strconv.Atoi(strings.TrimSpace(r.VendorGameID)) + return parsed +} + +func (r gameListRow) currencyIcon() string { + return r.CurrencyIcon +} + +func (s *BaishunService) resolveGSP(row gameListRow) int { + if parsed, err := strconv.Atoi(strings.TrimSpace(row.ExtGSP)); err == nil && parsed > 0 { + return parsed + } + if s.cfg.BaishunGSP > 0 { + return s.cfg.BaishunGSP + } + return 101 +} + +func defaultIfBlank(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +func pickFirstInt(raw string, fallback int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + var list []int + if strings.HasPrefix(raw, "[") { + if json.Unmarshal([]byte(raw), &list) == nil && len(list) > 0 { + return list[len(list)-1] + } + } + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + return parsed + } + return fallback +} + +func receiptTypeFromDiff(diff int64) string { + if diff >= 0 { + return "INCOME" + } + return "EXPENDITURE" +} + +func mustJSONString(value interface{}) string { + data, err := json.Marshal(value) + if err != nil { + return "" + } + return string(data) +} + +func abs64(value int64) int64 { + if value < 0 { + return -value + } + return value +} + +func parseInt64Default(value string) int64 { + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed +} + +func sanitizeRoomID(roomID string) string { + roomID = strings.ReplaceAll(strings.TrimSpace(roomID), " ", "_") + if roomID == "" { + return "room" + } + return roomID +} + +func intPtr(value int) *int { + return &value +} + +func derefInt(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func derefTime(value *time.Time) time.Time { + if value == nil { + return time.Unix(0, 0) + } + return *value +} + +func stringPtr(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + return &value +} + +func toString(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + default: + return fmt.Sprint(typed) + } +} diff --git a/internal/service/invite.go b/internal/service/invite.go new file mode 100644 index 0000000..cdbb2d5 --- /dev/null +++ b/internal/service/invite.go @@ -0,0 +1,1527 @@ +package service + +import ( + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/repo" + "chatapp3-golang/internal/util" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + defaultTimezone = "Asia/Riyadh" + defaultShareTitle = "Invite Friends, Earn Big Rewards!" + defaultShareDesc = "Invite friends to join Party App and unlock exclusive rewards." + defaultValidRechargeCoins = int64(80000) + rewardLogStatusPending = "PENDING" + rewardLogStatusSuccess = "SUCCESS" + rewardLogStatusFailed = "FAILED" + relationStatusEligible = "ELIGIBLE" + relationStatusBlocked = "BLOCKED" + blockReasonDuplicateIP = "DUPLICATE_IP" + blockReasonDuplicateDevice = "DUPLICATE_DEVICE" + ruleTypeInviterBind = "INVITER_BIND" + ruleTypeInviteeBind = "INVITEE_BIND" + ruleTypeInviteeRecharge = "INVITEE_RECHARGE" + ruleTypeInviterValidCount = "INVITER_VALID_COUNT" + ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" +) + +var errAlreadyProcessed = errors.New("invite recharge already processed") + +type AppError struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *AppError) Error() string { + return e.Message +} + +func NewAppError(status int, code, message string) *AppError { + return &AppError{Status: status, Code: code, Message: message} +} + +type AuthUser struct { + UserID int64 + SysOrigin string + Token string + Authorization string +} + +type RewardView struct { + RuleID int64 `json:"ruleId,omitempty"` + RuleType string `json:"ruleType,omitempty"` + Threshold int64 `json:"threshold,omitempty"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + ValidThreshold int64 `json:"validThreshold,omitempty"` +} + +type TaskView struct { + RuleID int64 `json:"ruleId"` + RuleType string `json:"ruleType"` + Threshold int64 `json:"threshold"` + Progress int64 `json:"progress"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + Achieved bool `json:"achieved"` + Claimed bool `json:"claimed"` +} + +type InviteStats struct { + TotalInvitedUsers int64 `json:"totalInvitedUsers"` + TotalValidUsers int64 `json:"totalValidUsers"` + TotalRechargeCoins int64 `json:"totalRechargeCoins"` + RewardGoldCoins int64 `json:"rewardGoldCoins"` +} + +type HomeResponse struct { + CampaignEnabled bool `json:"campaignEnabled"` + SysOrigin string `json:"sysOrigin"` + InviteCode string `json:"inviteCode"` + InviteLink string `json:"inviteLink"` + ShareTitle string `json:"shareTitle"` + ShareDesc string `json:"shareDesc"` + DownloadURL string `json:"downloadUrl"` + ServiceContact string `json:"serviceContact"` + Timezone string `json:"timezone"` + MonthKey string `json:"monthKey"` + ValidUserThreshold int64 `json:"validUserThreshold"` + InviterBindReward RewardView `json:"inviterBindReward"` + InviteeBindReward RewardView `json:"inviteeBindReward"` + InviteeRecharge []RewardView `json:"inviteeRechargeRules"` + ValidCountTasks []TaskView `json:"inviterValidCountTasks"` + TotalRechargeTasks []TaskView `json:"inviterTotalRechargeTasks"` + MonthlyProgress struct { + InviteCount int64 `json:"inviteCount"` + ValidUserCount int64 `json:"validUserCount"` + TotalRecharge int64 `json:"totalRechargeCoins"` + RewardGoldCoins int64 `json:"rewardGoldCoins"` + } `json:"monthlyProgress"` + Stats InviteStats `json:"stats"` +} + +type LandingResponse struct { + CampaignEnabled bool `json:"campaignEnabled"` + SysOrigin string `json:"sysOrigin"` + InviterUserID int64 `json:"inviterUserId"` + InviterNickname string `json:"inviterNickname"` + InviterAvatar string `json:"inviterAvatar"` + InviteCode string `json:"inviteCode"` + DownloadURL string `json:"downloadUrl"` + ShareTitle string `json:"shareTitle"` + ShareDesc string `json:"shareDesc"` + InviteeReward RewardView `json:"inviteeReward"` +} + +type BindCodeRequest struct { + InviteCode string `json:"inviteCode"` +} + +type BindCodeResponse struct { + RelationID int64 `json:"relationId"` + Eligible bool `json:"eligible"` + BlockReason string `json:"blockReason,omitempty"` + InviterUserID int64 `json:"inviterUserId"` + InviteCode string `json:"inviteCode"` + InviterReward RewardView `json:"inviterReward"` + InviteeReward RewardView `json:"inviteeReward"` +} + +type TaskClaimRequest struct { + RuleID int64 `json:"ruleId"` +} + +type TaskClaimResponse struct { + Claimed bool `json:"claimed"` + AlreadyClaimed bool `json:"alreadyClaimed"` + RuleID int64 `json:"ruleId"` + RuleType string `json:"ruleType"` + Progress int64 `json:"progress"` + Threshold int64 `json:"threshold"` + Reward RewardView `json:"reward"` +} + +type RechargeSuccessRequest struct { + OrderID string `json:"orderId"` + UserID int64 `json:"userId"` + RechargeCoins int64 `json:"rechargeCoins"` + PayTime any `json:"payTime"` + SysOrigin string `json:"sysOrigin"` +} + +type RechargeSuccessResponse struct { + Processed bool `json:"processed"` + AlreadyProcessed bool `json:"alreadyProcessed"` + HasInviteRelation bool `json:"hasInviteRelation"` + Eligible bool `json:"eligible"` + RelationID int64 `json:"relationId,omitempty"` + InviterUserID int64 `json:"inviterUserId,omitempty"` + TotalRechargeCoins int64 `json:"totalRechargeCoins,omitempty"` + ValidUser bool `json:"validUser"` + NewlyRewardedRuleIDs []int64 `json:"newlyRewardedRuleIds,omitempty"` +} + +type rewardDispatchInput struct { + SysOrigin string + MonthKey string + RelationID *int64 + InviterUserID *int64 + InviteeUserID *int64 + TargetUserID int64 + Reward RewardView + Scene string + RuleID *int64 + BusinessKey string + GoldOrigin string + PropsOrigin string + Remark string +} + +type rewardDispatchResult struct { + Success bool +} + +type campaignBundle struct { + Config model.InviteCampaignConfig `json:"config"` + Rules []model.InviteCampaignRewardRule `json:"rules"` +} + +type InviteService struct { + cfg config.Config + repo *repo.Repository + java *integration.Client +} + +func NewInviteService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *InviteService { + return &InviteService{ + cfg: cfg, + repo: repository, + java: javaClient, + } +} + +func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + inviteCode, err := s.ensureInviteCode(ctx, user) + if err != nil { + return nil, err + } + + now := time.Now() + timezone := normalizeTimezone(bundle.Config.Timezone) + monthKey := monthKeyAt(now, timezone) + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + stats, err := s.loadStats(ctx, user.SysOrigin, user.UserID) + if err != nil { + return nil, err + } + claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + + inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) + validCountRules := rulesByType(bundle.Rules, ruleTypeInviterValidCount) + totalRechargeRules := rulesByType(bundle.Rules, ruleTypeInviterTotalRecharge) + + resp := &HomeResponse{ + CampaignEnabled: bundle.Config.Enabled, + SysOrigin: user.SysOrigin, + InviteCode: inviteCode, + InviteLink: s.cfg.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(inviteCode), + ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), + ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), + DownloadURL: bundle.Config.DownloadURL, + ServiceContact: bundle.Config.ServiceContact, + Timezone: timezone, + MonthKey: monthKey, + ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), + InviterBindReward: rewardFromRule(inviterBindRule), + InviteeBindReward: rewardFromRule(inviteeBindRule), + InviteeRecharge: rewardsFromRules(inviteeRechargeRules), + Stats: stats, + } + resp.MonthlyProgress.InviteCount = monthly.InviteCount + resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount + resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge + resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins + resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) + resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) + return resp, nil +} + +func (s *InviteService) GetLanding(ctx context.Context, inviteCode string) (*LandingResponse, error) { + inviteCode = strings.TrimSpace(inviteCode) + if inviteCode == "" { + return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") + } + mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(404, "invite_code_not_found", "invite code not found") + } + return nil, err + } + inviter, err := s.findUserBaseInfo(ctx, mapping.UserID) + if err != nil { + return nil, err + } + bundle, err := s.loadBundle(ctx, inviter.OriginSys) + if err != nil { + return nil, err + } + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + return &LandingResponse{ + CampaignEnabled: bundle.Config.Enabled, + SysOrigin: inviter.OriginSys, + InviterUserID: inviter.ID, + InviterNickname: inviter.UserNickname, + InviterAvatar: inviter.UserAvatar, + InviteCode: mapping.InviteCode, + DownloadURL: bundle.Config.DownloadURL, + ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), + ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), + InviteeReward: rewardFromRule(inviteeBindRule), + }, nil +} + +func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) { + inviteCode := strings.TrimSpace(req.InviteCode) + if inviteCode == "" { + return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") + } + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + if !bundle.Config.Enabled { + return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled") + } + + mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NewAppError(404, "invite_code_not_found", "invite code not found") + } + return nil, err + } + if mapping.UserID == user.UserID { + return nil, NewAppError(400, "self_bind_not_allowed", "cannot bind your own invite code") + } + + inviterProfile, err := s.findUserBaseInfo(ctx, mapping.UserID) + if err != nil { + return nil, err + } + if inviterProfile.OriginSys != "" && inviterProfile.OriginSys != user.SysOrigin { + return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system") + } + + fingerprint := "" + if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { + fingerprint = strings.TrimSpace(value) + } + lockKeys := []string{ + fmt.Sprintf("invite:bind:lock:user:%s:%d", user.SysOrigin, user.UserID), + } + if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { + lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:ip:%s:%s", user.SysOrigin, strings.TrimSpace(clientIP))) + } + if bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { + lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:device:%s:%s", user.SysOrigin, fingerprint)) + } + + var ( + relationID int64 + eligibleStatus = relationStatusEligible + blockReason string + monthKey string + ) + err = s.withBindLocks(ctx, lockKeys, func() error { + if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { + return NewAppError(400, "already_bound", "invite code already bound") + } else if !errors.Is(lockErr, gorm.ErrRecordNotFound) { + return lockErr + } + + bindTime := time.Now() + monthKey = monthKeyAt(bindTime, normalizeTimezone(bundle.Config.Timezone)) + if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" { + exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP) + if existsErr != nil { + return existsErr + } + if exists { + eligibleStatus = relationStatusBlocked + blockReason = blockReasonDuplicateIP + } + } + if eligibleStatus == relationStatusEligible && bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" { + exists, existsErr := s.existsEligibleRelationByDevice(ctx, user.SysOrigin, fingerprint) + if existsErr != nil { + return existsErr + } + if exists { + eligibleStatus = relationStatusBlocked + blockReason = blockReasonDuplicateDevice + } + } + + nextRelationID, idErr := util.NextID() + if idErr != nil { + return idErr + } + progressID, idErr := util.NextID() + if idErr != nil { + return idErr + } + relationID = nextRelationID + relation := model.InviteCampaignRelation{ + ID: relationID, + SysOrigin: user.SysOrigin, + InviterUserID: mapping.UserID, + InviteeUserID: user.UserID, + InviteCode: inviteCode, + BindIP: strings.TrimSpace(clientIP), + DeviceFingerprint: fingerprint, + EligibleStatus: eligibleStatus, + BlockReason: blockReason, + BindTime: bindTime, + CreateTime: bindTime, + UpdateTime: bindTime, + } + progress := model.InviteCampaignInviteeProgress{ + ID: progressID, + RelationID: relationID, + SysOrigin: user.SysOrigin, + InviterUserID: mapping.UserID, + InviteeUserID: user.UserID, + TotalRecharge: 0, + ValidUser: false, + RewardedRuleIDs: "", + CreateTime: bindTime, + UpdateTime: bindTime, + } + + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&relation).Error; err != nil { + if isInviteRelationDuplicateErr(err) { + return NewAppError(400, "already_bound", "invite code already bound") + } + return err + } + if err := tx.Create(&progress).Error; err != nil { + return err + } + if eligibleStatus == relationStatusEligible { + if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.InviteCount += 1 + }); err != nil { + return err + } + } + return nil + }) + }) + if err != nil { + return nil, err + } + + inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) + inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) + if eligibleStatus == relationStatusEligible { + inviterUserID := mapping.UserID + inviteeUserID := user.UserID + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + MonthKey: monthKey, + RelationID: refInt64(relationID), + InviterUserID: &inviterUserID, + InviteeUserID: &inviteeUserID, + TargetUserID: mapping.UserID, + Reward: rewardFromRule(inviterBindRule), + Scene: ruleTypeInviterBind, + RuleID: optionalRuleID(inviterBindRule), + BusinessKey: fmt.Sprintf("bind:inviter:%d", relationID), + GoldOrigin: "INVITED_NEW_USER_REWARD", + PropsOrigin: "INVITE_USER_REWARDS", + Remark: "resident invite inviter bind reward", + }) + if err != nil { + return nil, err + } + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + RelationID: refInt64(relationID), + InviterUserID: &inviterUserID, + InviteeUserID: &inviteeUserID, + TargetUserID: user.UserID, + Reward: rewardFromRule(inviteeBindRule), + Scene: ruleTypeInviteeBind, + RuleID: optionalRuleID(inviteeBindRule), + BusinessKey: fmt.Sprintf("bind:invitee:%d", relationID), + GoldOrigin: "INVITE_USER_REWARDS", + PropsOrigin: "INVITE_USER_REWARDS", + Remark: "resident invite invitee bind reward", + }) + if err != nil { + return nil, err + } + } + + return &BindCodeResponse{ + RelationID: relationID, + Eligible: eligibleStatus == relationStatusEligible, + BlockReason: blockReason, + InviterUserID: mapping.UserID, + InviteCode: inviteCode, + InviterReward: rewardFromRule(inviterBindRule), + InviteeReward: rewardFromRule(inviteeBindRule), + }, nil +} + +func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskClaimRequest) (*TaskClaimResponse, error) { + if req.RuleID == 0 { + return nil, NewAppError(400, "invalid_rule_id", "ruleId is required") + } + bundle, err := s.loadBundle(ctx, user.SysOrigin) + if err != nil { + return nil, err + } + rule := findRuleByID(bundle.Rules, req.RuleID) + if rule == nil || !rule.Enabled { + return nil, NewAppError(404, "rule_not_found", "rule not found") + } + if rule.RuleType != ruleTypeInviterValidCount && rule.RuleType != ruleTypeInviterTotalRecharge { + return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") + } + + monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) + claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID) + if err != nil { + return nil, err + } + monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) + if err != nil { + return nil, err + } + progressValue := monthly.TotalRecharge + if rule.RuleType == ruleTypeInviterValidCount { + progressValue = monthly.ValidUserCount + } + if claimed { + return &TaskClaimResponse{ + Claimed: true, + AlreadyClaimed: true, + RuleID: rule.ID, + RuleType: rule.RuleType, + Progress: progressValue, + Threshold: rule.ThresholdValue, + Reward: rewardFromRule(rule), + }, nil + } + if progressValue < rule.ThresholdValue { + return nil, NewAppError(400, "task_not_reached", "task threshold not reached") + } + + _, err = s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: user.SysOrigin, + MonthKey: monthKey, + InviterUserID: refInt64(user.UserID), + TargetUserID: user.UserID, + Reward: rewardFromRule(rule), + Scene: rule.RuleType, + RuleID: refInt64(rule.ID), + BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, monthKey, rule.ID), + GoldOrigin: mapGoldOrigin(rule.RuleType), + PropsOrigin: mapPropsOrigin(rule.RuleType), + Remark: "resident invite monthly task reward", + }) + if err != nil { + return nil, err + } + + now := time.Now() + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, monthKey, rule.ID, now); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + + return &TaskClaimResponse{ + Claimed: true, + AlreadyClaimed: false, + RuleID: rule.ID, + RuleType: rule.RuleType, + Progress: progressValue, + Threshold: rule.ThresholdValue, + Reward: rewardFromRule(rule), + }, nil +} + +func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeSuccessRequest) (*RechargeSuccessResponse, error) { + req.OrderID = strings.TrimSpace(req.OrderID) + req.SysOrigin = strings.TrimSpace(req.SysOrigin) + if req.OrderID == "" { + return nil, NewAppError(400, "invalid_order_id", "orderId is required") + } + if req.UserID == 0 { + return nil, NewAppError(400, "invalid_user_id", "userId is required") + } + if req.RechargeCoins <= 0 { + return nil, NewAppError(400, "invalid_recharge", "rechargeCoins must be greater than 0") + } + if req.SysOrigin == "" { + return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required") + } + + bundle, err := s.loadBundle(ctx, req.SysOrigin) + if err != nil { + return nil, err + } + payTime, err := parseFlexibleTime(req.PayTime) + if err != nil { + return nil, NewAppError(400, "invalid_pay_time", err.Error()) + } + monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone)) + inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) + validThreshold := effectiveValidThreshold(inviteeRechargeRules) + + var ( + relation model.InviteCampaignRelation + progress model.InviteCampaignInviteeProgress + crossedRules []model.InviteCampaignRewardRule + oldRewardedIDs map[int64]struct{} + hasRelation bool + eligible bool + becameValid bool + inviterUserID int64 + relationID int64 + newTotal int64 + ) + + err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.InviteCampaignRechargeEvent + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("order_id = ?", req.OrderID). + First(&existing).Error + if err == nil { + return errAlreadyProcessed + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND invitee_user_id = ?", req.SysOrigin, req.UserID). + First(&relation).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hasRelation = err == nil + eligible = hasRelation && relation.EligibleStatus == relationStatusEligible + + eventID, idErr := util.NextID() + if idErr != nil { + return idErr + } + rechargeEvent := model.InviteCampaignRechargeEvent{ + ID: eventID, + SysOrigin: req.SysOrigin, + OrderID: req.OrderID, + InviteeUserID: req.UserID, + RechargeCoins: req.RechargeCoins, + PayTime: payTime, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if hasRelation { + rechargeEvent.InviterUserID = refInt64(relation.InviterUserID) + } + if err := tx.Create(&rechargeEvent).Error; err != nil { + return err + } + if !hasRelation { + return nil + } + + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("relation_id = ?", relation.ID). + First(&progress).Error + if err != nil { + return err + } + + oldTotal := progress.TotalRecharge + newTotal = oldTotal + req.RechargeCoins + oldRewardedIDs = parseRewardedRuleIDs(progress.RewardedRuleIDs) + crossedRules = crossedInviteeRechargeRules(inviteeRechargeRules, oldTotal, newTotal, oldRewardedIDs) + progress.TotalRecharge = newTotal + progress.UpdateTime = time.Now() + if eligible && !progress.ValidUser && newTotal >= validThreshold { + progress.ValidUser = true + progress.ValidTime = refTime(payTime) + becameValid = true + } + if err := tx.Save(&progress).Error; err != nil { + return err + } + if eligible { + if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.TotalRecharge += req.RechargeCoins + if becameValid { + item.ValidUserCount += 1 + } + }); err != nil { + return err + } + } + + inviterUserID = relation.InviterUserID + relationID = relation.ID + return nil + }) + if err != nil { + if errors.Is(err, errAlreadyProcessed) { + return &RechargeSuccessResponse{ + Processed: true, + AlreadyProcessed: true, + }, nil + } + return nil, err + } + if !hasRelation { + return &RechargeSuccessResponse{ + Processed: true, + HasInviteRelation: false, + }, nil + } + + successfulRuleIDs := make([]int64, 0, len(crossedRules)) + if eligible { + for _, rule := range crossedRules { + _, err := s.dispatchReward(ctx, rewardDispatchInput{ + SysOrigin: req.SysOrigin, + MonthKey: monthKey, + RelationID: refInt64(relationID), + InviterUserID: refInt64(inviterUserID), + InviteeUserID: refInt64(req.UserID), + TargetUserID: inviterUserID, + Reward: rewardFromRule(&rule), + Scene: ruleTypeInviteeRecharge, + RuleID: refInt64(rule.ID), + BusinessKey: fmt.Sprintf("recharge:%d:%d", relationID, rule.ID), + GoldOrigin: mapGoldOrigin(ruleTypeInviteeRecharge), + PropsOrigin: mapPropsOrigin(ruleTypeInviteeRecharge), + Remark: "resident invite recharge reward", + }) + if err != nil { + return nil, err + } + successfulRuleIDs = append(successfulRuleIDs, rule.ID) + } + if len(successfulRuleIDs) > 0 { + if err := s.mergeRewardedRuleIDs(ctx, relationID, successfulRuleIDs); err != nil { + return nil, err + } + } + } + + return &RechargeSuccessResponse{ + Processed: true, + AlreadyProcessed: false, + HasInviteRelation: true, + Eligible: eligible, + RelationID: relationID, + InviterUserID: inviterUserID, + TotalRechargeCoins: newTotal, + ValidUser: progress.ValidUser, + NewlyRewardedRuleIDs: successfulRuleIDs, + }, nil +} + +func (s *InviteService) loadBundle(ctx context.Context, sysOrigin string) (*campaignBundle, error) { + sysOrigin = normalizeSysOrigin(sysOrigin) + cacheKey := "invite:campaign:config:" + sysOrigin + if raw, err := s.repo.Redis.Get(ctx, cacheKey).Result(); err == nil && raw != "" { + var bundle campaignBundle + if jsonErr := json.Unmarshal([]byte(raw), &bundle); jsonErr == nil { + return &bundle, nil + } + } + + bundle := &campaignBundle{ + Config: model.InviteCampaignConfig{ + SysOrigin: sysOrigin, + Enabled: false, + Timezone: defaultTimezone, + AntiCheatSameIPOnce: true, + AntiCheatSameDeviceOnce: true, + }, + } + var configEntity model.InviteCampaignConfig + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + First(&configEntity).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if err == nil { + bundle.Config = configEntity + if strings.TrimSpace(bundle.Config.Timezone) == "" { + bundle.Config.Timezone = defaultTimezone + } + } + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ?", sysOrigin). + Order("sort asc, threshold_value asc, id asc"). + Find(&bundle.Rules).Error; err != nil { + return nil, err + } + payload, _ := json.Marshal(bundle) + s.repo.Redis.Set(ctx, cacheKey, payload, 0) + return bundle, nil +} + +func (s *InviteService) ensureInviteCode(ctx context.Context, user AuthUser) (string, error) { + if mapping, err := s.findInviteCodeMappingByUserID(ctx, user.UserID); err == nil { + return mapping.InviteCode, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + if user.Authorization == "" { + return "", NewAppError(401, "missing_authorization", "authorization is required to generate invite code") + } + resp, err := s.java.EnsureInviteCode(ctx, user.Authorization) + if err != nil { + return "", err + } + info, infoErr := s.findUserBaseInfo(ctx, user.UserID) + if infoErr != nil { + return "", infoErr + } + mapping := model.InviteCodeMapping{ + UserID: user.UserID, + InviteCode: resp.InviteCode, + Account: info.Account, + } + if err := s.repo.DB.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"invite_code", "account"}), + }). + Create(&mapping).Error; err != nil { + return "", err + } + return resp.InviteCode, nil +} + +func (s *InviteService) findInviteCodeMappingByUserID(ctx context.Context, userID int64) (*model.InviteCodeMapping, error) { + var mapping model.InviteCodeMapping + err := s.repo.DB.WithContext(ctx). + Where("user_id = ?", userID). + First(&mapping).Error + if err != nil { + return nil, err + } + return &mapping, nil +} + +func (s *InviteService) findInviteCodeMappingByCode(ctx context.Context, inviteCode string) (*model.InviteCodeMapping, error) { + var mapping model.InviteCodeMapping + err := s.repo.DB.WithContext(ctx). + Where("invite_code = ?", inviteCode). + First(&mapping).Error + if err != nil { + return nil, err + } + return &mapping, nil +} + +func (s *InviteService) findUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) { + var info model.UserBaseInfo + err := s.repo.DB.WithContext(ctx). + Where("id = ?", userID). + First(&info).Error + if err != nil { + return nil, err + } + return &info, nil +} + +func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin string, inviteeUserID int64) (*model.InviteCampaignRelation, error) { + var relation model.InviteCampaignRelation + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND invitee_user_id = ?", sysOrigin, inviteeUserID). + First(&relation).Error + if err != nil { + return nil, err + } + return &relation, nil +} + +func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error { + keys = uniqueSortedStrings(keys) + released := make([]string, 0, len(keys)) + for _, key := range keys { + acquired, err := s.acquireBindLock(ctx, key, 3*time.Second) + if err != nil { + s.releaseBindLocks(ctx, released) + return err + } + if !acquired { + s.releaseBindLocks(ctx, released) + return NewAppError(409, "bind_in_progress", "invite bind is in progress") + } + released = append(released, key) + } + defer s.releaseBindLocks(ctx, released) + return fn() +} + +func (s *InviteService) acquireBindLock(ctx context.Context, key string, ttl time.Duration) (bool, error) { + deadline := time.Now().Add(2 * time.Second) + for { + acquired, err := s.repo.Redis.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + return false, err + } + if acquired { + return true, nil + } + if time.Now().After(deadline) { + return false, nil + } + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) { + if len(keys) == 0 { + return + } + _ = s.repo.Redis.Del(ctx, keys...).Err() +} + +func uniqueSortedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result +} + +func isInviteRelationDuplicateErr(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(message, "duplicate entry") && strings.Contains(message, "invite_campaign_relation") +} + +func (s *InviteService) existsEligibleRelationByIP(ctx context.Context, sysOrigin, bindIP string) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND bind_ip = ? AND eligible_status = ?", sysOrigin, bindIP, relationStatusEligible). + Count(&count).Error + return count > 0, err +} + +func (s *InviteService) existsEligibleRelationByDevice(ctx context.Context, sysOrigin, fingerprint string) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND device_fingerprint = ? AND eligible_status = ?", sysOrigin, fingerprint, relationStatusEligible). + Count(&count).Error + return count > 0, err +} + +func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) { + var progress model.InviteCampaignMonthlyProgress + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + First(&progress).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &model.InviteCampaignMonthlyProgress{ + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + }, nil + } + if err != nil { + return nil, err + } + return &progress, nil +} + +func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviterUserID int64) (InviteStats, error) { + stats := InviteStats{} + if err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRelation{}). + Where("sys_origin = ? AND inviter_user_id = ? AND eligible_status = ?", sysOrigin, inviterUserID, relationStatusEligible). + Count(&stats.TotalInvitedUsers).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(COUNT(1), 0) + FROM invite_campaign_invitee_progress ip + INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id + WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? AND ip.valid_user = 1 + `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalValidUsers).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(ip.total_recharge_coins), 0) + FROM invite_campaign_invitee_progress ip + INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id + WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? + `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalRechargeCoins).Error; err != nil { + return stats, err + } + if err := s.repo.DB.WithContext(ctx).Raw(` + SELECT COALESCE(SUM(gold_amount), 0) + FROM invite_campaign_reward_log + WHERE sys_origin = ? AND inviter_user_id = ? AND target_user_id = ? AND status = ? + `, sysOrigin, inviterUserID, inviterUserID, rewardLogStatusSuccess).Scan(&stats.RewardGoldCoins).Error; err != nil { + return stats, err + } + return stats, nil +} + +func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) { + var claims []model.InviteCampaignTaskClaim + if err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + Find(&claims).Error; err != nil { + return nil, err + } + result := make(map[int64]bool, len(claims)) + for _, item := range claims { + result[item.RuleID] = true + } + return result, nil +} + +func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64) (bool, error) { + var count int64 + err := s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignTaskClaim{}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). + Count(&count).Error + return count > 0, err +} + +func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error { + var progress model.InviteCampaignMonthlyProgress + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). + First(&progress).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, idErr := util.NextID() + if idErr != nil { + return idErr + } + now := time.Now() + progress = model.InviteCampaignMonthlyProgress{ + ID: id, + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + CreateTime: now, + UpdateTime: now, + } + mutate(&progress) + return tx.Create(&progress).Error + } + if err != nil { + return err + } + mutate(&progress) + progress.UpdateTime = time.Now() + return tx.Save(&progress).Error +} + +func (s *InviteService) createTaskClaimIfAbsentTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64, claimTime time.Time) error { + var existing model.InviteCampaignTaskClaim + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). + First(&existing).Error + if err == nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + id, idErr := util.NextID() + if idErr != nil { + return idErr + } + return tx.Create(&model.InviteCampaignTaskClaim{ + ID: id, + SysOrigin: sysOrigin, + InviterUserID: inviterUserID, + MonthKey: monthKey, + RuleID: ruleID, + ClaimTime: claimTime, + CreateTime: claimTime, + UpdateTime: claimTime, + }).Error +} + +func (s *InviteService) dispatchReward(ctx context.Context, input rewardDispatchInput) (*rewardDispatchResult, error) { + if input.Reward.GoldAmount <= 0 && input.Reward.RewardGroupID == nil { + return &rewardDispatchResult{Success: true}, nil + } + if input.Reward.GoldAmount > 0 { + if err := s.dispatchGold(ctx, input); err != nil { + return &rewardDispatchResult{Success: false}, err + } + } + if input.Reward.RewardGroupID != nil { + if err := s.dispatchProps(ctx, input); err != nil { + return &rewardDispatchResult{Success: false}, err + } + } + return &rewardDispatchResult{Success: true}, nil +} + +func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchInput) error { + businessKey := input.BusinessKey + ":gold" + logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, input.Reward.GoldAmount, nil) + if err != nil { + return err + } + if logRecord.Status == rewardLogStatusSuccess { + return nil + } + + err = s.java.GrantGold(ctx, integration.GrantGoldRequest{ + UserID: input.TargetUserID, + SysOrigin: input.SysOrigin, + EventID: businessKey, + Origin: input.GoldOrigin, + GoldAmount: input.Reward.GoldAmount, + Remark: input.Remark, + }) + if err != nil { + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + } + if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil { + return err + } + if input.MonthKey != "" && input.InviterUserID != nil && *input.InviterUserID == input.TargetUserID { + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.incrementMonthlyProgressTx(tx, input.SysOrigin, input.TargetUserID, input.MonthKey, func(item *model.InviteCampaignMonthlyProgress) { + item.RewardGoldCoins += input.Reward.GoldAmount + }) + }) + } + return nil +} + +func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchInput) error { + if input.Reward.RewardGroupID == nil { + return nil + } + businessKey := input.BusinessKey + ":props" + logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, 0, input.Reward.RewardGroupID) + if err != nil { + return err + } + if logRecord.Status == rewardLogStatusSuccess { + return nil + } + err = s.java.GrantProps(ctx, integration.GrantPropsRequest{ + TrackID: logRecord.ID, + AcceptUserID: input.TargetUserID, + SysOrigin: input.SysOrigin, + Origin: input.PropsOrigin, + SourceGroupID: *input.Reward.RewardGroupID, + }) + if err != nil { + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) + } + return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "") +} + +func (s *InviteService) getOrCreateRewardLog(ctx context.Context, businessKey string, input rewardDispatchInput, goldAmount int64, rewardGroupID *int64) (*model.InviteCampaignRewardLog, error) { + var record model.InviteCampaignRewardLog + err := s.repo.DB.WithContext(ctx). + Where("business_key = ?", businessKey). + First(&record).Error + if err == nil { + return &record, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + id, idErr := util.NextID() + if idErr != nil { + return nil, idErr + } + now := time.Now() + record = model.InviteCampaignRewardLog{ + ID: id, + SysOrigin: input.SysOrigin, + RelationID: input.RelationID, + InviterUserID: input.InviterUserID, + InviteeUserID: input.InviteeUserID, + TargetUserID: input.TargetUserID, + RuleID: input.RuleID, + RewardScene: input.Scene, + BusinessKey: businessKey, + GoldAmount: goldAmount, + RewardGroupID: rewardGroupID, + Status: rewardLogStatusPending, + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return nil, err + } + if err := s.repo.DB.WithContext(ctx).Where("business_key = ?", businessKey).First(&record).Error; err != nil { + return nil, err + } + } + return &record, nil +} + +func (s *InviteService) updateRewardLogStatus(ctx context.Context, id int64, status, message string) error { + return s.repo.DB.WithContext(ctx). + Model(&model.InviteCampaignRewardLog{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "error_message": truncate(message, 255), + "update_time": time.Now(), + }).Error +} + +func (s *InviteService) mergeRewardedRuleIDs(ctx context.Context, relationID int64, ruleIDs []int64) error { + return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var progress model.InviteCampaignInviteeProgress + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("relation_id = ?", relationID). + First(&progress).Error; err != nil { + return err + } + current := parseRewardedRuleIDs(progress.RewardedRuleIDs) + for _, ruleID := range ruleIDs { + current[ruleID] = struct{}{} + } + progress.RewardedRuleIDs = formatRewardedRuleIDs(current) + progress.UpdateTime = time.Now() + return tx.Save(&progress).Error + }) +} + +func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView { + result := make([]TaskView, 0, len(rules)) + for _, rule := range rules { + if !rule.Enabled { + continue + } + result = append(result, TaskView{ + RuleID: rule.ID, + RuleType: rule.RuleType, + Threshold: rule.ThresholdValue, + Progress: progress, + GoldAmount: rule.GoldAmount, + RewardGroupID: rule.RewardGroupID, + Achieved: progress >= rule.ThresholdValue, + Claimed: claimed[rule.ID], + }) + } + return result +} + +func firstRule(rules []model.InviteCampaignRewardRule, ruleType string) *model.InviteCampaignRewardRule { + for _, item := range rules { + if item.RuleType == ruleType && item.Enabled { + rule := item + return &rule + } + } + return nil +} + +func rulesByType(rules []model.InviteCampaignRewardRule, ruleType string) []model.InviteCampaignRewardRule { + result := make([]model.InviteCampaignRewardRule, 0) + for _, item := range rules { + if item.RuleType == ruleType && item.Enabled { + result = append(result, item) + } + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Sort == result[j].Sort { + if result[i].ThresholdValue == result[j].ThresholdValue { + return result[i].ID < result[j].ID + } + return result[i].ThresholdValue < result[j].ThresholdValue + } + return result[i].Sort < result[j].Sort + }) + return result +} + +func findRuleByID(rules []model.InviteCampaignRewardRule, ruleID int64) *model.InviteCampaignRewardRule { + for _, item := range rules { + if item.ID == ruleID { + rule := item + return &rule + } + } + return nil +} + +func crossedInviteeRechargeRules(rules []model.InviteCampaignRewardRule, oldTotal, newTotal int64, rewarded map[int64]struct{}) []model.InviteCampaignRewardRule { + result := make([]model.InviteCampaignRewardRule, 0) + for _, rule := range rules { + if !rule.Enabled { + continue + } + if _, exists := rewarded[rule.ID]; exists { + continue + } + if oldTotal < rule.ThresholdValue && newTotal >= rule.ThresholdValue { + result = append(result, rule) + } + } + return result +} + +func rewardFromRule(rule *model.InviteCampaignRewardRule) RewardView { + if rule == nil { + return RewardView{} + } + return RewardView{ + RuleID: rule.ID, + RuleType: rule.RuleType, + Threshold: rule.ThresholdValue, + GoldAmount: rule.GoldAmount, + RewardGroupID: rule.RewardGroupID, + } +} + +func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView { + result := make([]RewardView, 0, len(rules)) + for _, item := range rules { + result = append(result, rewardFromRule(&item)) + } + return result +} + +func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 { + var threshold int64 + for _, rule := range rules { + if !rule.Enabled || rule.ThresholdValue <= 0 { + continue + } + if threshold == 0 || rule.ThresholdValue < threshold { + threshold = rule.ThresholdValue + } + } + if threshold == 0 { + return defaultValidRechargeCoins + } + return threshold +} + +func parseRewardedRuleIDs(raw string) map[int64]struct{} { + result := make(map[int64]struct{}) + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + value, err := strconv.ParseInt(item, 10, 64) + if err == nil && value > 0 { + result[value] = struct{}{} + } + } + return result +} + +func formatRewardedRuleIDs(values map[int64]struct{}) string { + items := make([]int64, 0, len(values)) + for value := range values { + items = append(items, value) + } + sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) + parts := make([]string, 0, len(items)) + for _, value := range items { + parts = append(parts, strconv.FormatInt(value, 10)) + } + return strings.Join(parts, ",") +} + +func parseFlexibleTime(value any) (time.Time, error) { + switch v := value.(type) { + case nil: + return time.Now(), nil + case float64: + return fromUnixNumber(int64(v)), nil + case int64: + return fromUnixNumber(v), nil + case int: + return fromUnixNumber(int64(v)), nil + case json.Number: + num, err := v.Int64() + if err != nil { + return time.Time{}, err + } + return fromUnixNumber(num), nil + case string: + v = strings.TrimSpace(v) + if v == "" { + return time.Now(), nil + } + if digitsOnly(v) { + num, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, err + } + return fromUnixNumber(num), nil + } + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, v); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported payTime format") + default: + return time.Time{}, fmt.Errorf("unsupported payTime type") + } +} + +func fromUnixNumber(value int64) time.Time { + if value > 1_000_000_000_000 { + return time.UnixMilli(value) + } + return time.Unix(value, 0) +} + +func normalizeSysOrigin(value string) string { + return strings.TrimSpace(value) +} + +func normalizeTimezone(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return defaultTimezone + } + return value +} + +func monthKeyAt(t time.Time, timezone string) string { + location, err := time.LoadLocation(normalizeTimezone(timezone)) + if err != nil { + location = time.FixedZone(defaultTimezone, 3*3600) + } + return t.In(location).Format("200601") +} + +func digitsOnly(value string) bool { + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func truncate(value string, size int) string { + if len(value) <= size { + return value + } + return value[:size] +} + +func optionalRuleID(rule *model.InviteCampaignRewardRule) *int64 { + if rule == nil || rule.ID == 0 { + return nil + } + return refInt64(rule.ID) +} + +func refInt64(value int64) *int64 { + return &value +} + +func refTime(value time.Time) *time.Time { + return &value +} + +func mapGoldOrigin(ruleType string) string { + switch ruleType { + case ruleTypeInviterBind: + return "INVITED_NEW_USER_REWARD" + case ruleTypeInviteeRecharge: + return "INVITED_USER_FIRST_RECHARGE_REWARD" + case ruleTypeInviterTotalRecharge: + return "CUMULATIVE_RECHARGE_REWARDS" + default: + return "INVITE_USER_REWARDS" + } +} + +func mapPropsOrigin(ruleType string) string { + switch ruleType { + case ruleTypeInviterTotalRecharge: + return "CUMULATIVE_RECHARGE_REWARDS" + default: + return "INVITE_USER_REWARDS" + } +} diff --git a/internal/util/id.go b/internal/util/id.go new file mode 100644 index 0000000..7af10d8 --- /dev/null +++ b/internal/util/id.go @@ -0,0 +1,23 @@ +package util + +import ( + "sync" + + "github.com/bwmarrin/snowflake" +) + +var ( + nodeOnce sync.Once + node *snowflake.Node + nodeErr error +) + +func NextID() (int64, error) { + nodeOnce.Do(func() { + node, nodeErr = snowflake.NewNode(1) + }) + if nodeErr != nil { + return 0, nodeErr + } + return node.Generate().Int64(), nil +} diff --git a/migrations/001_invite_campaign.sql b/migrations/001_invite_campaign.sql new file mode 100644 index 0000000..e440590 --- /dev/null +++ b/migrations/001_invite_campaign.sql @@ -0,0 +1,124 @@ +CREATE TABLE IF NOT EXISTS invite_campaign_config ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + download_url VARCHAR(512) DEFAULT NULL, + share_title VARCHAR(255) DEFAULT NULL, + share_desc VARCHAR(255) DEFAULT NULL, + service_contact VARCHAR(255) DEFAULT NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Riyadh', + anti_cheat_same_ip_once TINYINT(1) NOT NULL DEFAULT 1, + anti_cheat_same_device_once TINYINT(1) NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_campaign_sys_origin (sys_origin) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_reward_rule ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + rule_type VARCHAR(32) NOT NULL, + threshold_value BIGINT NOT NULL DEFAULT 0, + gold_amount BIGINT NOT NULL DEFAULT 0, + reward_group_id BIGINT DEFAULT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + sort INT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_invite_rule_sys_type_sort (sys_origin, rule_type, sort) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_relation ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + inviter_user_id BIGINT NOT NULL, + invitee_user_id BIGINT NOT NULL, + invite_code VARCHAR(32) NOT NULL, + bind_ip VARCHAR(128) DEFAULT NULL, + device_fingerprint VARCHAR(255) DEFAULT NULL, + eligible_status VARCHAR(32) NOT NULL DEFAULT 'ELIGIBLE', + block_reason VARCHAR(255) DEFAULT NULL, + bind_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_relation_invitee (sys_origin, invitee_user_id), + KEY idx_invite_relation_sys_inviter (sys_origin, inviter_user_id), + KEY idx_invite_relation_ip (bind_ip), + KEY idx_invite_relation_device (device_fingerprint) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_invitee_progress ( + id BIGINT NOT NULL PRIMARY KEY, + relation_id BIGINT NOT NULL, + sys_origin VARCHAR(32) NOT NULL, + inviter_user_id BIGINT NOT NULL, + invitee_user_id BIGINT NOT NULL, + total_recharge_coins BIGINT NOT NULL DEFAULT 0, + valid_user TINYINT(1) NOT NULL DEFAULT 0, + valid_time DATETIME DEFAULT NULL, + rewarded_rule_ids VARCHAR(255) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invitee_progress_relation (relation_id), + UNIQUE KEY uk_invitee_progress_invitee (invitee_user_id), + KEY idx_invitee_progress_inviter (inviter_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_monthly_progress ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + inviter_user_id BIGINT NOT NULL, + month_key VARCHAR(6) NOT NULL, + invite_count BIGINT NOT NULL DEFAULT 0, + valid_user_count BIGINT NOT NULL DEFAULT 0, + total_recharge_coins BIGINT NOT NULL DEFAULT 0, + reward_gold_coins BIGINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_monthly_progress (sys_origin, inviter_user_id, month_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_task_claim ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + inviter_user_id BIGINT NOT NULL, + month_key VARCHAR(6) NOT NULL, + rule_id BIGINT NOT NULL, + claim_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_task_claim (sys_origin, inviter_user_id, month_key, rule_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_reward_log ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + relation_id BIGINT DEFAULT NULL, + inviter_user_id BIGINT DEFAULT NULL, + invitee_user_id BIGINT DEFAULT NULL, + target_user_id BIGINT NOT NULL, + rule_id BIGINT DEFAULT NULL, + reward_scene VARCHAR(32) NOT NULL, + business_key VARCHAR(128) NOT NULL, + gold_amount BIGINT NOT NULL DEFAULT 0, + reward_group_id BIGINT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + error_message VARCHAR(255) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_reward_business (business_key), + KEY idx_invite_reward_target (sys_origin, target_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invite_campaign_recharge_event ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + order_id VARCHAR(128) NOT NULL, + inviter_user_id BIGINT DEFAULT NULL, + invitee_user_id BIGINT NOT NULL, + recharge_coins BIGINT NOT NULL DEFAULT 0, + pay_time DATETIME NOT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_recharge_order (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/002_baishun_game.sql b/migrations/002_baishun_game.sql new file mode 100644 index 0000000..8d4a7eb --- /dev/null +++ b/migrations/002_baishun_game.sql @@ -0,0 +1,145 @@ +CREATE TABLE IF NOT EXISTS sys_game_list_vendor_ext ( + id BIGINT NOT NULL PRIMARY KEY, + game_list_config_id BIGINT NOT NULL, + sys_origin VARCHAR(32) NOT NULL, + vendor_type VARCHAR(32) NOT NULL, + vendor_game_id VARCHAR(64) NOT NULL, + launch_mode VARCHAR(32) NOT NULL DEFAULT 'H5_REMOTE', + package_version VARCHAR(32) DEFAULT NULL, + package_url VARCHAR(1024) DEFAULT NULL, + preview_url VARCHAR(1024) DEFAULT NULL, + orientation TINYINT DEFAULT NULL, + safe_height INT NOT NULL DEFAULT 0, + gsp VARCHAR(64) DEFAULT NULL, + bridge_schema_version VARCHAR(16) NOT NULL DEFAULT '1.0', + currency_type INT DEFAULT NULL, + currency_icon VARCHAR(1024) DEFAULT NULL, + extra_json TEXT DEFAULT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_game_vendor_ext_cfg_vendor (game_list_config_id, vendor_type), + KEY idx_game_vendor_ext_sys_vendor (sys_origin, vendor_type, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS baishun_game_catalog ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + internal_game_id VARCHAR(64) NOT NULL, + vendor_game_id INT NOT NULL, + name VARCHAR(128) NOT NULL, + cover VARCHAR(1024) DEFAULT NULL, + preview_url VARCHAR(1024) DEFAULT NULL, + download_url VARCHAR(1024) DEFAULT NULL, + package_version VARCHAR(32) DEFAULT NULL, + game_mode_json VARCHAR(64) DEFAULT NULL, + orientation TINYINT DEFAULT NULL, + safe_height INT NOT NULL DEFAULT 0, + venue_level_json VARCHAR(64) DEFAULT NULL, + gsp VARCHAR(64) DEFAULT NULL, + raw_json LONGTEXT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_baishun_catalog_sys_vendor_game (sys_origin, vendor_game_id), + KEY idx_baishun_catalog_internal_game (sys_origin, internal_game_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS baishun_launch_session ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + internal_game_id VARCHAR(64) NOT NULL, + vendor_game_id INT NOT NULL, + game_session_id VARCHAR(64) NOT NULL, + launch_code VARCHAR(128) NOT NULL, + launch_code_expire_time DATETIME NOT NULL, + ss_token VARCHAR(255) DEFAULT NULL, + ss_token_expire_time DATETIME DEFAULT NULL, + language VARCHAR(16) NOT NULL DEFAULT '0', + gsp INT NOT NULL DEFAULT 0, + currency_type INT NOT NULL DEFAULT 0, + currency_icon VARCHAR(1024) DEFAULT NULL, + scene_mode INT NOT NULL DEFAULT 0, + game_mode INT NOT NULL DEFAULT 3, + package_version VARCHAR(32) DEFAULT NULL, + client_ip VARCHAR(64) DEFAULT NULL, + client_origin VARCHAR(16) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'INIT', + closed_reason VARCHAR(64) DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_baishun_launch_code (launch_code), + UNIQUE KEY uk_baishun_ss_token (ss_token), + UNIQUE KEY uk_baishun_game_session (game_session_id), + KEY idx_baishun_session_room_user (sys_origin, room_id, user_id, status), + KEY idx_baishun_session_token_expire (ss_token_expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS baishun_order_idempotency ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + order_id VARCHAR(128) NOT NULL, + game_round_id VARCHAR(128) DEFAULT NULL, + room_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + vendor_game_id INT NOT NULL, + currency_diff BIGINT NOT NULL DEFAULT 0, + diff_msg VARCHAR(32) NOT NULL, + msg_type VARCHAR(64) DEFAULT NULL, + currency_type INT DEFAULT NULL, + wallet_event_id VARCHAR(160) NOT NULL, + wallet_asset_record_id BIGINT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'INIT', + request_json LONGTEXT DEFAULT NULL, + response_json LONGTEXT DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_baishun_order (sys_origin, order_id), + UNIQUE KEY uk_baishun_wallet_event (wallet_event_id), + KEY idx_baishun_round (game_round_id), + KEY idx_baishun_order_user_time (sys_origin, user_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS baishun_callback_log ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) DEFAULT NULL, + request_type VARCHAR(32) NOT NULL, + order_id VARCHAR(128) DEFAULT NULL, + game_round_id VARCHAR(128) DEFAULT NULL, + room_id VARCHAR(64) DEFAULT NULL, + user_id BIGINT DEFAULT NULL, + vendor_game_id INT DEFAULT NULL, + request_json LONGTEXT DEFAULT NULL, + response_json LONGTEXT DEFAULT NULL, + biz_code VARCHAR(32) DEFAULT NULL, + biz_message VARCHAR(255) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_baishun_log_type_time (request_type, create_time), + KEY idx_baishun_log_order (order_id), + KEY idx_baishun_log_room_user (room_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS baishun_room_state ( + id BIGINT NOT NULL PRIMARY KEY, + sys_origin VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + host_user_id BIGINT NOT NULL, + current_game_id VARCHAR(64) DEFAULT NULL, + current_vendor_game_id INT DEFAULT NULL, + current_game_name VARCHAR(128) DEFAULT NULL, + current_game_cover VARCHAR(1024) DEFAULT NULL, + game_session_id VARCHAR(64) DEFAULT NULL, + launch_user_id BIGINT DEFAULT NULL, + state VARCHAR(32) NOT NULL DEFAULT 'IDLE', + extra_json TEXT DEFAULT NULL, + start_time DATETIME DEFAULT NULL, + end_time DATETIME DEFAULT NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_baishun_room_state (sys_origin, room_id), + KEY idx_baishun_room_state_session (game_session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;