r.slice(1,-1).indexOf(":")===-1?void 0:(()=>{const a=r.slice(1,-1),l=a.indexOf(":"),i=a.slice(0,l);return i?V1+i:void 0})(),K1=r=>{const{theme:a,classGroups:l}=r;return G1(l,a)},G1=(r,a)=>{const l=vp();for(const i in r){const c=r[i];_u(c,l,i,a)}return l},_u=(r,a,l,i)=>{const c=r.length;for(let d=0;d r.slice(1,-1).indexOf(":")===-1?void 0:(()=>{const a=r.slice(1,-1),l=a.indexOf(":"),i=a.slice(0,l);return i?K1+i:void 0})(),Y1=r=>{const{theme:a,classGroups:l}=r;return X1(l,a)},X1=(r,a)=>{const l=yp();for(const i in r){const c=r[i];_u(c,l,i,a)}return l},_u=(r,a,l,i)=>{const c=r.length;for(let d=0;d
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
- {user.wechat_id && ` · 💬 ${user.wechat_id}`}
+ {user.wechatId && ` · 💬 ${user.wechatId}`}
- ID: {user.id} · 推广码: {user.referral_code}
+ ID: {user.id} · 推广码: {user.referralCode ?? '-'}
推荐人数 {user.referral_count || 0} {user.referralCount ?? 0} 待提现
- ¥{(user.pending_earnings || 0).toFixed(2)}
+ ¥{(user.pendingEarnings ?? 0).toFixed(2)}
创建时间
- {user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
+ {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
- {binding.referee_nickname || '匿名用户'}
+ {binding.refereeNickname || '匿名用户'}
{binding.referee_phone} {binding.refereePhone} {binding.referrer_name || '-'} {binding.referrerName || '-'}
- {binding.referrer_code}
+ {binding.referrerCode}
- {withdrawal.user_name || withdrawal.name}
+ {withdrawal.userName || withdrawal.name}
{user.nickname}
- {user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
+ {user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
- {w.userNickname ?? w.user_name ?? '未知'}
+ {w.userName ?? '未知'}
- {w.userPhone ?? w.referralCode ?? (w.user_id ?? w.userId ?? '').slice(0, 10)}
+ {w.userPhone ?? w.referralCode ?? (w.userId ?? '').slice(0, 10)}
{user.nickname}
- {user.is_admin && (
+ {user.isAdmin && (
- {binding.bound_at
- ? new Date(binding.bound_at).toLocaleDateString('zh-CN')
+ {binding.boundAt
+ ? new Date(binding.boundAt).toLocaleDateString('zh-CN')
: '-'}
- {binding.expires_at
- ? new Date(binding.expires_at).toLocaleDateString('zh-CN')
+ {binding.expiresAt
+ ? new Date(binding.expiresAt).toLocaleDateString('zh-CN')
: '-'}
{getStatusBadge(binding.status)}
@@ -874,11 +867,11 @@ export function DistributionPage() {
/>
) : (
- {(withdrawal.created_at || withdrawal.createdAt)
- ? new Date(
- withdrawal.created_at || withdrawal.createdAt || '',
- ).toLocaleString('zh-CN')
+ {withdrawal.createdAt
+ ? new Date(withdrawal.createdAt).toLocaleString('zh-CN')
: '-'}
{getStatusBadge(withdrawal.status)}
diff --git a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx
index 4061e000..15c7d2ab 100644
--- a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx
+++ b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx
@@ -60,7 +60,7 @@ export function ReferralSettingsPage() {
}
const body = {
key: 'referral_config',
- config: safeConfig,
+ value: safeConfig,
description: '分销 / 推广规则配置',
}
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', body)
diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx
index 47279426..b0851e81 100644
--- a/soul-admin/src/pages/settings/SettingsPage.tsx
+++ b/soul-admin/src/pages/settings/SettingsPage.tsx
@@ -115,13 +115,13 @@ function mergeFromConfigList(list: unknown[]): ReturnType
- {user.referral_code || '-'}
+ {user.referralCode || '-'}
- {new Date(w.created_at ?? w.createdAt ?? '').toLocaleString()}
+ {new Date(w.createdAt ?? '').toLocaleString()}
) : (
- {(w.processedAt ?? w.completed_at)
- ? new Date(w.processedAt ?? w.completed_at ?? '').toLocaleString()
- : '-'}
+ {w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
{(w.status === 'pending' || w.status === 'pending_confirm') && (
diff --git a/soul-admin/tsconfig.tsbuildinfo b/soul-admin/tsconfig.tsbuildinfo
index a42798bf..e9ca3789 100644
--- a/soul-admin/tsconfig.tsbuildinfo
+++ b/soul-admin/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
\ No newline at end of file
diff --git a/soul-api/.air.toml b/soul-api/.air.toml
new file mode 100644
index 00000000..788dff85
--- /dev/null
+++ b/soul-api/.air.toml
@@ -0,0 +1,23 @@
+# Air 热重载配置:改 .go 后自动重新编译并重启
+root = "."
+tmp_dir = "tmp"
+
+# Windows 下用 .exe 避免系统弹出「选择应用打开 main」
+[build]
+ bin = "./tmp/main.exe"
+ cmd = "go build -o ./tmp/main.exe ./cmd/server"
+ delay = 800
+ exclude_dir = ["tmp", "vendor"]
+ exclude_regex = ["_test\\.go$"]
+ include_ext = ["go", "tpl", "tmpl", "html"]
+ log = "build-errors.log"
+ stop_on_error = true
+
+[log]
+ time = false
+
+[misc]
+ clean_on_exit = true
+
+[screen]
+ clear_on_rebuild = false
diff --git a/soul-api/.env b/soul-api/.env
new file mode 100644
index 00000000..092f8207
--- /dev/null
+++ b/soul-api/.env
@@ -0,0 +1,12 @@
+# 服务
+PORT=8080
+GIN_MODE=debug
+
+# 数据air库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
+DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
+
+# 可选:管理端鉴权密钥(若用 JWT)
+# JWT_SECRET=your-secret
+
+# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
+# TRUSTED_PROXIES=127.0.0.1,::1
diff --git a/soul-api/.env.example b/soul-api/.env.example
new file mode 100644
index 00000000..25561bde
--- /dev/null
+++ b/soul-api/.env.example
@@ -0,0 +1,12 @@
+# 服务
+PORT=8080
+GIN_MODE=debug
+
+# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
+DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
+
+# 可选:管理端鉴权密钥(若用 JWT)
+# JWT_SECRET=your-secret
+
+# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
+# TRUSTED_PROXIES=127.0.0.1,::1
diff --git a/soul-api/Makefile b/soul-api/Makefile
new file mode 100644
index 00000000..f4970f68
--- /dev/null
+++ b/soul-api/Makefile
@@ -0,0 +1,9 @@
+# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest)
+dev:
+ air
+
+# 普通运行(无热重载)
+run:
+ go run ./cmd/server
+
+.PHONY: dev run
diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go
new file mode 100644
index 00000000..1b22c815
--- /dev/null
+++ b/soul-api/cmd/server/main.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "soul-api/internal/config"
+ "soul-api/internal/database"
+ "soul-api/internal/router"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatal("load config: ", err)
+ }
+ if err := database.Init(cfg.DBDSN); err != nil {
+ log.Fatal("database: ", err)
+ }
+
+ r := router.Setup(cfg)
+ srv := &http.Server{
+ Addr: ":" + cfg.Port,
+ Handler: r,
+ }
+
+ go func() {
+ log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
+ log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal("listen: ", err)
+ }
+ }()
+
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+ log.Println("shutting down...")
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Fatal("server shutdown: ", err)
+ }
+ log.Println("bye")
+}
diff --git a/soul-api/go.mod b/soul-api/go.mod
new file mode 100644
index 00000000..cbc07e23
--- /dev/null
+++ b/soul-api/go.mod
@@ -0,0 +1,46 @@
+module soul-api
+
+go 1.25
+
+require (
+ github.com/gin-contrib/cors v1.7.2
+ github.com/gin-gonic/gin v1.10.0
+ github.com/joho/godotenv v1.5.1
+ github.com/unrolled/secure v1.17.0
+ golang.org/x/time v0.8.0
+ gorm.io/driver/mysql v1.5.7
+ gorm.io/gorm v1.25.12
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/go-sql-driver/mysql v1.7.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/kr/text v0.2.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/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/net v0.25.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/protobuf v1.34.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/soul-api/go.sum b/soul-api/go.sum
new file mode 100644
index 00000000..446ace80
--- /dev/null
+++ b/soul-api/go.sum
@@ -0,0 +1,116 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/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/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
+github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
+github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+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/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go
new file mode 100644
index 00000000..e62cdcab
--- /dev/null
+++ b/soul-api/internal/config/config.go
@@ -0,0 +1,42 @@
+package config
+
+import (
+ "os"
+
+ "github.com/joho/godotenv"
+)
+
+// Config 应用配置(从环境变量读取)
+type Config struct {
+ Port string
+ Mode string
+ DBDSN string
+ TrustedProxies []string
+ CORSOrigins []string
+}
+
+// Load 加载配置,开发环境可读 .env
+func Load() (*Config, error) {
+ _ = godotenv.Load()
+
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+ mode := os.Getenv("GIN_MODE")
+ if mode == "" {
+ mode = "debug"
+ }
+ dsn := os.Getenv("DB_DSN")
+ if dsn == "" {
+ dsn = "user:pass@tcp(127.0.0.1:3306)/soul?charset=utf8mb4&parseTime=True"
+ }
+
+ return &Config{
+ Port: port,
+ Mode: mode,
+ DBDSN: dsn,
+ TrustedProxies: []string{"127.0.0.1", "::1"},
+ CORSOrigins: []string{"http://localhost:5174", "http://127.0.0.1:5174", "https://soul.quwanzhi.com"},
+ }, nil
+}
diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go
new file mode 100644
index 00000000..6c069244
--- /dev/null
+++ b/soul-api/internal/database/database.go
@@ -0,0 +1,26 @@
+package database
+
+import (
+ "log"
+
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+)
+
+var db *gorm.DB
+
+// Init 使用 DSN 连接 MySQL,供 handler 通过 DB() 使用
+func Init(dsn string) error {
+ var err error
+ db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
+ if err != nil {
+ return err
+ }
+ log.Println("database: connected")
+ return nil
+}
+
+// DB 返回全局 *gorm.DB,仅在 Init 成功后调用
+func DB() *gorm.DB {
+ return db
+}
diff --git a/soul-api/internal/handler/admin.go b/soul-api/internal/handler/admin.go
new file mode 100644
index 00000000..3306ae92
--- /dev/null
+++ b/soul-api/internal/handler/admin.go
@@ -0,0 +1,33 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminCheck GET /api/admin 鉴权检查
+func AdminCheck(c *gin.Context) {
+ // TODO: 校验 session/token,返回 success: true 或 401
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// AdminLogin POST /api/admin 登录
+func AdminLogin(c *gin.Context) {
+ var body struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
+ return
+ }
+ // TODO: 校验用户名密码,写 session,返回 success
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// AdminLogout POST /api/admin/logout
+func AdminLogout(c *gin.Context) {
+ // TODO: 清除 session
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/admin_chapters.go b/soul-api/internal/handler/admin_chapters.go
new file mode 100644
index 00000000..1101e53e
--- /dev/null
+++ b/soul-api/internal/handler/admin_chapters.go
@@ -0,0 +1,101 @@
+package handler
+
+import (
+ "net/http"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
+func AdminChaptersList(c *gin.Context) {
+ var list []model.Chapter
+ if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
+ return
+ }
+ type section struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Price float64 `json:"price"`
+ IsFree bool `json:"isFree"`
+ Status string `json:"status"`
+ }
+ type chapter struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Sections []section `json:"sections"`
+ }
+ type part struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ Chapters []chapter `json:"chapters"`
+ }
+ partMap := make(map[string]*part)
+ chapterMap := make(map[string]map[string]*chapter)
+ for _, row := range list {
+ if partMap[row.PartID] == nil {
+ partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
+ chapterMap[row.PartID] = make(map[string]*chapter)
+ }
+ p := partMap[row.PartID]
+ if chapterMap[row.PartID][row.ChapterID] == nil {
+ ch := chapter{ID: row.ChapterID, Title: row.ChapterTitle, Sections: []section{}}
+ p.Chapters = append(p.Chapters, ch)
+ chapterMap[row.PartID][row.ChapterID] = &p.Chapters[len(p.Chapters)-1]
+ }
+ ch := chapterMap[row.PartID][row.ChapterID]
+ price := 1.0
+ if row.Price != nil {
+ price = *row.Price
+ }
+ isFree := false
+ if row.IsFree != nil {
+ isFree = *row.IsFree
+ }
+ st := "published"
+ if row.Status != nil {
+ st = *row.Status
+ }
+ ch.Sections = append(ch.Sections, section{ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st})
+ }
+ structure := make([]part, 0, len(partMap))
+ for _, p := range partMap {
+ structure = append(structure, *p)
+ }
+ var total int64
+ database.DB().Model(&model.Chapter{}).Count(&total)
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
+ })
+}
+
+// AdminChaptersAction POST/PUT/DELETE /api/admin/chapters
+func AdminChaptersAction(c *gin.Context) {
+ var body struct {
+ Action string `json:"action"`
+ ID string `json:"id"`
+ Price *float64 `json:"price"`
+ IsFree *bool `json:"isFree"`
+ Status *string `json:"status"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
+ return
+ }
+ db := database.DB()
+ if body.Action == "updatePrice" && body.ID != "" && body.Price != nil {
+ db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price)
+ }
+ if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil {
+ db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("is_free", *body.IsFree)
+ }
+ if body.Action == "updateStatus" && body.ID != "" && body.Status != nil {
+ db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("status", *body.Status)
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/admin_distribution.go b/soul-api/internal/handler/admin_distribution.go
new file mode 100644
index 00000000..2fb89d1a
--- /dev/null
+++ b/soul-api/internal/handler/admin_distribution.go
@@ -0,0 +1,99 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminDistributionOverview GET /api/admin/distribution/overview(全部使用 GORM,无 Raw SQL)
+func AdminDistributionOverview(c *gin.Context) {
+ now := time.Now()
+ todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ todayEnd := todayStart.Add(24 * time.Hour)
+ monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+ db := database.DB()
+ overview := gin.H{
+ "todayClicks": 0, "todayBindings": 0, "todayConversions": 0, "todayEarnings": 0,
+ "monthClicks": 0, "monthBindings": 0, "monthConversions": 0, "monthEarnings": 0,
+ "totalClicks": 0, "totalBindings": 0, "totalConversions": 0, "totalEarnings": 0,
+ "expiringBindings": 0, "pendingWithdrawals": 0, "pendingWithdrawAmount": 0,
+ "conversionRate": "0.00", "totalDistributors": 0, "activeDistributors": 0,
+ }
+
+ // 订单:仅用 Where + Count / Select(Sum) 参数化
+ var totalOrders int64
+ db.Model(&model.Order{}).Where("status = ?", "paid").Count(&totalOrders)
+ var totalAmount float64
+ db.Model(&model.Order{}).Where("status = ?", "paid").Select("COALESCE(SUM(amount),0)").Scan(&totalAmount)
+ var todayOrders int64
+ db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Count(&todayOrders)
+ var todayAmount float64
+ db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Select("COALESCE(SUM(amount),0)").Scan(&todayAmount)
+ var monthOrders int64
+ db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Count(&monthOrders)
+ var monthAmount float64
+ db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Select("COALESCE(SUM(amount),0)").Scan(&monthAmount)
+ overview["totalEarnings"] = totalAmount
+ overview["todayEarnings"] = todayAmount
+ overview["monthEarnings"] = monthAmount
+
+ // 绑定:全部 GORM Where
+ var totalBindings int64
+ db.Model(&model.ReferralBinding{}).Count(&totalBindings)
+ var converted int64
+ db.Model(&model.ReferralBinding{}).Where("status = ?", "converted").Count(&converted)
+ var todayBindings int64
+ db.Model(&model.ReferralBinding{}).Where("binding_date >= ? AND binding_date < ?", todayStart, todayEnd).Count(&todayBindings)
+ var todayConv int64
+ db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ? AND binding_date < ?", "converted", todayStart, todayEnd).Count(&todayConv)
+ var monthBindings int64
+ db.Model(&model.ReferralBinding{}).Where("binding_date >= ?", monthStart).Count(&monthBindings)
+ var monthConv int64
+ db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ?", "converted", monthStart).Count(&monthConv)
+ expiringEnd := now.Add(7 * 24 * time.Hour)
+ var expiring int64
+ db.Model(&model.ReferralBinding{}).Where("status = ? AND expiry_date > ? AND expiry_date <= ?", "active", now, expiringEnd).Count(&expiring)
+ overview["totalBindings"] = totalBindings
+ overview["totalConversions"] = converted
+ overview["todayBindings"] = todayBindings
+ overview["todayConversions"] = todayConv
+ overview["monthBindings"] = monthBindings
+ overview["monthConversions"] = monthConv
+ overview["expiringBindings"] = expiring
+
+ // 访问数
+ var visitTotal int64
+ db.Model(&model.ReferralVisit{}).Count(&visitTotal)
+ overview["totalClicks"] = visitTotal
+ if visitTotal > 0 && converted > 0 {
+ overview["conversionRate"] = formatPercent(float64(converted)/float64(visitTotal)*100)
+ }
+
+ // 提现待处理
+ var pendCount int64
+ db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Count(&pendCount)
+ var pendSum float64
+ db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Select("COALESCE(SUM(amount),0)").Scan(&pendSum)
+ overview["pendingWithdrawals"] = pendCount
+ overview["pendingWithdrawAmount"] = pendSum
+
+ // 分销商
+ var distTotal int64
+ db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ?", "").Count(&distTotal)
+ var distActive int64
+ db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ? AND earnings > ?", "", 0).Count(&distActive)
+ overview["totalDistributors"] = distTotal
+ overview["activeDistributors"] = distActive
+
+ c.JSON(http.StatusOK, gin.H{"success": true, "overview": overview})
+}
+
+func formatPercent(v float64) string {
+ return fmt.Sprintf("%.2f", v) + "%"
+}
diff --git a/soul-api/internal/handler/admin_extra.go b/soul-api/internal/handler/admin_extra.go
new file mode 100644
index 00000000..caa50181
--- /dev/null
+++ b/soul-api/internal/handler/admin_extra.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminContent GET/POST/PUT/DELETE /api/admin/content
+func AdminContent(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// AdminPayment GET/POST/PUT/DELETE /api/admin/payment
+func AdminPayment(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// AdminReferral GET/POST/PUT/DELETE /api/admin/referral
+func AdminReferral(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/admin_withdrawals.go b/soul-api/internal/handler/admin_withdrawals.go
new file mode 100644
index 00000000..f867722d
--- /dev/null
+++ b/soul-api/internal/handler/admin_withdrawals.go
@@ -0,0 +1,119 @@
+package handler
+
+import (
+ "net/http"
+ "time"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminWithdrawalsList GET /api/admin/withdrawals
+func AdminWithdrawalsList(c *gin.Context) {
+ statusFilter := c.Query("status")
+ var list []model.Withdrawal
+ q := database.DB().Order("created_at DESC").Limit(100)
+ if statusFilter != "" {
+ q = q.Where("status = ?", statusFilter)
+ }
+ if err := q.Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})
+ return
+ }
+ userIds := make([]string, 0, len(list))
+ seen := make(map[string]bool)
+ for _, w := range list {
+ if !seen[w.UserID] {
+ seen[w.UserID] = true
+ userIds = append(userIds, w.UserID)
+ }
+ }
+ var users []model.User
+ if len(userIds) > 0 {
+ database.DB().Where("id IN ?", userIds).Find(&users)
+ }
+ userMap := make(map[string]*model.User)
+ for i := range users {
+ userMap[users[i].ID] = &users[i]
+ }
+ withdrawals := make([]gin.H, 0, len(list))
+ for _, w := range list {
+ u := userMap[w.UserID]
+ userName := "未知用户"
+ var userAvatar *string
+ account := "未绑定微信号"
+ if w.WechatID != nil && *w.WechatID != "" {
+ account = *w.WechatID
+ }
+ if u != nil {
+ if u.Nickname != nil {
+ userName = *u.Nickname
+ }
+ userAvatar = u.Avatar
+ if u.WechatID != nil && *u.WechatID != "" {
+ account = *u.WechatID
+ }
+ }
+ st := "pending"
+ if w.Status != nil {
+ st = *w.Status
+ if st == "success" {
+ st = "completed"
+ } else if st == "failed" {
+ st = "rejected"
+ } else if st == "pending_confirm" {
+ st = "pending_confirm"
+ }
+ }
+ withdrawals = append(withdrawals, gin.H{
+ "id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
+ "amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
+ "method": "wechat", "account": account,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "withdrawals": withdrawals, "stats": gin.H{"total": len(withdrawals)}})
+}
+
+// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
+func AdminWithdrawalsAction(c *gin.Context) {
+ var body struct {
+ ID string `json:"id"`
+ Action string `json:"action"`
+ ErrorMessage string `json:"errorMessage"`
+ Reason string `json:"reason"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
+ return
+ }
+ reason := body.ErrorMessage
+ if reason == "" {
+ reason = body.Reason
+ }
+ if reason == "" && body.Action == "reject" {
+ reason = "管理员拒绝"
+ }
+ var newStatus string
+ switch body.Action {
+ case "approve":
+ newStatus = "success"
+ case "reject":
+ newStatus = "failed"
+ default:
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
+ return
+ }
+ now := time.Now()
+ err := database.DB().Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
+ "status": newStatus,
+ "error_message": reason,
+ "processed_at": now,
+ }).Error
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "操作成功"})
+}
diff --git a/soul-api/internal/handler/auth.go b/soul-api/internal/handler/auth.go
new file mode 100644
index 00000000..9c72e9eb
--- /dev/null
+++ b/soul-api/internal/handler/auth.go
@@ -0,0 +1,17 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AuthLogin POST /api/auth/login
+func AuthLogin(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// AuthResetPassword POST /api/auth/reset-password
+func AuthResetPassword(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go
new file mode 100644
index 00000000..8bab20e0
--- /dev/null
+++ b/soul-api/internal/handler/book.go
@@ -0,0 +1,160 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
+func BookAllChapters(c *gin.Context) {
+ var list []model.Chapter
+ if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
+}
+
+// BookChapterByID GET /api/book/chapter/:id
+func BookChapterByID(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
+ return
+ }
+ var ch model.Chapter
+ if err := database.DB().Where("id = ?", id).First(&ch).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": ch})
+}
+
+// BookChapters GET/POST/PUT/DELETE /api/book/chapters(与 app/api/book/chapters 一致,用 GORM)
+func BookChapters(c *gin.Context) {
+ db := database.DB()
+ switch c.Request.Method {
+ case http.MethodGet:
+ partId := c.Query("partId")
+ status := c.Query("status")
+ if status == "" {
+ status = "published"
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100"))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 500 {
+ pageSize = 100
+ }
+ q := db.Model(&model.Chapter{})
+ if partId != "" {
+ q = q.Where("part_id = ?", partId)
+ }
+ if status != "" && status != "all" {
+ q = q.Where("status = ?", status)
+ }
+ var total int64
+ q.Count(&total)
+ var list []model.Chapter
+ q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
+ totalPages := int(total) / pageSize
+ if int(total)%pageSize > 0 {
+ totalPages++
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{
+ "list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
+ },
+ })
+ return
+ case http.MethodPost:
+ var body model.Chapter
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
+ return
+ }
+ if body.ID == "" || body.PartID == "" || body.ChapterID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"})
+ return
+ }
+ if err := db.Create(&body).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
+ return
+ case http.MethodPut:
+ var body model.Chapter
+ if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
+ return
+ }
+ if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
+ "part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
+ "content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
+ "sort_order": body.SortOrder, "status": body.Status,
+ }).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+ return
+ case http.MethodDelete:
+ id := c.Query("id")
+ if id == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
+ return
+ }
+ if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
+}
+
+// BookHot GET /api/book/hot
+func BookHot(c *gin.Context) {
+ var list []model.Chapter
+ database.DB().Order("sort_order ASC, id ASC").Limit(10).Find(&list)
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
+}
+
+// BookLatestChapters GET /api/book/latest-chapters
+func BookLatestChapters(c *gin.Context) {
+ var list []model.Chapter
+ database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
+}
+
+// BookSearch GET /api/book/search 同 /api/search,由 SearchGet 处理
+func BookSearch(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+}
+
+// BookStats GET /api/book/stats
+func BookStats(c *gin.Context) {
+ var total int64
+ database.DB().Model(&model.Chapter{}).Count(&total)
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
+}
+
+// BookSync GET/POST /api/book/sync
+func BookSync(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
+}
diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go
new file mode 100644
index 00000000..a5f1258f
--- /dev/null
+++ b/soul-api/internal/handler/ckb.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// CKBJoin POST /api/ckb/join
+func CKBJoin(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// CKBMatch POST /api/ckb/match
+func CKBMatch(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// CKBSync GET/POST /api/ckb/sync
+func CKBSync(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/config.go b/soul-api/internal/handler/config.go
new file mode 100644
index 00000000..d9347cb9
--- /dev/null
+++ b/soul-api/internal/handler/config.go
@@ -0,0 +1,63 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// GetConfig GET /api/config 从 system_config 读取并合并(与 app/api/config 结构一致)
+func GetConfig(c *gin.Context) {
+ var list []model.SystemConfig
+ if err := database.DB().Order("config_key ASC").Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
+ "siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
+ })
+ return
+ }
+ out := gin.H{
+ "success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
+ "siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
+ "authorInfo": gin.H{}, "marketing": gin.H{}, "system": gin.H{},
+ }
+ for _, row := range list {
+ var val interface{}
+ _ = json.Unmarshal(row.ConfigValue, &val)
+ switch row.ConfigKey {
+ case "site_config", "siteConfig":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["siteConfig"] = m
+ }
+ case "menu_config", "menuConfig":
+ out["menuConfig"] = val
+ case "page_config", "pageConfig":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["pageConfig"] = m
+ }
+ case "payment_methods", "paymentMethods":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["paymentMethods"] = m
+ }
+ case "live_qr_codes", "liveQRCodes":
+ out["liveQRCodes"] = val
+ case "author_info", "authorInfo":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["authorInfo"] = m
+ }
+ case "marketing":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["marketing"] = m
+ }
+ case "system":
+ if m, ok := val.(map[string]interface{}); ok {
+ out["system"] = m
+ }
+ }
+ }
+ c.JSON(http.StatusOK, out)
+}
diff --git a/soul-api/internal/handler/content.go b/soul-api/internal/handler/content.go
new file mode 100644
index 00000000..1a617739
--- /dev/null
+++ b/soul-api/internal/handler/content.go
@@ -0,0 +1,12 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ContentGet GET /api/content
+func ContentGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/cron.go b/soul-api/internal/handler/cron.go
new file mode 100644
index 00000000..5aaded9b
--- /dev/null
+++ b/soul-api/internal/handler/cron.go
@@ -0,0 +1,17 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// CronSyncOrders GET/POST /api/cron/sync-orders
+func CronSyncOrders(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// CronUnbindExpired GET/POST /api/cron/unbind-expired
+func CronUnbindExpired(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go
new file mode 100644
index 00000000..49836bf2
--- /dev/null
+++ b/soul-api/internal/handler/db.go
@@ -0,0 +1,391 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// DBConfigGet GET /api/db/config
+func DBConfigGet(c *gin.Context) {
+ key := c.Query("key")
+ db := database.DB()
+ var list []model.SystemConfig
+ q := db.Table("system_config")
+ if key != "" {
+ q = q.Where("config_key = ?", key)
+ }
+ if err := q.Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ if key != "" && len(list) == 1 {
+ var val interface{}
+ _ = json.Unmarshal(list[0].ConfigValue, &val)
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
+ return
+ }
+ data := make([]gin.H, 0, len(list))
+ for _, row := range list {
+ var val interface{}
+ _ = json.Unmarshal(row.ConfigValue, &val)
+ data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val})
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
+}
+
+// DBConfigPost POST /api/db/config
+func DBConfigPost(c *gin.Context) {
+ var body struct {
+ Key string `json:"key"`
+ Value interface{} `json:"value"`
+ Description string `json:"description"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
+ return
+ }
+ valBytes, err := json.Marshal(body.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ db := database.DB()
+ desc := body.Description
+ var row model.SystemConfig
+ err = db.Where("config_key = ?", body.Key).First(&row).Error
+ if err != nil {
+ row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc}
+ err = db.Create(&row).Error
+ } else {
+ row.ConfigValue = valBytes
+ if body.Description != "" {
+ row.Description = &desc
+ }
+ err = db.Save(&row).Error
+ }
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
+}
+
+// DBUsersList GET /api/db/users
+func DBUsersList(c *gin.Context) {
+ var users []model.User
+ if err := database.DB().Find(&users).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "users": users})
+}
+
+// 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"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
+ return
+ }
+ userID := "user_" + randomSuffix()
+ code := "SOUL" + randomSuffix()[:4]
+ nick := "用户"
+ if body.Nickname != nil && *body.Nickname != "" {
+ nick = *body.Nickname
+ } else {
+ nick = nick + userID[len(userID)-4:]
+ }
+ u := model.User{
+ ID: userID, Nickname: &nick, ReferralCode: &code,
+ OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar,
+ }
+ if body.IsAdmin != nil {
+ u.IsAdmin = body.IsAdmin
+ }
+ if err := db.Create(&u).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
+ return
+ }
+ // PUT 更新
+ var body struct {
+ ID string `json:"id"`
+ Nickname *string `json:"nickname"`
+ Phone *string `json:"phone"`
+ WechatID *string `json:"wechatId"`
+ Avatar *string `json:"avatar"`
+ HasFullBook *bool `json:"hasFullBook"`
+ IsAdmin *bool `json:"isAdmin"`
+ Earnings *float64 `json:"earnings"`
+ PendingEarnings *float64 `json:"pendingEarnings"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
+ return
+ }
+ updates := map[string]interface{}{}
+ if body.Nickname != nil {
+ updates["nickname"] = *body.Nickname
+ }
+ if body.Phone != nil {
+ updates["phone"] = *body.Phone
+ }
+ if body.WechatID != nil {
+ updates["wechat_id"] = *body.WechatID
+ }
+ if body.Avatar != nil {
+ updates["avatar"] = *body.Avatar
+ }
+ if body.HasFullBook != nil {
+ updates["has_full_book"] = *body.HasFullBook
+ }
+ if body.IsAdmin != nil {
+ updates["is_admin"] = *body.IsAdmin
+ }
+ if body.Earnings != nil {
+ updates["earnings"] = *body.Earnings
+ }
+ if body.PendingEarnings != nil {
+ updates["pending_earnings"] = *body.PendingEarnings
+ }
+ if len(updates) == 0 {
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
+ return
+ }
+ if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"})
+}
+
+func randomSuffix() string {
+ return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
+}
+
+// DBUsersDelete DELETE /api/db/users
+func DBUsersDelete(c *gin.Context) {
+ id := c.Query("id")
+ if id == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
+ return
+ }
+ if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
+}
+
+// DBUsersReferrals GET /api/db/users/referrals
+func DBUsersReferrals(c *gin.Context) {
+ userId := c.Query("userId")
+ if userId == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
+ return
+ }
+ db := database.DB()
+ var bindings []model.ReferralBinding
+ if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "referrals": []interface{}{}, "stats": gin.H{"total": 0, "purchased": 0, "free": 0, "earnings": 0, "pendingEarnings": 0, "withdrawnEarnings": 0}})
+ return
+ }
+ refereeIds := make([]string, 0, len(bindings))
+ for _, b := range bindings {
+ refereeIds = append(refereeIds, b.RefereeID)
+ }
+ var users []model.User
+ if len(refereeIds) > 0 {
+ db.Where("id IN ?", refereeIds).Find(&users)
+ }
+ userMap := make(map[string]*model.User)
+ for i := range users {
+ userMap[users[i].ID] = &users[i]
+ }
+ referrals := make([]gin.H, 0, len(bindings))
+ for _, b := range bindings {
+ u := userMap[b.RefereeID]
+ nick := "微信用户"
+ var avatar *string
+ var phone *string
+ hasFullBook := false
+ if u != nil {
+ if u.Nickname != nil {
+ nick = *u.Nickname
+ }
+ avatar, phone = u.Avatar, u.Phone
+ if u.HasFullBook != nil {
+ hasFullBook = *u.HasFullBook
+ }
+ }
+ status := "active"
+ if b.Status != nil {
+ status = *b.Status
+ }
+ daysRemaining := 0
+ if b.ExpiryDate.After(time.Now()) {
+ daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
+ }
+ referrals = append(referrals, gin.H{
+ "id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
+ "hasFullBook": hasFullBook || status == "converted",
+ "createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.CommissionAmount,
+ "status": status,
+ })
+ }
+ var referrer model.User
+ earningsE, pendingE, withdrawnE := 0.0, 0.0, 0.0
+ if err := db.Where("id = ?", userId).Select("earnings", "pending_earnings", "withdrawn_earnings").First(&referrer).Error; err == nil {
+ if referrer.Earnings != nil {
+ earningsE = *referrer.Earnings
+ }
+ if referrer.PendingEarnings != nil {
+ pendingE = *referrer.PendingEarnings
+ }
+ if referrer.WithdrawnEarnings != nil {
+ withdrawnE = *referrer.WithdrawnEarnings
+ }
+ }
+ purchased := 0
+ for _, b := range bindings {
+ u := userMap[b.RefereeID]
+ if (u != nil && u.HasFullBook != nil && *u.HasFullBook) || (b.Status != nil && *b.Status == "converted") {
+ purchased++
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true, "referrals": referrals,
+ "stats": gin.H{
+ "total": len(bindings), "purchased": purchased, "free": len(bindings) - purchased,
+ "earnings": earningsE, "pendingEarnings": pendingE, "withdrawnEarnings": withdrawnE,
+ },
+ })
+}
+
+// DBInit POST /api/db/init
+func DBInit(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})
+}
+
+// DBDistribution GET /api/db/distribution
+func DBDistribution(c *gin.Context) {
+ userId := c.Query("userId")
+ db := database.DB()
+ var bindings []model.ReferralBinding
+ q := db.Order("binding_date DESC").Limit(500)
+ if userId != "" {
+ q = q.Where("referrer_id = ?", userId)
+ }
+ if err := q.Find(&bindings).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0})
+ return
+ }
+ referrerIds := make(map[string]bool)
+ refereeIds := make(map[string]bool)
+ for _, b := range bindings {
+ referrerIds[b.ReferrerID] = true
+ refereeIds[b.RefereeID] = true
+ }
+ allIds := make([]string, 0, len(referrerIds)+len(refereeIds))
+ for id := range referrerIds {
+ allIds = append(allIds, id)
+ }
+ for id := range refereeIds {
+ if !referrerIds[id] {
+ allIds = append(allIds, id)
+ }
+ }
+ var users []model.User
+ if len(allIds) > 0 {
+ db.Where("id IN ?", allIds).Find(&users)
+ }
+ userMap := make(map[string]*model.User)
+ for i := range users {
+ userMap[users[i].ID] = &users[i]
+ }
+ out := make([]gin.H, 0, len(bindings))
+ for _, b := range bindings {
+ refNick := "用户"
+ if u := userMap[b.RefereeID]; u != nil && u.Nickname != nil {
+ refNick = *u.Nickname
+ } else {
+ refNick = refNick + b.RefereeID
+ }
+ var referrerName *string
+ if u := userMap[b.ReferrerID]; u != nil {
+ referrerName = u.Nickname
+ }
+ days := 0
+ if b.ExpiryDate.After(time.Now()) {
+ days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
+ }
+ var refereePhone *string
+ if u := userMap[b.RefereeID]; u != nil {
+ refereePhone = u.Phone
+ }
+ out = append(out, gin.H{
+ "id": b.ID, "referrer_id": b.ReferrerID, "referrer_name": referrerName, "referrer_code": b.ReferralCode,
+ "referee_id": b.RefereeID, "referee_nickname": refNick, "referee_phone": refereePhone,
+ "bound_at": b.BindingDate, "expires_at": b.ExpiryDate, "status": b.Status,
+ "days_remaining": days, "commission": b.CommissionAmount, "source": "miniprogram",
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "bindings": out, "total": len(out)})
+}
+
+// DBChapters GET/POST /api/db/chapters
+func DBChapters(c *gin.Context) {
+ var list []model.Chapter
+ if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
+}
+
+// DBConfigDelete DELETE /api/db/config
+func DBConfigDelete(c *gin.Context) {
+ key := c.Query("key")
+ if key == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
+ return
+ }
+ if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// DBInitGet GET /api/db/init
+func DBInitGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
+}
+
+// DBMigrateGet GET /api/db/migrate
+func DBMigrateGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"})
+}
+
+// DBMigratePost POST /api/db/migrate
+func DBMigratePost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"})
+}
diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go
new file mode 100644
index 00000000..6dea18b8
--- /dev/null
+++ b/soul-api/internal/handler/db_book.go
@@ -0,0 +1,247 @@
+package handler
+
+import (
+ "net/http"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
+type sectionListItem struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Price float64 `json:"price"`
+ IsFree *bool `json:"isFree,omitempty"`
+ PartID string `json:"partId"`
+ PartTitle string `json:"partTitle"`
+ ChapterID string `json:"chapterId"`
+ ChapterTitle string `json:"chapterTitle"`
+ FilePath *string `json:"filePath,omitempty"`
+}
+
+// DBBookAction GET/POST/PUT /api/db/book
+func DBBookAction(c *gin.Context) {
+ db := database.DB()
+ switch c.Request.Method {
+ case http.MethodGet:
+ action := c.Query("action")
+ id := c.Query("id")
+ switch action {
+ case "list":
+ var rows []model.Chapter
+ if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
+ return
+ }
+ sections := make([]sectionListItem, 0, len(rows))
+ for _, r := range rows {
+ price := 1.0
+ if r.Price != nil {
+ price = *r.Price
+ }
+ sections = append(sections, sectionListItem{
+ ID: r.ID,
+ Title: r.SectionTitle,
+ Price: price,
+ IsFree: r.IsFree,
+ PartID: r.PartID,
+ PartTitle: r.PartTitle,
+ ChapterID: r.ChapterID,
+ ChapterTitle: r.ChapterTitle,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
+ return
+ case "read":
+ if id == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
+ return
+ }
+ var ch model.Chapter
+ if err := db.Where("id = ?", id).First(&ch).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ price := 1.0
+ if ch.Price != nil {
+ price = *ch.Price
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "section": gin.H{
+ "id": ch.ID,
+ "title": ch.SectionTitle,
+ "price": price,
+ "content": ch.Content,
+ "partId": ch.PartID,
+ "partTitle": ch.PartTitle,
+ "chapterId": ch.ChapterID,
+ "chapterTitle": ch.ChapterTitle,
+ },
+ })
+ return
+ case "export":
+ var rows []model.Chapter
+ if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ sections := make([]sectionListItem, 0, len(rows))
+ for _, r := range rows {
+ price := 1.0
+ if r.Price != nil {
+ price = *r.Price
+ }
+ sections = append(sections, sectionListItem{
+ ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree,
+ PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections})
+ return
+ default:
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
+ return
+ }
+ case http.MethodPost:
+ var body struct {
+ Action string `json:"action"`
+ Data []importItem `json:"data"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
+ return
+ }
+ switch body.Action {
+ case "sync":
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
+ return
+ case "import":
+ imported, failed := 0, 0
+ for _, item := range body.Data {
+ price := 1.0
+ if item.Price != nil {
+ price = *item.Price
+ }
+ isFree := false
+ if item.IsFree != nil {
+ isFree = *item.IsFree
+ }
+ wordCount := len(item.Content)
+ status := "published"
+ ch := model.Chapter{
+ ID: item.ID,
+ PartID: strPtr(item.PartID, "part-1"),
+ PartTitle: strPtr(item.PartTitle, "未分类"),
+ ChapterID: strPtr(item.ChapterID, "chapter-1"),
+ ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
+ SectionTitle: item.Title,
+ Content: item.Content,
+ WordCount: &wordCount,
+ IsFree: &isFree,
+ Price: &price,
+ Status: &status,
+ }
+ err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
+ if err == gorm.ErrRecordNotFound {
+ err = db.Create(&ch).Error
+ } else if err == nil {
+ err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
+ "section_title": ch.SectionTitle,
+ "content": ch.Content,
+ "word_count": ch.WordCount,
+ "is_free": ch.IsFree,
+ "price": ch.Price,
+ }).Error
+ }
+ if err != nil {
+ failed++
+ continue
+ }
+ imported++
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
+ return
+ default:
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
+ return
+ }
+ case http.MethodPut:
+ var body struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ Price *float64 `json:"price"`
+ IsFree *bool `json:"isFree"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
+ return
+ }
+ price := 1.0
+ if body.Price != nil {
+ price = *body.Price
+ }
+ isFree := false
+ if body.IsFree != nil {
+ isFree = *body.IsFree
+ }
+ wordCount := len(body.Content)
+ updates := map[string]interface{}{
+ "section_title": body.Title,
+ "content": body.Content,
+ "word_count": wordCount,
+ "price": price,
+ "is_free": isFree,
+ }
+ err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
+}
+
+type importItem struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ Price *float64 `json:"price"`
+ IsFree *bool `json:"isFree"`
+ PartID *string `json:"partId"`
+ PartTitle *string `json:"partTitle"`
+ ChapterID *string `json:"chapterId"`
+ ChapterTitle *string `json:"chapterTitle"`
+}
+
+func strPtr(s *string, def string) string {
+ if s != nil && *s != "" {
+ return *s
+ }
+ return def
+}
+
+// DBBookDelete DELETE /api/db/book
+func DBBookDelete(c *gin.Context) {
+ id := c.Query("id")
+ if id == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
+ return
+ }
+ if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/distribution.go b/soul-api/internal/handler/distribution.go
new file mode 100644
index 00000000..f0c44ede
--- /dev/null
+++ b/soul-api/internal/handler/distribution.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// DistributionGet POST /api/distribution GET/POST/PUT
+func DistributionGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// DistributionAutoWithdrawConfig GET/POST/DELETE /api/distribution/auto-withdraw-config
+func DistributionAutoWithdrawConfig(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// DistributionMessages GET/POST /api/distribution/messages
+func DistributionMessages(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/documentation.go b/soul-api/internal/handler/documentation.go
new file mode 100644
index 00000000..00699750
--- /dev/null
+++ b/soul-api/internal/handler/documentation.go
@@ -0,0 +1,12 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// DocGenerate POST /api/documentation/generate
+func DocGenerate(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/match.go b/soul-api/internal/handler/match.go
new file mode 100644
index 00000000..29eefdf7
--- /dev/null
+++ b/soul-api/internal/handler/match.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// MatchConfigGet GET /api/match/config
+func MatchConfigGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MatchConfigPost POST /api/match/config
+func MatchConfigPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MatchUsers POST /api/match/users (Next 为 POST,拆解计划写 GET,两法都挂)
+func MatchUsers(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+}
diff --git a/soul-api/internal/handler/menu.go b/soul-api/internal/handler/menu.go
new file mode 100644
index 00000000..43f6c32e
--- /dev/null
+++ b/soul-api/internal/handler/menu.go
@@ -0,0 +1,12 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// MenuGet GET /api/menu
+func MenuGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+}
diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go
new file mode 100644
index 00000000..868364c1
--- /dev/null
+++ b/soul-api/internal/handler/miniprogram.go
@@ -0,0 +1,32 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// MiniprogramLogin POST /api/miniprogram/login
+func MiniprogramLogin(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MiniprogramPay GET/POST /api/miniprogram/pay
+func MiniprogramPay(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MiniprogramPayNotify POST /api/miniprogram/pay/notify
+func MiniprogramPayNotify(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MiniprogramPhone POST /api/miniprogram/phone
+func MiniprogramPhone(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// MiniprogramQrcode POST /api/miniprogram/qrcode (Next 为 POST)
+func MiniprogramQrcode(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/orders.go b/soul-api/internal/handler/orders.go
new file mode 100644
index 00000000..25326b3d
--- /dev/null
+++ b/soul-api/internal/handler/orders.go
@@ -0,0 +1,20 @@
+package handler
+
+import (
+ "net/http"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// OrdersList GET /api/orders
+func OrdersList(c *gin.Context) {
+ var orders []model.Order
+ if err := database.DB().Order("created_at DESC").Find(&orders).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []interface{}{}})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "orders": orders})
+}
diff --git a/soul-api/internal/handler/payment.go b/soul-api/internal/handler/payment.go
new file mode 100644
index 00000000..dbe71553
--- /dev/null
+++ b/soul-api/internal/handler/payment.go
@@ -0,0 +1,52 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// PaymentAlipayNotify POST /api/payment/alipay/notify
+func PaymentAlipayNotify(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentCallback POST /api/payment/callback
+func PaymentCallback(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentCreateOrder POST /api/payment/create-order
+func PaymentCreateOrder(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentMethods GET /api/payment/methods
+func PaymentMethods(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+}
+
+// PaymentQuery GET /api/payment/query
+func PaymentQuery(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentStatusOrderSn GET /api/payment/status/:orderSn
+func PaymentStatusOrderSn(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentVerify POST /api/payment/verify
+func PaymentVerify(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentWechatNotify POST /api/payment/wechat/notify
+func PaymentWechatNotify(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
+func PaymentWechatTransferNotify(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go
new file mode 100644
index 00000000..22aab14b
--- /dev/null
+++ b/soul-api/internal/handler/referral.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ReferralBind POST /api/referral/bind
+func ReferralBind(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// ReferralData GET /api/referral/data
+func ReferralData(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// ReferralVisit POST /api/referral/visit
+func ReferralVisit(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/search.go b/soul-api/internal/handler/search.go
new file mode 100644
index 00000000..f8dd7b83
--- /dev/null
+++ b/soul-api/internal/handler/search.go
@@ -0,0 +1,81 @@
+package handler
+
+import (
+ "net/http"
+ "strings"
+ "unicode/utf8"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用
+func escapeLike(s string) string {
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, "%", "\\%")
+ s = strings.ReplaceAll(s, "_", "\\_")
+ return s
+}
+
+// SearchGet GET /api/search?q= 从 chapters 表搜索(GORM,参数化)
+func SearchGet(c *gin.Context) {
+ q := strings.TrimSpace(c.Query("q"))
+ if q == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"})
+ return
+ }
+ pattern := "%" + escapeLike(q) + "%"
+ var list []model.Chapter
+ err := database.DB().Model(&model.Chapter{}).
+ Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
+ Order("sort_order ASC, id ASC").
+ Limit(50).
+ Find(&list).Error
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
+ return
+ }
+ lowerQ := strings.ToLower(q)
+ results := make([]gin.H, 0, len(list))
+ for _, ch := range list {
+ matchType := "content"
+ score := 5
+ if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
+ matchType = "title"
+ score = 10
+ }
+ snippet := ""
+ pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
+ if pos >= 0 && len(ch.Content) > 0 {
+ start := pos - 50
+ if start < 0 {
+ start = 0
+ }
+ end := pos + utf8.RuneCountInString(q) + 50
+ if end > len(ch.Content) {
+ end = len(ch.Content)
+ }
+ snippet = ch.Content[start:end]
+ if start > 0 {
+ snippet = "..." + snippet
+ }
+ if end < len(ch.Content) {
+ snippet = snippet + "..."
+ }
+ }
+ price := 1.0
+ if ch.Price != nil {
+ price = *ch.Price
+ }
+ results = append(results, gin.H{
+ "id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
+ "price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{"keyword": q, "total": len(results), "results": results},
+ })
+}
diff --git a/soul-api/internal/handler/sync.go b/soul-api/internal/handler/sync.go
new file mode 100644
index 00000000..a5bf15eb
--- /dev/null
+++ b/soul-api/internal/handler/sync.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// SyncGet GET /api/sync
+func SyncGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// SyncPost POST /api/sync
+func SyncPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// SyncPut PUT /api/sync
+func SyncPut(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go
new file mode 100644
index 00000000..eda35d47
--- /dev/null
+++ b/soul-api/internal/handler/upload.go
@@ -0,0 +1,17 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// UploadPost POST /api/upload
+func UploadPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "url": ""})
+}
+
+// UploadDelete DELETE /api/upload
+func UploadDelete(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go
new file mode 100644
index 00000000..3ca2aa8b
--- /dev/null
+++ b/soul-api/internal/handler/user.go
@@ -0,0 +1,157 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// UserAddressesGet GET /api/user/addresses
+func UserAddressesGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
+}
+
+// UserAddressesPost POST /api/user/addresses
+func UserAddressesPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id
+func UserAddressesByID(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserCheckPurchased GET /api/user/check-purchased
+func UserCheckPurchased(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserProfileGet GET /api/user/profile
+func UserProfileGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserProfilePost POST /api/user/profile
+func UserProfilePost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserPurchaseStatus GET /api/user/purchase-status
+func UserPurchaseStatus(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserReadingProgressGet GET /api/user/reading-progress
+func UserReadingProgressGet(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserReadingProgressPost POST /api/user/reading-progress
+func UserReadingProgressPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM)
+func UserTrackGet(c *gin.Context) {
+ userId := c.Query("userId")
+ phone := c.Query("phone")
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
+ if limit < 1 || limit > 100 {
+ limit = 50
+ }
+ if userId == "" && phone == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
+ return
+ }
+ db := database.DB()
+ if userId == "" && phone != "" {
+ var u model.User
+ if err := db.Where("phone = ?", phone).First(&u).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
+ return
+ }
+ userId = u.ID
+ }
+ var tracks []model.UserTrack
+ if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0})
+ return
+ }
+ stats := make(map[string]int)
+ formatted := make([]gin.H, 0, len(tracks))
+ for _, t := range tracks {
+ stats[t.Action]++
+ target := ""
+ if t.Target != nil {
+ target = *t.Target
+ }
+ if t.ChapterID != nil && target == "" {
+ target = *t.ChapterID
+ }
+ formatted = append(formatted, gin.H{
+ "id": t.ID, "action": t.Action, "target": target, "chapterTitle": t.ChapterID,
+ "createdAt": t.CreatedAt,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
+}
+
+// UserTrackPost POST /api/user/track 记录行为(GORM)
+func UserTrackPost(c *gin.Context) {
+ var body struct {
+ UserID string `json:"userId"`
+ Phone string `json:"phone"`
+ Action string `json:"action"`
+ Target string `json:"target"`
+ ExtraData interface{} `json:"extraData"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
+ return
+ }
+ if body.UserID == "" && body.Phone == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
+ return
+ }
+ if body.Action == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
+ return
+ }
+ db := database.DB()
+ userId := body.UserID
+ if userId == "" {
+ var u model.User
+ if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
+ return
+ }
+ userId = u.ID
+ }
+ trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
+ chID := body.Target
+ if body.Action == "view_chapter" {
+ chID = body.Target
+ }
+ t := model.UserTrack{
+ ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
+ }
+ if body.Target != "" {
+ t.ChapterID = &chID
+ }
+ if err := db.Create(&t).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
+}
+
+// UserUpdate POST /api/user/update
+func UserUpdate(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/wechat.go b/soul-api/internal/handler/wechat.go
new file mode 100644
index 00000000..86f83816
--- /dev/null
+++ b/soul-api/internal/handler/wechat.go
@@ -0,0 +1,12 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// WechatLogin POST /api/wechat/login
+func WechatLogin(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/soul-api/internal/handler/withdraw.go b/soul-api/internal/handler/withdraw.go
new file mode 100644
index 00000000..08850270
--- /dev/null
+++ b/soul-api/internal/handler/withdraw.go
@@ -0,0 +1,60 @@
+package handler
+
+import (
+ "net/http"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// WithdrawPost POST /api/withdraw 创建提现申请(占位:仅返回成功,实际需对接微信打款)
+func WithdrawPost(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录(GORM)
+func WithdrawRecords(c *gin.Context) {
+ userId := c.Query("userId")
+ if userId == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
+ return
+ }
+ var list []model.Withdrawal
+ if err := database.DB().Where("user_id = ?", userId).Order("created_at DESC").Limit(100).Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}}})
+ return
+ }
+ out := make([]gin.H, 0, len(list))
+ for _, w := range list {
+ st := ""
+ if w.Status != nil {
+ st = *w.Status
+ }
+ out = append(out, gin.H{
+ "id": w.ID, "amount": w.Amount, "status": st,
+ "createdAt": w.CreatedAt, "processedAt": w.ProcessedAt,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
+}
+
+// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表(GORM)
+func WithdrawPendingConfirm(c *gin.Context) {
+ userId := c.Query("userId")
+ if userId == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
+ return
+ }
+ var list []model.Withdrawal
+ if err := database.DB().Where("user_id = ? AND status = ?", userId, "pending_confirm").Order("created_at DESC").Find(&list).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}, "mch_id": "", "app_id": ""}})
+ return
+ }
+ out := make([]gin.H, 0, len(list))
+ for _, w := range list {
+ out = append(out, gin.H{"id": w.ID, "amount": w.Amount, "createdAt": w.CreatedAt})
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out, "mchId": "", "appId": ""}})
+}
diff --git a/soul-api/internal/middleware/admin_auth.go b/soul-api/internal/middleware/admin_auth.go
new file mode 100644
index 00000000..2435a1c5
--- /dev/null
+++ b/soul-api/internal/middleware/admin_auth.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+ "net/http"
+ "os"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminAuth 管理端鉴权:校验登录态(Cookie 或 Authorization),未登录返回 401
+// 开发模式(GIN_MODE=debug)下暂不校验,便于联调;生产请实现 Session/JWT
+func AdminAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if os.Getenv("GIN_MODE") == "debug" {
+ c.Next()
+ return
+ }
+ _, err := c.Cookie("admin_session")
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未登录"})
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/soul-api/internal/middleware/ratelimit.go b/soul-api/internal/middleware/ratelimit.go
new file mode 100644
index 00000000..8643e934
--- /dev/null
+++ b/soul-api/internal/middleware/ratelimit.go
@@ -0,0 +1,65 @@
+package middleware
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "golang.org/x/time/rate"
+
+ "github.com/gin-gonic/gin"
+)
+
+// RateLimiter 按 IP 的限流器
+type RateLimiter struct {
+ mu sync.Mutex
+ clients map[string]*rate.Limiter
+ r rate.Limit
+ b int
+}
+
+// NewRateLimiter 创建限流中间件,r 每秒请求数,b 突发容量
+func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
+ return &RateLimiter{
+ clients: make(map[string]*rate.Limiter),
+ r: r,
+ b: b,
+ }
+}
+
+// getLimiter 获取或创建该 key 的 limiter
+func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ if lim, ok := rl.clients[key]; ok {
+ return lim
+ }
+ lim := rate.NewLimiter(rl.r, rl.b)
+ rl.clients[key] = lim
+ return lim
+}
+
+// Middleware 返回 Gin 限流中间件(按客户端 IP)
+func (rl *RateLimiter) Middleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ key := c.ClientIP()
+ lim := rl.getLimiter(key)
+ if !lim.Allow() {
+ c.AbortWithStatus(http.StatusTooManyRequests)
+ return
+ }
+ c.Next()
+ }
+}
+
+// Cleanup 定期清理过期 limiter(可选,避免 map 无限增长)
+func (rl *RateLimiter) Cleanup(interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ go func() {
+ for range ticker.C {
+ rl.mu.Lock()
+ rl.clients = make(map[string]*rate.Limiter)
+ rl.mu.Unlock()
+ }
+ }()
+}
diff --git a/soul-api/internal/middleware/secure.go b/soul-api/internal/middleware/secure.go
new file mode 100644
index 00000000..7e9eec19
--- /dev/null
+++ b/soul-api/internal/middleware/secure.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/unrolled/secure"
+)
+
+// Secure 安全响应头中间件
+func Secure() gin.HandlerFunc {
+ s := secure.New(secure.Options{
+ FrameDeny: true,
+ ContentTypeNosniff: true,
+ BrowserXssFilter: true,
+ ContentSecurityPolicy: "frame-ancestors 'none'",
+ ReferrerPolicy: "no-referrer",
+ })
+ return func(c *gin.Context) {
+ err := s.Process(c.Writer, c.Request)
+ if err != nil {
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/soul-api/internal/model/README.txt b/soul-api/internal/model/README.txt
new file mode 100644
index 00000000..a2c6b5a7
--- /dev/null
+++ b/soul-api/internal/model/README.txt
@@ -0,0 +1 @@
+在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。
diff --git a/soul-api/internal/model/chapter.go b/soul-api/internal/model/chapter.go
new file mode 100644
index 00000000..72e84cdf
--- /dev/null
+++ b/soul-api/internal/model/chapter.go
@@ -0,0 +1,23 @@
+package model
+
+import "time"
+
+// Chapter 对应表 chapters(与 Prisma 一致),JSON 小写驼峰
+type Chapter struct {
+ ID string `gorm:"column:id;primaryKey;size:20" json:"id"`
+ PartID string `gorm:"column:part_id;size:20" json:"partId"`
+ PartTitle string `gorm:"column:part_title;size:100" json:"partTitle"`
+ ChapterID string `gorm:"column:chapter_id;size:20" json:"chapterId"`
+ ChapterTitle string `gorm:"column:chapter_title;size:200" json:"chapterTitle"`
+ SectionTitle string `gorm:"column:section_title;size:200" json:"sectionTitle"`
+ Content string `gorm:"column:content;type:longtext" json:"content,omitempty"`
+ WordCount *int `gorm:"column:word_count" json:"wordCount,omitempty"`
+ IsFree *bool `gorm:"column:is_free" json:"isFree,omitempty"`
+ Price *float64 `gorm:"column:price;type:decimal(10,2)" json:"price,omitempty"`
+ SortOrder *int `gorm:"column:sort_order" json:"sortOrder,omitempty"`
+ Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+ UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+func (Chapter) TableName() string { return "chapters" }
diff --git a/soul-api/internal/model/order.go b/soul-api/internal/model/order.go
new file mode 100644
index 00000000..0d22eaf7
--- /dev/null
+++ b/soul-api/internal/model/order.go
@@ -0,0 +1,24 @@
+package model
+
+import "time"
+
+// Order 对应表 orders,JSON 输出与现网接口 1:1(小写驼峰)
+type Order struct {
+ ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
+ OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
+ UserID string `gorm:"column:user_id;size:50" json:"userId"`
+ OpenID string `gorm:"column:open_id;size:100" json:"openId"`
+ ProductType string `gorm:"column:product_type;size:50" json:"productType"`
+ ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
+ Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
+ Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
+ Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
+ TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
+ PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
+ ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
+ ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+ UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+func (Order) TableName() string { return "orders" }
diff --git a/soul-api/internal/model/referral_binding.go b/soul-api/internal/model/referral_binding.go
new file mode 100644
index 00000000..091530b5
--- /dev/null
+++ b/soul-api/internal/model/referral_binding.go
@@ -0,0 +1,19 @@
+package model
+
+import "time"
+
+// ReferralBinding 对应表 referral_bindings
+type ReferralBinding struct {
+ ID string `gorm:"column:id;primaryKey;size:50"`
+ ReferrerID string `gorm:"column:referrer_id;size:50"`
+ RefereeID string `gorm:"column:referee_id;size:50"`
+ ReferralCode string `gorm:"column:referral_code;size:20"`
+ Status *string `gorm:"column:status;size:20"`
+ BindingDate time.Time `gorm:"column:binding_date"`
+ ExpiryDate time.Time `gorm:"column:expiry_date"`
+ CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+ UpdatedAt time.Time `gorm:"column:updated_at"`
+}
+
+func (ReferralBinding) TableName() string { return "referral_bindings" }
diff --git a/soul-api/internal/model/referral_visit.go b/soul-api/internal/model/referral_visit.go
new file mode 100644
index 00000000..de5a3863
--- /dev/null
+++ b/soul-api/internal/model/referral_visit.go
@@ -0,0 +1,13 @@
+package model
+
+import "time"
+
+// ReferralVisit 对应表 referral_visits
+type ReferralVisit struct {
+ ID int `gorm:"column:id;primaryKey;autoIncrement"`
+ ReferrerID string `gorm:"column:referrer_id;size:50"`
+ VisitorID *string `gorm:"column:visitor_id;size:50"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+}
+
+func (ReferralVisit) TableName() string { return "referral_visits" }
diff --git a/soul-api/internal/model/system_config.go b/soul-api/internal/model/system_config.go
new file mode 100644
index 00000000..590e221a
--- /dev/null
+++ b/soul-api/internal/model/system_config.go
@@ -0,0 +1,35 @@
+package model
+
+import (
+ "database/sql/driver"
+ "time"
+)
+
+// ConfigValue 存 system_config.config_value(JSON 列,可为 object 或 array)
+type ConfigValue []byte
+
+func (c ConfigValue) Value() (driver.Value, error) { return []byte(c), nil }
+func (c *ConfigValue) Scan(value interface{}) error {
+ if value == nil {
+ *c = nil
+ return nil
+ }
+ b, ok := value.([]byte)
+ if !ok {
+ return nil
+ }
+ *c = append((*c)[0:0], b...)
+ return nil
+}
+
+// SystemConfig 对应表 system_config,JSON 输出与现网 1:1(小写驼峰)
+type SystemConfig struct {
+ ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
+ ConfigKey string `gorm:"column:config_key;uniqueIndex;size:100" json:"configKey"`
+ ConfigValue ConfigValue `gorm:"column:config_value;type:json" json:"configValue"`
+ Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+ UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+func (SystemConfig) TableName() string { return "system_config" }
diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go
new file mode 100644
index 00000000..300bc584
--- /dev/null
+++ b/soul-api/internal/model/user.go
@@ -0,0 +1,26 @@
+package model
+
+import "time"
+
+
+// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
+type User struct {
+ ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
+ OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
+ Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
+ Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
+ Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
+ WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
+ ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"`
+ HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"`
+ Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"`
+ PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"`
+ ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+ UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
+ IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"`
+ WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
+ Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
+}
+
+func (User) TableName() string { return "users" }
diff --git a/soul-api/internal/model/user_track.go b/soul-api/internal/model/user_track.go
new file mode 100644
index 00000000..f86f42dd
--- /dev/null
+++ b/soul-api/internal/model/user_track.go
@@ -0,0 +1,16 @@
+package model
+
+import "time"
+
+// UserTrack 对应表 user_tracks
+type UserTrack struct {
+ ID string `gorm:"column:id;primaryKey;size:50"`
+ UserID string `gorm:"column:user_id;size:100"`
+ Action string `gorm:"column:action;size:50"`
+ ChapterID *string `gorm:"column:chapter_id;size:100"`
+ Target *string `gorm:"column:target;size:200"`
+ ExtraData []byte `gorm:"column:extra_data;type:json"`
+ CreatedAt *time.Time `gorm:"column:created_at"`
+}
+
+func (UserTrack) TableName() string { return "user_tracks" }
diff --git a/soul-api/internal/model/withdrawal.go b/soul-api/internal/model/withdrawal.go
new file mode 100644
index 00000000..f0dc56ec
--- /dev/null
+++ b/soul-api/internal/model/withdrawal.go
@@ -0,0 +1,17 @@
+package model
+
+import "time"
+
+// Withdrawal 对应表 withdrawals
+type Withdrawal struct {
+ ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
+ UserID string `gorm:"column:user_id;size:50" json:"userId"`
+ Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
+ Status *string `gorm:"column:status;size:20" json:"status"`
+ WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
+ WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"`
+ CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
+ ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
+}
+
+func (Withdrawal) TableName() string { return "withdrawals" }
diff --git a/soul-api/internal/repository/README.txt b/soul-api/internal/repository/README.txt
new file mode 100644
index 00000000..4f0cfda6
--- /dev/null
+++ b/soul-api/internal/repository/README.txt
@@ -0,0 +1 @@
+在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。
diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go
new file mode 100644
index 00000000..a52fade8
--- /dev/null
+++ b/soul-api/internal/router/router.go
@@ -0,0 +1,213 @@
+package router
+
+import (
+ "soul-api/internal/config"
+ "soul-api/internal/handler"
+ "soul-api/internal/middleware"
+
+ "github.com/gin-contrib/cors"
+ "github.com/gin-gonic/gin"
+)
+
+// Setup 创建并配置 Gin 引擎,路径与 app/api 一致
+func Setup(cfg *config.Config) *gin.Engine {
+ gin.SetMode(cfg.Mode)
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.Use(gin.Logger())
+ _ = r.SetTrustedProxies(cfg.TrustedProxies)
+
+ r.Use(middleware.Secure())
+ r.Use(cors.New(cors.Config{
+ AllowOrigins: cfg.CORSOrigins,
+ AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+ AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
+ AllowCredentials: true,
+ MaxAge: 86400,
+ }))
+ rateLimiter := middleware.NewRateLimiter(100, 200)
+ r.Use(rateLimiter.Middleware())
+
+ api := r.Group("/api")
+ {
+ // ----- 管理端 -----
+ api.GET("/admin", handler.AdminCheck)
+ api.POST("/admin", handler.AdminLogin)
+ api.POST("/admin/logout", handler.AdminLogout)
+
+ admin := api.Group("/admin")
+ admin.Use(middleware.AdminAuth())
+ {
+ admin.GET("/chapters", handler.AdminChaptersList)
+ admin.POST("/chapters", handler.AdminChaptersAction)
+ admin.PUT("/chapters", handler.AdminChaptersAction)
+ admin.DELETE("/chapters", handler.AdminChaptersAction)
+ admin.GET("/content", handler.AdminContent)
+ admin.POST("/content", handler.AdminContent)
+ admin.PUT("/content", handler.AdminContent)
+ admin.DELETE("/content", handler.AdminContent)
+ admin.GET("/distribution/overview", handler.AdminDistributionOverview)
+ admin.GET("/payment", handler.AdminPayment)
+ admin.POST("/payment", handler.AdminPayment)
+ admin.PUT("/payment", handler.AdminPayment)
+ admin.DELETE("/payment", handler.AdminPayment)
+ admin.GET("/referral", handler.AdminReferral)
+ admin.POST("/referral", handler.AdminReferral)
+ admin.PUT("/referral", handler.AdminReferral)
+ admin.DELETE("/referral", handler.AdminReferral)
+ admin.GET("/withdrawals", handler.AdminWithdrawalsList)
+ admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
+ }
+
+ // ----- 鉴权 -----
+ api.POST("/auth/login", handler.AuthLogin)
+ api.POST("/auth/reset-password", handler.AuthResetPassword)
+
+ // ----- 书籍/章节 -----
+ api.GET("/book/all-chapters", handler.BookAllChapters)
+ api.GET("/book/chapter/:id", handler.BookChapterByID)
+ api.GET("/book/chapters", handler.BookChapters)
+ api.POST("/book/chapters", handler.BookChapters)
+ api.PUT("/book/chapters", handler.BookChapters)
+ api.DELETE("/book/chapters", handler.BookChapters)
+ api.GET("/book/hot", handler.BookHot)
+ api.GET("/book/latest-chapters", handler.BookLatestChapters)
+ api.GET("/book/search", handler.BookSearch)
+ api.GET("/book/stats", handler.BookStats)
+ api.GET("/book/sync", handler.BookSync)
+ api.POST("/book/sync", handler.BookSync)
+
+ // ----- CKB -----
+ api.POST("/ckb/join", handler.CKBJoin)
+ api.POST("/ckb/match", handler.CKBMatch)
+ api.GET("/ckb/sync", handler.CKBSync)
+ api.POST("/ckb/sync", handler.CKBSync)
+
+ // ----- 配置 -----
+ api.GET("/config", handler.GetConfig)
+
+ // ----- 内容 -----
+ api.GET("/content", handler.ContentGet)
+
+ // ----- 定时任务 -----
+ api.GET("/cron/sync-orders", handler.CronSyncOrders)
+ api.POST("/cron/sync-orders", handler.CronSyncOrders)
+ api.GET("/cron/unbind-expired", handler.CronUnbindExpired)
+ api.POST("/cron/unbind-expired", handler.CronUnbindExpired)
+
+ // ----- 数据库(管理端) -----
+ db := api.Group("/db")
+ db.Use(middleware.AdminAuth())
+ {
+ db.GET("/book", handler.DBBookAction)
+ db.POST("/book", handler.DBBookAction)
+ db.PUT("/book", handler.DBBookAction)
+ db.DELETE("/book", handler.DBBookDelete)
+ db.GET("/chapters", handler.DBChapters)
+ db.POST("/chapters", handler.DBChapters)
+ db.GET("/config", handler.DBConfigGet)
+ db.POST("/config", handler.DBConfigPost)
+ db.DELETE("/config", handler.DBConfigDelete)
+ db.GET("/distribution", handler.DBDistribution)
+ db.GET("/init", handler.DBInitGet)
+ db.POST("/init", handler.DBInit)
+ db.GET("/migrate", handler.DBMigrateGet)
+ db.POST("/migrate", handler.DBMigratePost)
+ db.GET("/users", handler.DBUsersList)
+ db.POST("/users", handler.DBUsersAction)
+ db.PUT("/users", handler.DBUsersAction)
+ db.DELETE("/users", handler.DBUsersDelete)
+ db.GET("/users/referrals", handler.DBUsersReferrals)
+ }
+
+ // ----- 分销 -----
+ api.GET("/distribution", handler.DistributionGet)
+ api.POST("/distribution", handler.DistributionGet)
+ api.PUT("/distribution", handler.DistributionGet)
+ api.GET("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
+ api.POST("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
+ api.DELETE("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
+ api.GET("/distribution/messages", handler.DistributionMessages)
+ api.POST("/distribution/messages", handler.DistributionMessages)
+
+ // ----- 文档生成 -----
+ api.POST("/documentation/generate", handler.DocGenerate)
+
+ // ----- 找伙伴 -----
+ api.GET("/match/config", handler.MatchConfigGet)
+ api.POST("/match/config", handler.MatchConfigPost)
+ api.POST("/match/users", handler.MatchUsers)
+
+ // ----- 菜单 -----
+ api.GET("/menu", handler.MenuGet)
+
+ // ----- 小程序 -----
+ api.POST("/miniprogram/login", handler.MiniprogramLogin)
+ api.GET("/miniprogram/pay", handler.MiniprogramPay)
+ api.POST("/miniprogram/pay", handler.MiniprogramPay)
+ api.POST("/miniprogram/pay/notify", handler.MiniprogramPayNotify)
+ api.POST("/miniprogram/phone", handler.MiniprogramPhone)
+ api.POST("/miniprogram/qrcode", handler.MiniprogramQrcode)
+
+ // ----- 订单 -----
+ api.GET("/orders", handler.OrdersList)
+
+ // ----- 支付 -----
+ api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
+ api.POST("/payment/callback", handler.PaymentCallback)
+ api.POST("/payment/create-order", handler.PaymentCreateOrder)
+ api.GET("/payment/methods", handler.PaymentMethods)
+ api.GET("/payment/query", handler.PaymentQuery)
+ api.GET("/payment/status/:orderSn", handler.PaymentStatusOrderSn)
+ api.POST("/payment/verify", handler.PaymentVerify)
+ api.POST("/payment/wechat/notify", handler.PaymentWechatNotify)
+ api.POST("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
+
+ // ----- 推荐 -----
+ api.POST("/referral/bind", handler.ReferralBind)
+ api.GET("/referral/data", handler.ReferralData)
+ api.POST("/referral/visit", handler.ReferralVisit)
+
+ // ----- 搜索 -----
+ api.GET("/search", handler.SearchGet)
+
+ // ----- 同步 -----
+ api.GET("/sync", handler.SyncGet)
+ api.POST("/sync", handler.SyncPost)
+ api.PUT("/sync", handler.SyncPut)
+
+ // ----- 上传 -----
+ api.POST("/upload", handler.UploadPost)
+ api.DELETE("/upload", handler.UploadDelete)
+
+ // ----- 用户 -----
+ api.GET("/user/addresses", handler.UserAddressesGet)
+ api.POST("/user/addresses", handler.UserAddressesPost)
+ api.GET("/user/addresses/:id", handler.UserAddressesByID)
+ api.PUT("/user/addresses/:id", handler.UserAddressesByID)
+ api.DELETE("/user/addresses/:id", handler.UserAddressesByID)
+ api.GET("/user/check-purchased", handler.UserCheckPurchased)
+ api.GET("/user/profile", handler.UserProfileGet)
+ api.POST("/user/profile", handler.UserProfilePost)
+ api.GET("/user/purchase-status", handler.UserPurchaseStatus)
+ api.GET("/user/reading-progress", handler.UserReadingProgressGet)
+ api.POST("/user/reading-progress", handler.UserReadingProgressPost)
+ api.GET("/user/track", handler.UserTrackGet)
+ api.POST("/user/track", handler.UserTrackPost)
+ api.POST("/user/update", handler.UserUpdate)
+
+ // ----- 微信登录 -----
+ api.POST("/wechat/login", handler.WechatLogin)
+
+ // ----- 提现 -----
+ api.POST("/withdraw", handler.WithdrawPost)
+ api.GET("/withdraw/records", handler.WithdrawRecords)
+ api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
+ }
+
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(200, gin.H{"status": "ok"})
+ })
+
+ return r
+}
diff --git a/soul-api/internal/service/README.txt b/soul-api/internal/service/README.txt
new file mode 100644
index 00000000..50e47169
--- /dev/null
+++ b/soul-api/internal/service/README.txt
@@ -0,0 +1 @@
+在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。
diff --git a/soul-api/server.exe b/soul-api/server.exe
new file mode 100644
index 00000000..fd8edd9c
Binary files /dev/null and b/soul-api/server.exe differ
diff --git a/soul-api/tmp/main.exe b/soul-api/tmp/main.exe
new file mode 100644
index 00000000..6fe7d25b
Binary files /dev/null and b/soul-api/tmp/main.exe differ
diff --git a/开发文档/2、架构/Gin技术栈-Go1.25依赖清单.md b/开发文档/2、架构/Gin技术栈-Go1.25依赖清单.md
new file mode 100644
index 00000000..696456fc
--- /dev/null
+++ b/开发文档/2、架构/Gin技术栈-Go1.25依赖清单.md
@@ -0,0 +1,120 @@
+# Gin 技术栈依赖清单(适配 Go 1.25.7)
+
+**目标版本**:Go 1.25.7
+**原则**:精简、高效、易上手、好用、安全;所有依赖均兼容 Go 1.25,无需更换或移除。
+
+---
+
+## 一、Go 版本说明
+
+- Go 1.25 于 2025 年 8 月发布,遵守 Go 1 兼容性承诺,现有主流库均可使用。
+- **Gin** 官方要求 Go 1.24+,1.25.7 满足要求。
+- **golang.org/x/\***(crypto、time 等)随 Go 工具链维护,支持当前稳定版。
+- 若某依赖未显式声明支持 1.25,只要其 `go.mod` 为 `go 1.21` 或更高,在 1.25 下均可正常编译使用。
+
+---
+
+## 二、依赖列表(均适配 Go 1.25.7)
+
+### 1. 核心与数据库
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/gin-gonic/gin` | 最新 v1.x | 要求 Go 1.24+,1.25.7 兼容。 |
+| `gorm.io/gorm` | 最新 v1.31.x | 无对高版本 Go 的限制。 |
+| `gorm.io/driver/mysql` | 最新 | 与 GORM 配套。 |
+
+### 2. 安全
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/unrolled/secure` | v1.17+ | 标准 net/http 中间件,兼容 Go 1.25。 |
+| `golang.org/x/crypto` | 最新 | 使用 `bcrypt` 等,随 Go 生态更新。 |
+| `golang.org/x/time` | 最新 | 使用 `rate` 限流,兼容 Go 1.25。 |
+
+### 3. 配置
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/joho/godotenv` | v1.5.x | 仅读 .env,无高版本 Go 要求。 |
+| 或 `github.com/caarlos0/env/v11` | v11.x | 解析 env 到结构体,兼容当前 Go。 |
+
+### 4. 跨域与鉴权
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/gin-contrib/cors` | v1.6+(务必 ≥1.6,修复 CVE) | 与 Gin 1.24+ / Go 1.25 兼容。 |
+| `github.com/golang-jwt/jwt/v5` | 最新 v5.x | 推荐 v5,与 Go 1.25 兼容。 |
+
+### 5. 接口文档(可选)
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/swaggo/swag/cmd/swag` | 最新(CLI 工具) | 代码生成,使用最新 CLI 即可。 |
+| `github.com/swaggo/gin-swagger` | 最新 | 与 Gin 配套。 |
+| `github.com/swaggo/files` | 最新 | Swagger UI 静态资源。 |
+
+### 6. 开发工具(仅开发环境)
+
+| 依赖 | 版本建议 | 说明 |
+|------|----------|------|
+| `github.com/cosmtrek/air` | 最新 | 热重载,与 Go 1.25 兼容。 |
+
+---
+
+## 三、无需更换或移除
+
+- 上述依赖在 Go 1.25.7 下**均无需更换或移除**。
+- 未列入的冗余依赖(如单独再引入 validator、Viper、zap 等)按此前「精简版」建议已不纳入,无需因 Go 1.25 再改。
+
+---
+
+## 四、推荐 go.mod 片段(Go 1.25)
+
+在项目根目录执行:
+
+```bash
+go mod init soul-server
+go mod edit -go=1.25
+```
+
+然后按需拉取依赖(示例):
+
+```bash
+go get github.com/gin-gonic/gin
+go get gorm.io/gorm gorm.io/driver/mysql
+go get github.com/unrolled/secure
+go get golang.org/x/crypto golang.org/x/time
+go get github.com/gin-contrib/cors
+go get github.com/golang-jwt/jwt/v5
+go get github.com/joho/godotenv
+# 可选
+go get github.com/swaggo/gin-swagger github.com/swaggo/files
+# 开发
+go install github.com/cosmtrek/air@latest
+go install github.com/swaggo/swag/cmd/swag@latest
+```
+
+---
+
+## 五、验证方式
+
+在 soul-server 目录下执行:
+
+```bash
+go mod tidy
+go build ./...
+```
+
+若通过,则当前依赖与 Go 1.25.7 兼容。若某库报错,优先升级该库至最新 minor/patch 再试。
+
+---
+
+## 六、小结
+
+| 项目 | 结论 |
+|------|------|
+| Go 1.25.7 | 支持,所有推荐依赖均适用。 |
+| 需要更换的依赖 | 无。 |
+| 需要移除的依赖 | 无(按本清单与精简原则已不包含不必要项)。 |
+| 建议 | 使用 `go 1.25`,定期 `go get -u ./...` 与 `go mod tidy` 保持依赖健康。 |