Merge branch 'yongxu-dev' into devlop
# Conflicts: # miniprogram/app.js # miniprogram/app.json # miniprogram/pages/chapters/chapters.js # miniprogram/pages/chapters/chapters.wxml # miniprogram/pages/chapters/chapters.wxss # miniprogram/pages/index/index.js # miniprogram/pages/index/index.wxml # miniprogram/pages/match/match.js # miniprogram/pages/my/my.js # miniprogram/pages/my/my.wxml # miniprogram/pages/read/read.js # miniprogram/pages/read/read.wxml # miniprogram/pages/read/read.wxss # miniprogram/pages/referral/referral.js # miniprogram/pages/search/search.js # miniprogram/pages/vip/vip.js # miniprogram/pages/wallet/wallet.wxml # miniprogram/project.private.config.json # soul-admin/dist/index.html # soul-admin/src/pages/dashboard/DashboardPage.tsx # soul-admin/src/pages/settings/SettingsPage.tsx # soul-api/go.mod # soul-api/internal/handler/admin_dashboard.go # soul-api/internal/handler/db.go # soul-api/wechat/info.log # 开发文档/10、项目管理/运营与变更.md # 开发文档/README.md
This commit is contained in:
@@ -46,11 +46,13 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502
|
||||
// 预热 Redis 缓存,避免首请求冷启动 502
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // 等 DB 完全就绪
|
||||
handler.WarmAllChaptersCache()
|
||||
handler.WarmBookPartsCache()
|
||||
handler.WarmConfigCache()
|
||||
handler.WarmLatestChaptersCache()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -6,7 +6,7 @@ require (
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/unrolled/secure v1.17.0
|
||||
@@ -16,47 +16,56 @@ require (
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/gin-contrib/gzip v1.2.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gin-contrib/sse v1.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-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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/klauspost/cpuid/v2 v2.3.0 // 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/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -8,16 +8,24 @@ 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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
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 v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
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/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
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/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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=
|
||||
@@ -27,12 +35,20 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
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-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
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/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -45,10 +61,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
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-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -65,6 +87,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
|
||||
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -83,10 +107,16 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
@@ -108,6 +138,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
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=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -129,10 +161,16 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
@@ -141,8 +179,12 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
75
soul-api/internal/cache/cache.go
vendored
75
soul-api/internal/cache/cache.go
vendored
@@ -17,11 +17,36 @@ const defaultTimeout = 2 * time.Second
|
||||
// KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del
|
||||
const KeyBookParts = "soul:book:parts"
|
||||
|
||||
// KeyAllChapters 全书章节列表,default 与 excludeFixed 两种
|
||||
func KeyAllChapters(cacheKey string) string {
|
||||
if cacheKey == "excludeFixed" {
|
||||
return "soul:book:all-chapters:excludeFixed"
|
||||
}
|
||||
return "soul:book:all-chapters"
|
||||
}
|
||||
|
||||
// KeyChaptersByPart 篇章内章节,格式 soul:book:chapters-by-part:{partId}
|
||||
func KeyChaptersByPart(partId string) string {
|
||||
return "soul:book:chapters-by-part:" + partId
|
||||
}
|
||||
|
||||
// KeyChaptersByPartPattern 用于批量删除 chapters-by-part 缓存
|
||||
const KeyChaptersByPartPattern = "soul:book:chapters-by-part:*"
|
||||
|
||||
// KeyBookLatestChapters 最新更新章节
|
||||
const KeyBookLatestChapters = "soul:book:latest-chapters"
|
||||
|
||||
// KeyFreeChapterIDs 免费章节 ID 列表(JSON 数组)
|
||||
const KeyFreeChapterIDs = "soul:config:free-chapters"
|
||||
|
||||
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
|
||||
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
|
||||
const KeyBookRecommended = "soul:book:recommended"
|
||||
const KeyBookStats = "soul:book:stats"
|
||||
const KeyConfigMiniprogram = "soul:config:miniprogram"
|
||||
const KeyConfigAuditMode = "soul:config:audit-mode"
|
||||
const KeyConfigCore = "soul:config:core"
|
||||
const KeyConfigReadExtras = "soul:config:read-extras"
|
||||
|
||||
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
|
||||
func Get(ctx context.Context, key string, dest interface{}) bool {
|
||||
@@ -81,12 +106,47 @@ func Del(ctx context.Context, key string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效
|
||||
func DelPattern(ctx context.Context, pattern string) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout*2)
|
||||
defer cancel()
|
||||
keys, err := client.Keys(ctx, pattern).Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
if err := client.Del(ctx, keys...).Err(); err != nil {
|
||||
log.Printf("cache.DelPattern %s: %v (非致命)", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
// BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长
|
||||
const BookPartsTTL = 10 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效
|
||||
// AllChaptersTTL 全书章节列表 TTL
|
||||
const AllChaptersTTL = 10 * time.Minute
|
||||
|
||||
// ChaptersByPartTTL 篇章内章节 TTL
|
||||
const ChaptersByPartTTL = 10 * time.Minute
|
||||
|
||||
// FreeChapterIDsTTL 免费章节配置 TTL
|
||||
const FreeChapterIDsTTL = 5 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录、章节列表等缓存失效
|
||||
func InvalidateBookParts() {
|
||||
Del(context.Background(), KeyBookParts)
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyBookParts)
|
||||
Del(ctx, KeyAllChapters("default"))
|
||||
Del(ctx, KeyAllChapters("excludeFixed"))
|
||||
Del(ctx, KeyBookLatestChapters)
|
||||
Del(ctx, KeyFreeChapterIDs)
|
||||
DelPattern(ctx, KeyChaptersByPartPattern)
|
||||
}
|
||||
|
||||
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
|
||||
@@ -99,9 +159,13 @@ func InvalidateBookCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 及拆分接口缓存失效
|
||||
func InvalidateConfig() {
|
||||
Del(context.Background(), KeyConfigMiniprogram)
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyConfigMiniprogram)
|
||||
Del(ctx, KeyConfigAuditMode)
|
||||
Del(ctx, KeyConfigCore)
|
||||
Del(ctx, KeyConfigReadExtras)
|
||||
}
|
||||
|
||||
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
||||
@@ -110,6 +174,9 @@ const BookRelatedTTL = 5 * time.Minute
|
||||
// ConfigTTL 配置接口 TTL
|
||||
const ConfigTTL = 10 * time.Minute
|
||||
|
||||
// AuditModeTTL 审核模式 TTL,管理端开关后需较快生效
|
||||
const AuditModeTTL = 1 * time.Minute
|
||||
|
||||
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
|
||||
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -54,11 +56,17 @@ func AdminDashboardStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders?limit=10
|
||||
func AdminDashboardRecentOrders(c *gin.Context) {
|
||||
db := database.DB()
|
||||
limit := 5
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 20 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(10).Find(&recentOrders)
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(limit).Find(&recentOrders)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
|
||||
}
|
||||
|
||||
@@ -180,6 +188,101 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all
|
||||
// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count
|
||||
func AdminTrackStats(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "week")
|
||||
if period != "today" && period != "week" && period != "month" && period != "all" {
|
||||
period = "week"
|
||||
}
|
||||
now := time.Now()
|
||||
var start time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
case "week":
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
|
||||
case "month":
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
case "all":
|
||||
start = time.Time{}
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
q := db.Model(&model.UserTrack{})
|
||||
if !start.IsZero() {
|
||||
q = q.Where("created_at >= ?", start)
|
||||
}
|
||||
if err := q.Find(&tracks).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// byModule: module -> map[key] -> count, key = action + "|" + target
|
||||
type item struct {
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Module string `json:"module"`
|
||||
Page string `json:"page"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
byModule := make(map[string]map[string]*item)
|
||||
total := 0
|
||||
for _, t := range tracks {
|
||||
total++
|
||||
module := "other"
|
||||
page := ""
|
||||
if len(t.ExtraData) > 0 {
|
||||
var extra map[string]interface{}
|
||||
if err := json.Unmarshal(t.ExtraData, &extra); err == nil {
|
||||
if m, ok := extra["module"].(string); ok && m != "" {
|
||||
module = m
|
||||
}
|
||||
if p, ok := extra["page"].(string); ok {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
}
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
key := t.Action + "|" + target
|
||||
if byModule[module] == nil {
|
||||
byModule[module] = make(map[string]*item)
|
||||
}
|
||||
if byModule[module][key] == nil {
|
||||
byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0}
|
||||
}
|
||||
byModule[module][key].Count++
|
||||
}
|
||||
// 转为前端期望格式:byModule[module] = [{action,target,module,page,count},...]
|
||||
out := make(map[string][]gin.H)
|
||||
for mod, m := range byModule {
|
||||
list := make([]gin.H, 0, len(m))
|
||||
for _, v := range m {
|
||||
list = append(list, gin.H{
|
||||
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
|
||||
})
|
||||
}
|
||||
out[mod] = list
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out})
|
||||
}
|
||||
|
||||
// AdminBalanceSummary GET /api/admin/balance/summary
|
||||
// 汇总代付金额(product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」
|
||||
func AdminBalanceSummary(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var totalGifted float64
|
||||
db.Model(&model.Order{}).Where("product_type IN ? AND status IN ?", []string{"gift_pay", "gift_pay_batch"}, paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalGifted)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalGifted": totalGifted}})
|
||||
}
|
||||
|
||||
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
|
||||
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
|
||||
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error
|
||||
|
||||
@@ -94,7 +94,27 @@ var bookPartsCache struct {
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
// chaptersByPartCache 篇章内章节列表内存缓存,30 秒 TTL
|
||||
type chaptersByPartEntry struct {
|
||||
data []model.Chapter
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var chaptersByPartCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*chaptersByPartEntry
|
||||
}
|
||||
|
||||
const chaptersByPartCacheTTL = 30 * time.Second
|
||||
|
||||
// InvalidateChaptersByPartCache 后台更新章节时调用,使 chapters-by-part 内存缓存失效
|
||||
func InvalidateChaptersByPartCache() {
|
||||
chaptersByPartCache.mu.Lock()
|
||||
chaptersByPartCache.entries = nil
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存(Redis+内存),避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
@@ -112,6 +132,7 @@ func WarmAllChaptersCache() {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -202,15 +223,26 @@ func WarmBookPartsCache() {
|
||||
}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
//
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part + book/stats,id↔mid 从各接口响应积累。
|
||||
// 保留以兼容旧版/管理端,计划后续下线。
|
||||
//
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
cacheKey := "default"
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
cacheKey = "excludeFixed"
|
||||
}
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyAllChapters(cacheKey), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
data := allChaptersCache.data
|
||||
@@ -220,6 +252,7 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
if cacheKey == "excludeFixed" {
|
||||
@@ -227,7 +260,6 @@ func BookAllChapters(c *gin.Context) {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -243,6 +275,8 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -311,14 +345,33 @@ func BookParts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookChaptersByPart(c *gin.Context) {
|
||||
partId := c.Query("partId")
|
||||
if partId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyChaptersByPart(partId), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
chaptersByPartCache.mu.RLock()
|
||||
if chaptersByPartCache.entries != nil {
|
||||
if e, ok := chaptersByPartCache.entries[partId]; ok && time.Now().Before(e.expires) {
|
||||
list := e.data
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
}
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Where("part_id = ?", partId).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
@@ -336,9 +389,42 @@ func BookChaptersByPart(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyChaptersByPart(partId), list, cache.ChaptersByPartTTL)
|
||||
chaptersByPartCache.mu.Lock()
|
||||
if chaptersByPartCache.entries == nil {
|
||||
chaptersByPartCache.entries = make(map[string]*chaptersByPartEntry)
|
||||
}
|
||||
chaptersByPartCache.entries[partId] = &chaptersByPartEntry{data: list, expires: time.Now().Add(chaptersByPartCacheTTL)}
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// getOrderedChapterList 获取按 sort_order+id 排序的章节列表(复用 all-chapters 缓存)
|
||||
func getOrderedChapterList() []model.Chapter {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyAllChapters("default"), &list) && len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == "default" && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
list = allChaptersCache.data
|
||||
allChaptersCache.mu.RUnlock()
|
||||
return list
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
db := database.DB()
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&list).Error; err != nil || len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
return list
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
@@ -357,8 +443,16 @@ func BookChapterByMID(c *gin.Context) {
|
||||
}
|
||||
|
||||
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
||||
// Redis 缓存 5min,后台更新时失效
|
||||
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
var ids map[string]bool
|
||||
if cache.Get(context.Background(), cache.KeyFreeChapterIDs, &ids) {
|
||||
if ids == nil {
|
||||
return make(map[string]bool)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
ids = make(map[string]bool)
|
||||
for _, key := range []string{"free_chapters", "chapter_config"} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
|
||||
@@ -388,6 +482,7 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -550,6 +645,38 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
"isFree": isFree,
|
||||
}
|
||||
// 文章详情内直接输出上一篇/下一篇,省去单独请求
|
||||
if list := getOrderedChapterList(); len(list) > 0 {
|
||||
idx := -1
|
||||
for i, item := range list {
|
||||
if item.ID == ch.ID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 {
|
||||
toItem := func(c *model.Chapter) gin.H {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
t := c.SectionTitle
|
||||
if t == "" {
|
||||
t = c.ChapterTitle
|
||||
}
|
||||
return gin.H{"id": c.ID, "mid": c.MID, "title": t}
|
||||
}
|
||||
if idx > 0 {
|
||||
out["prev"] = toItem(&list[idx-1])
|
||||
} else {
|
||||
out["prev"] = nil
|
||||
}
|
||||
if idx < len(list)-1 {
|
||||
out["next"] = toItem(&list[idx+1])
|
||||
} else {
|
||||
out["next"] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if isFreeFromConfig {
|
||||
out["price"] = float64(0)
|
||||
} else if ch.Price != nil {
|
||||
@@ -773,13 +900,18 @@ func BookRecommended(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
||||
// Redis 缓存 5min,首页「最新更新」主接口
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -799,9 +931,42 @@ func BookLatestChapters(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// WarmLatestChaptersCache 启动时预热最新章节 Redis 缓存(首页主接口)
|
||||
func WarmLatestChaptersCache() {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
}
|
||||
|
||||
func escapeLikeBook(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
|
||||
@@ -17,14 +17,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
|
||||
func buildMiniprogramConfig() gin.H {
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
@@ -32,17 +26,19 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
apiDomain = cfg.BaseURL
|
||||
}
|
||||
defaultMp := gin.H{
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"buyerDiscount": 5,
|
||||
"referralBindDays": 30,
|
||||
"minWithdraw": 10,
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"buyerDiscount": 5,
|
||||
"referralBindDays": 30,
|
||||
"minWithdraw": 10,
|
||||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||||
"mchId": "1318592501",
|
||||
"mchId": "1318592501",
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"success": true,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
@@ -134,10 +130,149 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["linkedMiniprograms"]; !has {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
// 明确归一化 auditMode:仅当 DB 显式为 true 时返回 true,否则一律 false(避免历史脏数据/类型异常导致误判)
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
mp["auditMode"] = true
|
||||
} else {
|
||||
mp["auditMode"] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
//
|
||||
// Deprecated: 计划迁移至 /config/core + /config/audit-mode + /config/read-extras,保留以兼容线上小程序
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
out := buildMiniprogramConfig()
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
|
||||
func GetAuditMode(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
auditMode := false
|
||||
if mp, ok := full["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
}
|
||||
out := gin.H{"auditMode": auditMode}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetCoreConfig GET /api/miniprogram/config/core 核心配置(prices、features、userDiscount、mpConfig),首屏/Tab 用
|
||||
func GetCoreConfig(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigCore, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"prices": full["prices"],
|
||||
"features": full["features"],
|
||||
"userDiscount": full["userDiscount"],
|
||||
"mpConfig": full["mpConfig"],
|
||||
}
|
||||
if out["prices"] == nil {
|
||||
out["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
}
|
||||
if out["features"] == nil {
|
||||
out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
}
|
||||
if out["userDiscount"] == nil {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
if out["mpConfig"] == nil {
|
||||
out["mpConfig"] = gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigCore, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetReadExtras GET /api/miniprogram/config/read-extras 阅读页扩展(linkTags、linkedMiniprograms),懒加载
|
||||
func GetReadExtras(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigReadExtras, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
out := gin.H{
|
||||
"linkTags": full["linkTags"],
|
||||
"linkedMiniprograms": full["linkedMiniprograms"],
|
||||
}
|
||||
if out["linkTags"] == nil {
|
||||
out["linkTags"] = []gin.H{}
|
||||
}
|
||||
if out["linkedMiniprograms"] == nil {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigReadExtras, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// WarmConfigCache 启动时预热 config 及拆分接口缓存,避免首请求冷启动
|
||||
func WarmConfigCache() {
|
||||
out := buildMiniprogramConfig()
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
// 拆分接口预热
|
||||
auditMode := false
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
|
||||
core := gin.H{
|
||||
"success": true,
|
||||
"prices": out["prices"],
|
||||
"features": out["features"],
|
||||
"userDiscount": out["userDiscount"],
|
||||
"mpConfig": out["mpConfig"],
|
||||
}
|
||||
if core["prices"] == nil {
|
||||
core["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
}
|
||||
if core["features"] == nil {
|
||||
core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
}
|
||||
if core["userDiscount"] == nil {
|
||||
core["userDiscount"] = float64(5)
|
||||
}
|
||||
if core["mpConfig"] == nil {
|
||||
core["mpConfig"] = gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigCore, core, cache.ConfigTTL)
|
||||
readExtras := gin.H{
|
||||
"linkTags": out["linkTags"],
|
||||
"linkedMiniprograms": out["linkedMiniprograms"],
|
||||
}
|
||||
if readExtras["linkTags"] == nil {
|
||||
readExtras["linkTags"] = []gin.H{}
|
||||
}
|
||||
if readExtras["linkedMiniprograms"] == nil {
|
||||
readExtras["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigReadExtras, readExtras, cache.ConfigTTL)
|
||||
}
|
||||
|
||||
// DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用)
|
||||
func DBConfigGet(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
@@ -174,15 +309,17 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
apiDomain = cfg.BaseURL
|
||||
}
|
||||
defaultMp := gin.H{
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||||
"mchId": "1318592501",
|
||||
"minWithdraw": float64(10),
|
||||
"mchId": "1318592501",
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
"mpConfig": defaultMp,
|
||||
"ossConfig": gin.H{},
|
||||
@@ -289,12 +426,12 @@ func AdminReferralSettingsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
defaultConfig := gin.H{
|
||||
"distributorShare": float64(90),
|
||||
"minWithdrawAmount": float64(10),
|
||||
"bindingDays": float64(30),
|
||||
"userDiscount": float64(5),
|
||||
"withdrawFee": float64(5),
|
||||
"enableAutoWithdraw": false,
|
||||
"vipOrderShareVip": float64(20),
|
||||
"minWithdrawAmount": float64(10),
|
||||
"bindingDays": float64(30),
|
||||
"userDiscount": float64(5),
|
||||
"withdrawFee": float64(5),
|
||||
"enableAutoWithdraw": false,
|
||||
"vipOrderShareVip": float64(20),
|
||||
"vipOrderShareNonVip": float64(10),
|
||||
}
|
||||
var row model.SystemConfig
|
||||
@@ -337,11 +474,11 @@ func AdminReferralSettingsPost(c *gin.Context) {
|
||||
val := gin.H{
|
||||
"distributorShare": body.DistributorShare,
|
||||
"minWithdrawAmount": body.MinWithdrawAmount,
|
||||
"bindingDays": body.BindingDays,
|
||||
"userDiscount": body.UserDiscount,
|
||||
"withdrawFee": body.WithdrawFee,
|
||||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||||
"vipOrderShareVip": vipOrderShareVip,
|
||||
"bindingDays": body.BindingDays,
|
||||
"userDiscount": body.UserDiscount,
|
||||
"withdrawFee": body.WithdrawFee,
|
||||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||||
"vipOrderShareVip": vipOrderShareVip,
|
||||
"vipOrderShareNonVip": vipOrderShareNonVip,
|
||||
}
|
||||
valBytes, err := json.Marshal(val)
|
||||
@@ -456,12 +593,12 @@ func AdminAuthorSettingsPost(c *gin.Context) {
|
||||
err := db.First(&row).Error
|
||||
if err != nil {
|
||||
row = model.AuthorConfig{
|
||||
Name: name,
|
||||
Avatar: avatar,
|
||||
AvatarImg: str("avatarImg"),
|
||||
Title: str("title"),
|
||||
Bio: str("bio"),
|
||||
Stats: string(statsBytes),
|
||||
Name: name,
|
||||
Avatar: avatar,
|
||||
AvatarImg: str("avatarImg"),
|
||||
Title: str("title"),
|
||||
Bio: str("bio"),
|
||||
Stats: string(statsBytes),
|
||||
Highlights: string(highlightsBytes),
|
||||
}
|
||||
err = db.Create(&row).Error
|
||||
@@ -547,7 +684,7 @@ func DBUsersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -720,21 +857,21 @@ func DBUsersList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrFloat64(f float64) *float64 { v := f; return &v }
|
||||
func ptrInt(n int) *int { return &n }
|
||||
func ptrInt(n int) *int { return &n }
|
||||
|
||||
// DBUsersAction POST /api/db/users(创建)、PUT /api/db/users(更新)
|
||||
func DBUsersAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
if c.Request.Method == http.MethodPost {
|
||||
var body struct {
|
||||
OpenID *string `json:"openId"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
OpenID *string `json:"openId"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -764,25 +901,25 @@ func DBUsersAction(c *gin.Context) {
|
||||
}
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio;tags 存 ckb_tags)
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
@@ -902,7 +1039,7 @@ func randomSuffix() string {
|
||||
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
|
||||
}
|
||||
|
||||
// DBUsersDelete DELETE /api/db/users
|
||||
// DBUsersDelete DELETE /api/db/users(软删除:仅设置 deleted_at,用户再次登录会新建账号)
|
||||
func DBUsersDelete(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
@@ -910,29 +1047,16 @@ func DBUsersDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
cleanupTables := []struct{ table, col string }{
|
||||
{"match_records", "user_id"},
|
||||
{"reading_progress", "user_id"},
|
||||
{"user_tracks", "user_id"},
|
||||
{"referral_bindings", "referrer_id"},
|
||||
{"referral_bindings", "referee_id"},
|
||||
{"referral_visits", "visitor_id"},
|
||||
{"ckb_submit_records", "user_id"},
|
||||
{"ckb_lead_records", "user_id"},
|
||||
{"user_addresses", "user_id"},
|
||||
{"user_balances", "user_id"},
|
||||
{"balance_transactions", "user_id"},
|
||||
{"withdrawals", "user_id"},
|
||||
{"orders", "user_id"},
|
||||
}
|
||||
for _, t := range cleanupTables {
|
||||
db.Exec("DELETE FROM "+t.table+" WHERE "+t.col+" = ?", id)
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
result := db.Where("id = ?", id).Delete(&model.User{})
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在或已被删除"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户已删除(假删除),该用户再次登录将创建新账号"})
|
||||
}
|
||||
|
||||
// DBUsersReferrals GET /api/db/users/referrals(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算)
|
||||
@@ -990,9 +1114,9 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free,供前端徽章展示
|
||||
referrals = append(referrals, gin.H{
|
||||
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
|
||||
"hasFullBook": hasFullBook || status == "converted",
|
||||
"hasFullBook": hasFullBook || status == "converted",
|
||||
"purchasedSections": getBindingPurchaseCount(b),
|
||||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||||
"status": displayStatus,
|
||||
})
|
||||
}
|
||||
@@ -1099,7 +1223,7 @@ func DBDistribution(c *gin.Context) {
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
query = query.Where("status = ?", statusFilter)
|
||||
}
|
||||
if err := query.Offset((page-1)*pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
|
||||
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ func DBBookAction(c *gin.Context) {
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
@@ -501,6 +502,7 @@ func DBBookAction(c *gin.Context) {
|
||||
imported++
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
@@ -566,6 +568,7 @@ func DBBookAction(c *gin.Context) {
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
@@ -582,6 +585,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
@@ -607,6 +611,7 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
|
||||
return
|
||||
@@ -716,6 +721,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
cache.InvalidateChapterContent(ch.MID)
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
@@ -731,6 +737,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
cache.InvalidateChapterContentByID(body.ID)
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
@@ -778,6 +785,7 @@ func DBBookDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,9 +15,24 @@ import (
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const giftPayExpireHours = 24
|
||||
const wechatAttachMaxBytes = 128
|
||||
|
||||
// truncateStr 截断字符串至最多 n 字节(UTF-8 安全)
|
||||
func truncateStr(s string, n int) string {
|
||||
b := []byte(s)
|
||||
if len(b) <= n {
|
||||
return s
|
||||
}
|
||||
b = b[:n]
|
||||
for len(b) > 0 && b[len(b)-1] >= 0x80 {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// giftPayPreviewContent 取内容前 20%,用于代付页营销展示
|
||||
func giftPayPreviewContent(content string) string {
|
||||
@@ -38,17 +54,23 @@ func giftPayPreviewContent(content string) string {
|
||||
return string(runes[:limit]) + "……"
|
||||
}
|
||||
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求(改造后:发起人支付,好友领取)
|
||||
func GiftPayCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
quantity := req.Quantity
|
||||
if quantity < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发放份数须为正整数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 校验发起人
|
||||
@@ -70,11 +92,15 @@ func GiftPayCreate(c *gin.Context) {
|
||||
productID = "fullbook"
|
||||
}
|
||||
}
|
||||
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
amount := unitPrice * float64(quantity)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
// 发起人若有推荐人绑定,享受好友优惠
|
||||
var referrerID *string
|
||||
var binding struct {
|
||||
@@ -91,7 +117,11 @@ func GiftPayCreate(c *gin.Context) {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
amount = amount * (1 - userDiscount/100)
|
||||
unitPrice = unitPrice * (1 - userDiscount/100)
|
||||
if unitPrice < 0.01 {
|
||||
unitPrice = 0.01
|
||||
}
|
||||
amount = unitPrice * float64(quantity)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
@@ -101,28 +131,7 @@ func GiftPayCreate(c *gin.Context) {
|
||||
}
|
||||
_ = referrerID // 分佣在 PayNotify 时按发起人计算
|
||||
|
||||
// 校验发起人是否已拥有
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status IN ?",
|
||||
req.UserID, "section", productID, []string{"paid", "completed"}).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有该章节"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ProductType == "fullbook" || req.ProductType == "vip" {
|
||||
var u model.User
|
||||
db.Where("id = ?", req.UserID).Select("has_full_book", "is_vip", "vip_expire_date").First(&u)
|
||||
if u.HasFullBook != nil && *u.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有全书"})
|
||||
return
|
||||
}
|
||||
if req.ProductType == "vip" && u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已是有效VIP"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 改造后:发起人帮别人买,发起人自己可已拥有,不再校验
|
||||
|
||||
// 描述
|
||||
desc := ""
|
||||
@@ -155,95 +164,44 @@ func GiftPayCreate(c *gin.Context) {
|
||||
ProductType: req.ProductType,
|
||||
ProductID: productID,
|
||||
Amount: amount,
|
||||
Description: desc,
|
||||
Status: "pending",
|
||||
Description: desc,
|
||||
Status: "pending_pay",
|
||||
Quantity: quantity,
|
||||
RedeemedCount: 0,
|
||||
ExpireAt: expireAt,
|
||||
}
|
||||
if err := db.Create(&gpr).Error; err != nil {
|
||||
fmt.Printf("[GiftPayCreate] 创建失败: %v\n", err)
|
||||
// 若报 unknown column 'quantity' 等,需执行 soul-api/scripts/add-gift-pay-quantity.sql
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
sectionTitle := desc
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
}
|
||||
path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"sectionTitle": sectionTitle,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx 代付详情(代付人用)
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发起人昵称(脱敏)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname").First(&initiator).Error; err == nil && initiator.Nickname != nil {
|
||||
n := *initiator.Nickname
|
||||
if len(n) > 2 {
|
||||
n = string([]rune(n)[0]) + "**"
|
||||
}
|
||||
nickname = n
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览,吸引代付人
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"amount": gpr.Amount,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayPay POST /api/miniprogram/gift-pay/pay 代付人发起支付
|
||||
func GiftPayPay(c *gin.Context) {
|
||||
// GiftPayInitiatorPay POST /api/miniprogram/gift-pay/initiator-pay 发起人支付(改造后:我帮别人付款)
|
||||
func GiftPayInitiatorPay(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
UserID string `json:"userId"` // 代付人ID,用于校验不能自己付
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
@@ -252,8 +210,8 @@ func GiftPayPay(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已处理"})
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending_pay").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已支付"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
@@ -261,55 +219,54 @@ func GiftPayPay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 不能自己给自己代付
|
||||
if req.UserID != "" && req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
if req.UserID != gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅发起人可支付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取代付人信息
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&payer).Error; err != nil {
|
||||
var initiator model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&initiator).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if payer.ID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
if initiator.ID != gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "登录用户与发起人不一致"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建订单(归属发起人,记录代付信息)
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
productType := "gift_pay_batch"
|
||||
productID := gpr.ProductID
|
||||
desc := gpr.Description
|
||||
desc := fmt.Sprintf("代付分享 - %s × %d 份", gpr.Description, gpr.Quantity)
|
||||
gprID := gpr.ID
|
||||
payerID := payer.ID
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: gpr.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &payerID,
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &gpr.InitiatorUserID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 唤起微信支付,attach 中 userId=发起人,giftPayRequestSn=请求号
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s","giftPayRequestSn":"%s"}`,
|
||||
gpr.ProductType, gpr.ProductID, gpr.InitiatorUserID, gpr.RequestSN)
|
||||
totalFee := int(gpr.Amount * 100)
|
||||
// 微信 attach 最大 128 字节;发起人付订单已存在,PayNotify 从 order 取 giftPayRequestSn
|
||||
attach := `{"ip":1}`
|
||||
totalFee := int(math.Round(gpr.Amount * 100)) // 与正常章节支付一致,避免浮点精度导致分额错误
|
||||
if totalFee < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "金额异常,无法发起支付"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
|
||||
if err != nil {
|
||||
@@ -322,9 +279,6 @@ func GiftPayPay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 预占:更新请求状态为 paying(可选,防并发)
|
||||
// 简化:不预占,PayNotify 时再更新
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
@@ -335,6 +289,277 @@ func GiftPayPay(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx&userId= 或 ?sectionId=xxx&userId= 预览态
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
sectionId := strings.TrimSpace(c.Query("sectionId"))
|
||||
callerUserID := strings.TrimSpace(c.Query("userId"))
|
||||
db := database.DB()
|
||||
|
||||
// 预览态:无 requestSn 有 sectionId,返回文章信息供创建代付
|
||||
if requestSn == "" && sectionId != "" {
|
||||
if callerUserID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
unitPrice, priceErr := getStandardPrice(db, "section", sectionId)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
// 发起人若有推荐人,享受折扣(与 create 一致)
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT referrer_id FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC LIMIT 1
|
||||
`, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
unitPrice = unitPrice * (1 - userDiscount/100)
|
||||
if unitPrice < 0.01 {
|
||||
unitPrice = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ch model.Chapter
|
||||
sectionTitle := ""
|
||||
productMid := 0
|
||||
if err := db.Select("section_title", "mid").Where("id = ?", sectionId).First(&ch).Error; err == nil {
|
||||
sectionTitle = ch.SectionTitle
|
||||
productMid = ch.MID
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"mode": "create",
|
||||
"sectionId": sectionId,
|
||||
"sectionTitle": sectionTitle,
|
||||
"productMid": productMid,
|
||||
"unitPrice": unitPrice,
|
||||
"isInitiator": true,
|
||||
"action": "create",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
isInitiator := callerUserID != "" && callerUserID == gpr.InitiatorUserID
|
||||
|
||||
// 发起人昵称与头像(完整展示)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
initiatorAvatar := ""
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname", "avatar").First(&initiator).Error; err == nil {
|
||||
if initiator.Nickname != nil && *initiator.Nickname != "" {
|
||||
nickname = *initiator.Nickname
|
||||
}
|
||||
if initiator.Avatar != nil && *initiator.Avatar != "" {
|
||||
initiatorAvatar = *initiator.Avatar
|
||||
}
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
productMid := 0
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content", "mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
productMid = ch.MID
|
||||
}
|
||||
}
|
||||
|
||||
// 领取记录(发起人查看)
|
||||
var redeemList []gin.H
|
||||
if isInitiator {
|
||||
var orders []model.Order
|
||||
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
gpr.ID, "section", "paid").Order("created_at ASC").Find(&orders)
|
||||
for _, o := range orders {
|
||||
if o.UserID == "" {
|
||||
continue
|
||||
}
|
||||
var u model.User
|
||||
nickname := "用户"
|
||||
avatar := ""
|
||||
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
|
||||
if u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")})
|
||||
}
|
||||
}
|
||||
|
||||
// action: pay=发起人待支付 | share=发起人已支付可分享 | redeem=好友可领取 | wait=好友待发起人支付
|
||||
action := ""
|
||||
if isInitiator {
|
||||
if gpr.Status == "pending_pay" {
|
||||
action = "pay"
|
||||
} else if gpr.Status == "paid" {
|
||||
action = "share"
|
||||
} else if gpr.Status == "pending" {
|
||||
action = "share" // 旧版:待好友付
|
||||
}
|
||||
} else {
|
||||
if gpr.Status == "pending_pay" || gpr.Status == "pending" {
|
||||
action = "wait"
|
||||
} else if gpr.Status == "paid" {
|
||||
// 好友已领取过:返回 alreadyRedeemed,供前端直接跳转 read
|
||||
var existCnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
callerUserID, gpr.ID, "section", "paid",
|
||||
).Count(&existCnt)
|
||||
if existCnt > 0 {
|
||||
action = "alreadyRedeemed"
|
||||
} else {
|
||||
action = "redeem"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"productMid": productMid,
|
||||
"amount": gpr.Amount,
|
||||
"quantity": gpr.Quantity,
|
||||
"redeemedCount": gpr.RedeemedCount,
|
||||
"redeemList": redeemList,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorAvatar": initiatorAvatar,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"isInitiator": isInitiator,
|
||||
"action": action,
|
||||
"status": gpr.Status,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GiftPayRedeem POST /api/miniprogram/gift-pay/redeem 好友领取(改造后:免费获得章节)
|
||||
func GiftPayRedeem(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "paid").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或未支付"})
|
||||
return
|
||||
}
|
||||
if req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发起人无需领取"})
|
||||
return
|
||||
}
|
||||
if gpr.RedeemedCount >= gpr.Quantity {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "已领完"})
|
||||
return
|
||||
}
|
||||
|
||||
// 同一用户同一 requestSn 只能领一次
|
||||
var existCnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
req.UserID, gpr.ID, "section", "paid",
|
||||
).Count(&existCnt)
|
||||
if existCnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已领取过"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建好友订单:productType=section, status=paid, paymentMethod=gift_pay
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "paid"
|
||||
pm := "gift_pay"
|
||||
productID := gpr.ProductID
|
||||
desc := fmt.Sprintf("代付领取 - %s", gpr.Description)
|
||||
gprID := gpr.ID
|
||||
amount := 0.0
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: "section",
|
||||
ProductID: &productID,
|
||||
Amount: amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &gpr.InitiatorUserID,
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.GiftPayRequest{}).Where("id = ?", gpr.ID).
|
||||
Update("redeemed_count", gorm.Expr("redeemed_count + 1")).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "领取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
_ = amount
|
||||
productMid := 0
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
productMid = ch.MID
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"sectionId": gpr.ProductID,
|
||||
"sectionMid": productMid,
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
|
||||
func GiftPayCancel(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -356,7 +581,7 @@ func GiftPayCancel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
|
||||
return
|
||||
}
|
||||
if gpr.Status != "pending" {
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
@@ -365,7 +590,7 @@ func GiftPayCancel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
|
||||
}
|
||||
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的(含领取记录)
|
||||
func GiftPayMyRequests(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
@@ -375,45 +600,45 @@ func GiftPayMyRequests(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
db.Where("initiator_user_id = ? AND status != ?", userID, "cancelled").Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
// 领取记录:orders 表 gift_pay_request_id + product_type=section + payment_method=gift_pay
|
||||
var redeemList []gin.H
|
||||
var orders []model.Order
|
||||
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
r.ID, "section", "paid").Order("created_at ASC").Find(&orders) // 好友领取订单
|
||||
for _, o := range orders {
|
||||
if o.UserID == "" {
|
||||
continue
|
||||
}
|
||||
var u model.User
|
||||
nickname := "用户"
|
||||
avatar := ""
|
||||
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
|
||||
if u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
redeemAt := o.CreatedAt.Format("2006-01-02 15:04")
|
||||
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": redeemAt})
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// GiftPayMyPayments GET /api/miniprogram/gift-pay/my-payments?userId= 我帮付的
|
||||
func GiftPayMyPayments(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("payer_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"quantity": r.Quantity,
|
||||
"redeemedCount": r.RedeemedCount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
"redeemList": redeemList,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
@@ -479,6 +704,8 @@ func AdminGiftPayRequestsList(c *gin.Context) {
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"quantity": r.Quantity,
|
||||
"redeemedCount": r.RedeemedCount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"payerUserId": r.PayerUserID,
|
||||
|
||||
@@ -69,8 +69,8 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 创建新用户
|
||||
userID := openID // 直接使用 openid 作为用户 ID
|
||||
// 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突)
|
||||
userID := "user_" + randomSuffix()
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
@@ -408,9 +408,17 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// userID:优先用客户端传入;为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
if userID == "" && req.OpenID != "" {
|
||||
var u model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
|
||||
userID = u.ID
|
||||
} else {
|
||||
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
@@ -538,13 +546,38 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
|
||||
PT string `json:"pt"`
|
||||
PID string `json:"pid"`
|
||||
UID string `json:"uid"`
|
||||
SN string `json:"sn"`
|
||||
IP int `json:"ip"`
|
||||
}
|
||||
if attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
if attach.ProductType == "" {
|
||||
if attach.PT == "gpb" {
|
||||
attach.ProductType = "gift_pay_batch"
|
||||
} else {
|
||||
attach.ProductType = attach.PT
|
||||
}
|
||||
}
|
||||
if attach.ProductID == "" {
|
||||
attach.ProductID = attach.PID
|
||||
}
|
||||
if attach.UserID == "" {
|
||||
attach.UserID = attach.UID
|
||||
}
|
||||
if attach.GiftPayRequestSn == "" {
|
||||
attach.GiftPayRequestSn = attach.SN
|
||||
}
|
||||
if attach.IP != 0 {
|
||||
attach.GiftPayInitiatorPay = true
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
@@ -612,13 +645,23 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 代付订单:更新 gift_pay_request、订单 payer_user_id
|
||||
// 权益归属与分佣:代付时归发起人(order.UserID),普通订单归 buyerUserID
|
||||
beneficiaryUserID := buyerUserID
|
||||
if attach.GiftPayRequestSn != "" && order.UserID != "" {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单,权益归属发起人: %s\n", beneficiaryUserID)
|
||||
// 权益归属与分佣:旧版好友付归发起人;新版发起人付不发放权益(好友领取时再发)
|
||||
giftPayRequestSn := attach.GiftPayRequestSn
|
||||
if giftPayRequestSn == "" && order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("id = ?", *order.GiftPayRequestID).Select("request_sn").First(&gpr).Error; err == nil {
|
||||
giftPayRequestSn = gpr.RequestSN
|
||||
}
|
||||
}
|
||||
if attach.GiftPayRequestSn != "" {
|
||||
beneficiaryUserID := buyerUserID
|
||||
if giftPayRequestSn != "" && order.UserID != "" && !attach.GiftPayInitiatorPay {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单(好友付),权益归属发起人: %s\n", beneficiaryUserID)
|
||||
}
|
||||
if attach.GiftPayInitiatorPay {
|
||||
fmt.Printf("[PayNotify] 代付订单(发起人付),不发放权益,好友领取时再发\n")
|
||||
}
|
||||
if giftPayRequestSn != "" {
|
||||
var payerUserID string
|
||||
if openID != "" {
|
||||
var payer model.User
|
||||
@@ -627,7 +670,7 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
db.Model(&order).Update("payer_user_id", payerUserID)
|
||||
}
|
||||
}
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", giftPayRequestSn).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"payer_user_id": payerUserID,
|
||||
|
||||
@@ -664,6 +664,11 @@ func MiniprogramTrackPost(c *gin.Context) {
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if body.ExtraData != nil {
|
||||
if b, err := json.Marshal(body.ExtraData); err == nil {
|
||||
t.ExtraData = b
|
||||
}
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -54,6 +54,8 @@ func WechatPhoneLogin(c *gin.Context) {
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
|
||||
userID := "user_" + randomSuffix()
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
@@ -67,7 +69,7 @@ func WechatPhoneLogin(c *gin.Context) {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
user = model.User{
|
||||
ID: openID,
|
||||
ID: userID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
|
||||
@@ -2,7 +2,8 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GiftPayRequest 代付请求表(美团式:发起人创建,好友支付,权益归发起人)
|
||||
// GiftPayRequest 代付请求表(改造后:发起人创建并支付,好友领取)
|
||||
// status: pending_pay(待发起人支付)| paid(已支付待领取)| cancelled | expired
|
||||
type GiftPayRequest struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
RequestSN string `gorm:"column:request_sn;uniqueIndex;size:32" json:"requestSn"`
|
||||
@@ -11,7 +12,9 @@ type GiftPayRequest struct {
|
||||
ProductID string `gorm:"column:product_id;size:50" json:"productId"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending / paid / cancelled / expired
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending_pay / paid / cancelled / expired
|
||||
Quantity int `gorm:"column:quantity;default:1" json:"quantity"`
|
||||
RedeemedCount int `gorm:"column:redeemed_count;default:0" json:"redeemedCount"`
|
||||
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
|
||||
OrderID *string `gorm:"column:order_id;size:50" json:"orderId,omitempty"`
|
||||
ExpireAt time.Time `gorm:"column:expire_at" json:"expireAt"`
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
// 软删除:管理端删除仅设置 deleted_at,用户再次登录会创建新账号
|
||||
type User struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
@@ -51,6 +56,9 @@ type User struct {
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
|
||||
// 软删除:管理端假删除,用户再次登录会新建账号
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
|
||||
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
|
||||
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"soul-api/internal/redis"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
_ = r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
|
||||
r.Use(middleware.Secure())
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.CORSOrigins,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
@@ -93,6 +95,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
|
||||
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
|
||||
admin.GET("/balance/summary", handler.AdminBalanceSummary)
|
||||
admin.GET("/users", handler.AdminUsersList)
|
||||
admin.POST("/users", handler.AdminUsersAction)
|
||||
admin.PUT("/users", handler.AdminUsersAction)
|
||||
@@ -108,6 +111,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
api.POST("/auth/reset-password", handler.AuthResetPassword)
|
||||
|
||||
// ----- 书籍/章节(只读,写操作由 /api/db/book 管理端路由承担) -----
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part,保留以兼容 next-project/管理端
|
||||
api.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
api.GET("/book/parts", handler.BookParts)
|
||||
api.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
@@ -131,7 +135,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
// ----- 配置 -----
|
||||
api.GET("/config", handler.GetConfig)
|
||||
// 小程序用:GET /api/db/config 返回 freeChapters、prices(不鉴权,先于 db 组匹配)
|
||||
// Deprecated: 小程序已迁移至 /miniprogram/config/core + audit-mode + read-extras,保留以兼容 next-project
|
||||
api.GET("/db/config", handler.GetPublicDBConfig)
|
||||
|
||||
// ----- 内容 -----
|
||||
@@ -274,6 +278,11 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
|
||||
miniprogram := api.Group("/miniprogram")
|
||||
{
|
||||
// config 拆分接口(优先匹配,路径更具体)
|
||||
miniprogram.GET("/config/audit-mode", handler.GetAuditMode)
|
||||
miniprogram.GET("/config/core", handler.GetCoreConfig)
|
||||
miniprogram.GET("/config/read-extras", handler.GetReadExtras)
|
||||
// Deprecated: 保留以兼容线上,计划迁移至上述拆分接口
|
||||
miniprogram.GET("/config", handler.GetPublicDBConfig)
|
||||
miniprogram.POST("/login", handler.MiniprogramLogin)
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
@@ -284,11 +293,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调,URL 需在商户平台配置
|
||||
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
|
||||
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part
|
||||
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
miniprogram.GET("/book/parts", handler.BookParts)
|
||||
miniprogram.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
miniprogram.GET("/book/chapter/by-id/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/hot", handler.BookHot)
|
||||
miniprogram.GET("/book/recommended", handler.BookRecommended)
|
||||
miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters)
|
||||
@@ -349,13 +359,13 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/balance/refund", handler.BalanceRefundPost)
|
||||
miniprogram.POST("/balance/consume", handler.BalanceConsumePost)
|
||||
miniprogram.GET("/gift/link", handler.GiftLinkGet)
|
||||
// 代付(美团式:代付页面)
|
||||
// 代付(改造后:发起人支付,好友领取)
|
||||
miniprogram.POST("/gift-pay/create", handler.GiftPayCreate)
|
||||
miniprogram.POST("/gift-pay/initiator-pay", handler.GiftPayInitiatorPay)
|
||||
miniprogram.GET("/gift-pay/detail", handler.GiftPayDetail)
|
||||
miniprogram.POST("/gift-pay/pay", handler.GiftPayPay)
|
||||
miniprogram.POST("/gift-pay/redeem", handler.GiftPayRedeem)
|
||||
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
|
||||
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
|
||||
miniprogram.GET("/gift-pay/my-payments", handler.GiftPayMyPayments)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
@@ -396,8 +406,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
"database": dbStatus,
|
||||
"redis": redisStatus,
|
||||
})
|
||||
|
||||
@@ -299,7 +299,18 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int,
|
||||
return "", err
|
||||
}
|
||||
if res == nil || res.PrepayID == "" {
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空")
|
||||
// 微信 v3 错误时可能返回 code+message,或 err_code+err_code_des
|
||||
detail := "res=nil"
|
||||
if res != nil {
|
||||
if res.Code != "" || res.Message != "" {
|
||||
detail = fmt.Sprintf("code=%s message=%s", res.Code, res.Message)
|
||||
} else if res.ErrCode != "" || res.ErrCodeDes != "" {
|
||||
detail = fmt.Sprintf("err_code=%s err_code_des=%s", res.ErrCode, res.ErrCodeDes)
|
||||
} else {
|
||||
detail = fmt.Sprintf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空 (%s)", detail)
|
||||
}
|
||||
return res.PrepayID, nil
|
||||
}
|
||||
|
||||
6
soul-api/scripts/add-gift-pay-quantity.sql
Normal file
6
soul-api/scripts/add-gift-pay-quantity.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 代付逻辑改造:gift_pay_requests 增加 quantity、redeemed_count
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-gift-pay-quantity.sql
|
||||
-- 若列已存在会报 Duplicate column,可忽略
|
||||
|
||||
ALTER TABLE gift_pay_requests ADD COLUMN quantity INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE gift_pay_requests ADD COLUMN redeemed_count INT NOT NULL DEFAULT 0;
|
||||
5
soul-api/scripts/add-users-soft-delete.sql
Normal file
5
soul-api/scripts/add-users-soft-delete.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 用户软删除:管理端假删除,用户再次登录会新建账号
|
||||
-- 执行后,DELETE 操作改为 SET deleted_at,不再物理删除,避免外键约束
|
||||
|
||||
ALTER TABLE users ADD COLUMN deleted_at DATETIME(3) NULL DEFAULT NULL COMMENT '软删除时间' AFTER updated_at;
|
||||
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
|
||||
5
soul-api/scripts/fix-part-titles.sql
Normal file
5
soul-api/scripts/fix-part-titles.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 修复篇章标题:将 slug 形式的 part_title 更新为展示标题(数据来源:DB)
|
||||
-- 执行:node .cursor/scripts/db-exec/run.js -f soul-api/scripts/fix-part-titles.sql
|
||||
|
||||
-- part-2026-daily 的标题应为「2026每日派对干货」
|
||||
UPDATE chapters SET part_title = '2026每日派对干货' WHERE part_id = 'part-2026-daily' AND (part_title = 'part-2026-daily' OR part_title = '' OR part_title IS NULL);
|
||||
37459
soul-api/wechat/info.log
Normal file
37459
soul-api/wechat/info.log
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user