chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/ - 移除并忽略 .env 与小程序私有配置 - 同步小程序/管理端/API与脚本改动 Made-with: Cursor
This commit is contained in:
@@ -1,47 +0,0 @@
|
||||
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
|
||||
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
|
||||
# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
|
||||
# 统一 API 域名(支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
|
||||
API_BASE_URL=https://soul.quwanzhi.com
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 支付回调:未设置时由 API_BASE_URL + /api/miniprogram/pay/notify 派生
|
||||
# WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
|
||||
# 小程序码/订阅消息跳转版本:formal=正式版(默认) | trial=体验版 | developer=开发版
|
||||
WECHAT_MINI_PROGRAM_STATE=formal
|
||||
|
||||
# 微信转账配置(API v3)
|
||||
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
|
||||
WECHAT_KEY_PATH=certs/apiclient_key.pem
|
||||
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
# 转账回调:未设置时由 API_BASE_URL + /api/payment/wechat/transfer/notify 派生
|
||||
# WECHAT_TRANSFER_URL=https://souladmin.quwanzhi.com/api/payment/wechat/transfer/notify
|
||||
|
||||
# 管理端登录(与 next-project 一致,默认 admin / admin123)
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=admin123
|
||||
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
|
||||
|
||||
# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# 跨域 CORS:允许的源,逗号分隔。未设置时使用默认值(含 localhost、soul.quwanzhi.com)
|
||||
CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
|
||||
|
||||
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey(与 join/match 不同)
|
||||
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
|
||||
@@ -1,49 +0,0 @@
|
||||
# 测试环境配置(air / make dev 时加载,见 .air.toml env_files)
|
||||
APP_ENV=development
|
||||
|
||||
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据库(测试环境 souldev)
|
||||
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
|
||||
# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
# 统一 API 域名(测试环境)
|
||||
API_BASE_URL=https://souldev.quwanzhi.com
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 支付回调:未设置时由 API_BASE_URL 派生
|
||||
# WECHAT_NOTIFY_URL=https://souldev.quwanzhi.com/api/miniprogram/pay/notify
|
||||
# 小程序码/订阅消息跳转版本:formal=正式版(默认) | trial=体验版 | developer=开发版
|
||||
WECHAT_MINI_PROGRAM_STATE=formal
|
||||
|
||||
# 微信转账配置(API v3)
|
||||
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
|
||||
WECHAT_KEY_PATH=certs/apiclient_key.pem
|
||||
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
# 转账回调:未设置时由 API_BASE_URL 派生
|
||||
# WECHAT_TRANSFER_URL=https://souldev.quwanzhi.com/api/payment/wechat/transfer/notify
|
||||
|
||||
# 管理端登录(与 next-project 一致,默认 admin / admin123)
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=admin123
|
||||
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
|
||||
|
||||
# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# 跨域 CORS:允许的源,逗号分隔。未设置时使用默认值(含 localhost、soul.quwanzhi.com)
|
||||
CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
|
||||
|
||||
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey(与 join/match 不同)
|
||||
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
|
||||
@@ -1,44 +0,0 @@
|
||||
# 正式环境配置(部署时复制为 .env,devlop.py 打包用)
|
||||
APP_ENV=production
|
||||
|
||||
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
|
||||
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
|
||||
# 统一 API 域名(支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
|
||||
API_BASE_URL=https://soulapi.quwanzhi.com
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 支付回调:未设置时由 API_BASE_URL 派生
|
||||
# WECHAT_NOTIFY_URL=https://soulapi.quwanzhi.com/api/miniprogram/pay/notify
|
||||
|
||||
# 微信转账配置(API v3)
|
||||
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
|
||||
WECHAT_KEY_PATH=certs/apiclient_key.pem
|
||||
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
# 转账回调:未设置时由 API_BASE_URL 派生
|
||||
# WECHAT_TRANSFER_URL=https://soulapi.quwanzhi.com/api/payment/wechat/transfer/notify
|
||||
|
||||
# 管理端登录(与 next-project 一致,默认 admin / admin123)
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=admin123
|
||||
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
|
||||
|
||||
# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
|
||||
# 跨域 CORS:允许的源,逗号分隔。未设置时使用默认值(含 localhost、soul.quwanzhi.com)
|
||||
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
|
||||
2
soul-api/.gitignore
vendored
2
soul-api/.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
tmp/
|
||||
log/
|
||||
soul-api
|
||||
soul-api-linux
|
||||
server.exe
|
||||
soul-api.exe
|
||||
wechat/info.log
|
||||
|
||||
146
soul-api/cmd/migrate-base64-images/main.go
Normal file
146
soul-api/cmd/migrate-base64-images/main.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// migrate-base64-images 将 chapters 表中 content 内的 base64 图片提取为文件并替换为 URL
|
||||
// 用法:cd soul-api && go run ./cmd/migrate-base64-images [--dry-run]
|
||||
// 测试环境:APP_ENV=development 时加载 .env.development,请先在测试库验证
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
// data:image/png;base64,iVBORw0KG... 或 data:image/jpeg;base64,/9j/4AAQ...
|
||||
var base64ImgRe = regexp.MustCompile(`(?i)src=["'](data:image/([^;"']+);base64,([A-Za-z0-9+/=]+))["']`)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "仅统计和预览,不写入文件与数据库")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("load config: ", err)
|
||||
}
|
||||
config.SetCurrent(cfg)
|
||||
if err := database.Init(cfg.DBDSN); err != nil {
|
||||
log.Fatal("database: ", err)
|
||||
}
|
||||
|
||||
uploadDir := cfg.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
bookImagesDir := filepath.Join(uploadDir, "book-images")
|
||||
if !*dryRun {
|
||||
if err := os.MkdirAll(bookImagesDir, 0755); err != nil {
|
||||
log.Fatal("mkdir book-images: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var chapters []model.Chapter
|
||||
if err := db.Select("id", "mid", "section_title", "content").Where("content LIKE ?", "%data:image%").Find(&chapters).Error; err != nil {
|
||||
log.Fatal("query chapters: ", err)
|
||||
}
|
||||
|
||||
log.Printf("找到 %d 篇含 base64 图片的章节", len(chapters))
|
||||
if len(chapters) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
randomStr := func(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
mimeToExt := map[string]string{
|
||||
"png": ".png",
|
||||
"jpeg": ".jpg",
|
||||
"jpg": ".jpg",
|
||||
"gif": ".gif",
|
||||
"webp": ".webp",
|
||||
}
|
||||
|
||||
totalReplaced := 0
|
||||
totalFiles := 0
|
||||
for _, ch := range chapters {
|
||||
matches := base64ImgRe.FindAllStringSubmatch(ch.Content, -1)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
newContent := ch.Content
|
||||
for _, m := range matches {
|
||||
fullDataURL := m[1]
|
||||
mime := strings.ToLower(strings.TrimSpace(m[2]))
|
||||
b64 := m[3]
|
||||
ext := mimeToExt[mime]
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
log.Printf(" [%s] base64 解码失败: %v", ch.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStr(6), ext)
|
||||
dst := filepath.Join(bookImagesDir, name)
|
||||
url := "/uploads/" + filepath.ToSlash(filepath.Join("book-images", name))
|
||||
|
||||
if !*dryRun {
|
||||
if err := os.WriteFile(dst, decoded, 0644); err != nil {
|
||||
log.Printf(" [%s] 写入文件失败 %s: %v", ch.ID, name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
oldSrc := `src="` + fullDataURL + `"`
|
||||
newSrc := `src="` + url + `"`
|
||||
if strings.Contains(newContent, oldSrc) {
|
||||
newContent = strings.Replace(newContent, oldSrc, newSrc, 1)
|
||||
} else {
|
||||
oldSrc2 := `src='` + fullDataURL + `'`
|
||||
newSrc2 := `src="` + url + `"`
|
||||
newContent = strings.Replace(newContent, oldSrc2, newSrc2, 1)
|
||||
}
|
||||
totalFiles++
|
||||
log.Printf(" [%s] %s -> %s (%d bytes)", ch.ID, mime, name, len(decoded))
|
||||
}
|
||||
|
||||
if newContent != ch.Content {
|
||||
totalReplaced++
|
||||
oldLen := len(ch.Content)
|
||||
newLen := len(newContent)
|
||||
if !*dryRun {
|
||||
if err := db.Model(&model.Chapter{}).Where("id = ?", ch.ID).Update("content", newContent).Error; err != nil {
|
||||
log.Printf(" [%s] 更新数据库失败: %v", ch.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
log.Printf(" [%s] 已更新,content 长度 %d -> %d (减少 %d)", ch.ID, oldLen, newLen, oldLen-newLen)
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
log.Printf("[dry-run] 将处理 %d 篇章节,共 %d 张 base64 图片", totalReplaced, totalFiles)
|
||||
log.Printf("[dry-run] 去掉 --dry-run 后执行以实际写入")
|
||||
} else {
|
||||
log.Printf("完成:更新 %d 篇章节,提取 %d 张图片到 uploads/book-images/", totalReplaced, totalFiles)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/handler"
|
||||
"soul-api/internal/redis"
|
||||
"soul-api/internal/router"
|
||||
"soul-api/internal/wechat"
|
||||
)
|
||||
@@ -31,6 +32,13 @@ func main() {
|
||||
if err := wechat.InitTransfer(cfg); err != nil {
|
||||
log.Fatal("wechat transfer: ", err)
|
||||
}
|
||||
if cfg.RedisURL != "" && cfg.RedisURL != "disable" {
|
||||
if err := redis.Init(cfg.RedisURL); err != nil {
|
||||
log.Printf("redis: 连接失败,跳过(%v)", err)
|
||||
} else {
|
||||
defer redis.Close()
|
||||
}
|
||||
}
|
||||
|
||||
r := router.Setup(cfg)
|
||||
srv := &http.Server{
|
||||
@@ -38,9 +46,19 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // 等 DB 完全就绪
|
||||
handler.WarmAllChaptersCache()
|
||||
handler.WarmBookPartsCache()
|
||||
}()
|
||||
|
||||
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 cfg.UploadDir != "" {
|
||||
log.Printf(" -> 上传目录: %s", cfg.UploadDir)
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
|
||||
- 打包使用 .env.production 作为服务器 .env
|
||||
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
|
||||
- 打包使用 .env.development 作为服务器 .env
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/self/soul-api
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
- 上传到 /www/wwwroot/self/soul-dev
|
||||
- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ except ImportError:
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_cfg():
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def run_build(root):
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
DEPLOY_PORT = 8080
|
||||
DEPLOY_PORT = 8081
|
||||
|
||||
|
||||
def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
env_src = os.path.join(root, ".env.production")
|
||||
env_src = os.path.join(root, ".env.development")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env.production -> .env")
|
||||
print(" [已包含] .env.development -> .env")
|
||||
else:
|
||||
env_example = os.path.join(root, ".env.example")
|
||||
if os.path.isfile(env_example):
|
||||
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "formal")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
name = cfg.get("bt_go_project_name", "soulDev")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
@@ -295,7 +295,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [成功] 已解压到: %s" % project_path)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soulApp 服务 ...")
|
||||
print("[4/4] 重启 soulDev 服务 ...")
|
||||
ok = False
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
@@ -315,7 +315,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
print(" [成功] soulDev 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
@@ -334,7 +334,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
@@ -353,7 +353,7 @@ def main():
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
print(" soul-api 部署到宝塔,重启 soulDev")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
|
||||
18
soul-api/docker-compose.yml
Normal file
18
soul-api/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
# soul-api 本地开发用 Redis
|
||||
# 启动:docker compose up -d
|
||||
# 停止:docker compose down
|
||||
# 若拉取失败,可配置 Docker Desktop → Settings → Docker Engine → registry-mirrors
|
||||
services:
|
||||
redis:
|
||||
# 使用 DaoCloud 镜像(国内加速);若已配置 daemon 镜像源可改回 redis:7-alpine
|
||||
image: docker.m.daocloud.io/library/redis:7-alpine
|
||||
container_name: soul-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
@@ -7,14 +7,19 @@ require (
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/unrolled/secure v1.17.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/time v0.8.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -29,7 +34,6 @@ require (
|
||||
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/golang-jwt/jwt/v5 v5.3.1 // 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
|
||||
@@ -41,7 +45,7 @@ require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
@@ -49,7 +53,6 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBj
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
||||
171
soul-api/internal/cache/cache.go
vendored
Normal file
171
soul-api/internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/redis"
|
||||
)
|
||||
|
||||
const defaultTimeout = 2 * time.Second
|
||||
|
||||
// KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del
|
||||
const KeyBookParts = "soul:book:parts"
|
||||
|
||||
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
|
||||
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
|
||||
const KeyBookRecommended = "soul:book:recommended"
|
||||
const KeyBookStats = "soul:book:stats"
|
||||
const KeyConfigMiniprogram = "soul:config:miniprogram"
|
||||
|
||||
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
|
||||
func Get(ctx context.Context, key string, dest interface{}) bool {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
val, err := client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if dest != nil && len(val) > 0 {
|
||||
_ = json.Unmarshal(val, dest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Set 写入 Redis,失败仅打日志不阻塞
|
||||
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
data, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
log.Printf("cache.Set marshal %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
log.Printf("cache.Set %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Del 删除 key,失败仅打日志
|
||||
func Del(ctx context.Context, key string) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
if err := client.Del(ctx, key).Err(); err != nil {
|
||||
log.Printf("cache.Del %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长
|
||||
const BookPartsTTL = 10 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效
|
||||
func InvalidateBookParts() {
|
||||
Del(context.Background(), KeyBookParts)
|
||||
}
|
||||
|
||||
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
|
||||
func InvalidateBookCache() {
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyBookRecommended)
|
||||
Del(ctx, KeyBookStats)
|
||||
for _, limit := range []int{3, 10, 20, 50} {
|
||||
Del(ctx, KeyBookHot(limit))
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
|
||||
func InvalidateConfig() {
|
||||
Del(context.Background(), KeyConfigMiniprogram)
|
||||
}
|
||||
|
||||
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
||||
const BookRelatedTTL = 5 * time.Minute
|
||||
|
||||
// ConfigTTL 配置接口 TTL
|
||||
const ConfigTTL = 10 * time.Minute
|
||||
|
||||
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
|
||||
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
|
||||
|
||||
// ChapterContentTTL 章节正文 TTL,后台更新时主动 Del
|
||||
const ChapterContentTTL = 30 * time.Minute
|
||||
|
||||
// GetString 读取字符串(不经过 JSON,适合大文本 content)
|
||||
func GetString(ctx context.Context, key string) (string, bool) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return "", false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
val, err := client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
// SetString 写入字符串(不经过 JSON,适合大文本 content)
|
||||
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
defer cancel()
|
||||
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
|
||||
log.Printf("cache.SetString %s: %v (非致命)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateChapterContent 章节内容更新时调用,mid<=0 时忽略
|
||||
func InvalidateChapterContent(mid int) {
|
||||
if mid <= 0 {
|
||||
return
|
||||
}
|
||||
Del(context.Background(), KeyChapterContent(mid))
|
||||
}
|
||||
|
||||
// InvalidateChapterContentByID 按业务 id 使章节内容缓存失效(内部查 mid 后调用 InvalidateChapterContent)
|
||||
func InvalidateChapterContentByID(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
var mid int
|
||||
if err := database.DB().Model(&model.Chapter{}).Where("id = ?", id).Pluck("mid", &mid).Error; err != nil || mid <= 0 {
|
||||
return
|
||||
}
|
||||
InvalidateChapterContent(mid)
|
||||
}
|
||||
@@ -22,6 +22,11 @@ type Config struct {
|
||||
// 统一 API 域名字段:支付回调、转账回调、apiDomain 等均由 BaseURL 拼接
|
||||
BaseURL string // API_BASE_URL,如 https://soulapi.quwanzhi.com(无尾部斜杠)
|
||||
|
||||
// 存客宝配置
|
||||
CkbLeadAPIKey string // CKB_LEAD_API_KEY,请求到存客宝添加好友使用的 apiKey(内部 /v1/api/scenarios)
|
||||
CkbOpenAPIKey string // CKB_OPEN_API_KEY,开放 API 鉴权使用的 apiKey(/v1/open/auth/token)
|
||||
CkbOpenAccount string // CKB_OPEN_ACCOUNT,对应存客宝登录账号(开放 API 使用)
|
||||
|
||||
// 微信小程序配置
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
@@ -44,6 +49,12 @@ type Config struct {
|
||||
|
||||
// 订单对账定时任务间隔(分钟),0 表示不启动内置定时任务
|
||||
SyncOrdersIntervalMinutes int
|
||||
|
||||
// 上传目录(绝对路径,air 运行时避免相对路径解析错误)
|
||||
UploadDir string
|
||||
|
||||
// Redis 连接地址(如 redis://localhost:6379/0),空表示不使用 Redis
|
||||
RedisURL string
|
||||
}
|
||||
|
||||
// BaseURLJoin 将路径拼接到 BaseURL,path 应以 / 开头
|
||||
@@ -156,7 +167,7 @@ func Load() (*Config, error) {
|
||||
|
||||
version := os.Getenv("APP_VERSION")
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
// 微信配置
|
||||
@@ -224,6 +235,9 @@ func Load() (*Config, error) {
|
||||
if adminSessionSecret == "" {
|
||||
adminSessionSecret = "soul-admin-secret-change-in-prod"
|
||||
}
|
||||
ckbLeadAPIKey := os.Getenv("CKB_LEAD_API_KEY")
|
||||
ckbOpenAPIKey := os.Getenv("CKB_OPEN_API_KEY")
|
||||
ckbOpenAccount := os.Getenv("CKB_OPEN_ACCOUNT")
|
||||
syncOrdersInterval := 5
|
||||
if s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" {
|
||||
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
|
||||
@@ -231,7 +245,18 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
// 上传目录:优先 UPLOAD_DIR 环境变量,否则用项目根下的 uploads
|
||||
uploadDir := strings.TrimSpace(os.Getenv("UPLOAD_DIR"))
|
||||
if uploadDir == "" {
|
||||
uploadDir = resolveUploadDir(workDir, execDir)
|
||||
} else if !filepath.IsAbs(uploadDir) {
|
||||
uploadDir, _ = filepath.Abs(filepath.Join(workDir, uploadDir))
|
||||
}
|
||||
|
||||
// Redis:REDIS_URL 配置后启用;不配置则跳过。本地开发可设 REDIS_URL=redis://localhost:6379/0
|
||||
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||
|
||||
cfg := &Config{
|
||||
Port: port,
|
||||
Mode: mode,
|
||||
DBDSN: dsn,
|
||||
@@ -239,6 +264,9 @@ func Load() (*Config, error) {
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
Version: version,
|
||||
BaseURL: baseURL,
|
||||
CkbLeadAPIKey: ckbLeadAPIKey,
|
||||
CkbOpenAPIKey: ckbOpenAPIKey,
|
||||
CkbOpenAccount: ckbOpenAccount,
|
||||
WechatAppID: wechatAppID,
|
||||
WechatAppSecret: wechatAppSecret,
|
||||
WechatMchID: wechatMchID,
|
||||
@@ -254,5 +282,46 @@ func Load() (*Config, error) {
|
||||
AdminPassword: adminPassword,
|
||||
AdminSessionSecret: adminSessionSecret,
|
||||
SyncOrdersIntervalMinutes: syncOrdersInterval,
|
||||
}, nil
|
||||
UploadDir: uploadDir,
|
||||
RedisURL: redisURL,
|
||||
}
|
||||
|
||||
// 生产环境(GIN_MODE=release)强制校验敏感配置,禁止使用默认值
|
||||
if cfg.Mode == "release" {
|
||||
sensitive := []struct {
|
||||
name string
|
||||
val string
|
||||
}{
|
||||
{"WECHAT_APPSECRET", cfg.WechatAppSecret},
|
||||
{"WECHAT_MCH_KEY", cfg.WechatMchKey},
|
||||
{"WECHAT_APIV3_KEY", cfg.WechatAPIv3Key},
|
||||
{"ADMIN_PASSWORD", cfg.AdminPassword},
|
||||
{"ADMIN_SESSION_SECRET", cfg.AdminSessionSecret},
|
||||
}
|
||||
for _, s := range sensitive {
|
||||
if s.val == "" ||
|
||||
strings.HasPrefix(s.val, "wx3e31b068") ||
|
||||
s.val == "admin123" ||
|
||||
s.val == "soul-admin-secret-change-in-prod" {
|
||||
log.Fatalf("生产环境必须配置 %s,禁止使用默认值", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveUploadDir 解析上传目录绝对路径(air 运行时 exe 在 tmp/,需用项目根)
|
||||
func resolveUploadDir(workDir, execDir string) string {
|
||||
root := workDir
|
||||
if execDir != "" {
|
||||
base := filepath.Base(execDir)
|
||||
if base == "tmp" {
|
||||
root = filepath.Dir(execDir)
|
||||
} else {
|
||||
root = execDir
|
||||
}
|
||||
}
|
||||
abs, _ := filepath.Abs(filepath.Join(root, "uploads"))
|
||||
return abs
|
||||
}
|
||||
|
||||
@@ -2,22 +2,51 @@ package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
// Init 使用 DSN 连接 MySQL,供 handler 通过 DB() 使用
|
||||
func Init(dsn string) error {
|
||||
// 慢查询阈值:默认 5 秒,避免 GORM 默认 200ms 导致控制台刷屏;可通过 SLOW_SQL_THRESHOLD_MS 覆盖
|
||||
slowMs := 5000
|
||||
if s := os.Getenv("SLOW_SQL_THRESHOLD_MS"); s != "" {
|
||||
if n, e := strconv.Atoi(s); e == nil && n > 0 {
|
||||
slowMs = n
|
||||
}
|
||||
}
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Duration(slowMs) * time.Millisecond,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
|
||||
var err error
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLogger})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skipMigrate := strings.ToLower(strings.TrimSpace(os.Getenv("SKIP_AUTO_MIGRATE")))
|
||||
if skipMigrate == "1" || skipMigrate == "true" || skipMigrate == "yes" {
|
||||
log.Println("database: SKIP_AUTO_MIGRATE enabled, skipping schema migration")
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil {
|
||||
log.Printf("database: wechat_callback_logs migrate warning: %v", err)
|
||||
}
|
||||
@@ -36,6 +65,12 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.Order{}); err != nil {
|
||||
log.Printf("database: orders migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserBalance{}); err != nil {
|
||||
log.Printf("database: user_balances migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.BalanceTransaction{}); err != nil {
|
||||
log.Printf("database: balance_transactions migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Mentor{}); err != nil {
|
||||
log.Printf("database: mentors migrate warning: %v", err)
|
||||
}
|
||||
@@ -48,8 +83,11 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
|
||||
log.Printf("database: admin_users migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
|
||||
log.Printf("database: user_rules migrate warning: %v", err)
|
||||
if err := db.AutoMigrate(&model.CkbSubmitRecord{}); err != nil {
|
||||
log.Printf("database: ckb_submit_records migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
|
||||
log.Printf("database: ckb_lead_records migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Person{}); err != nil {
|
||||
log.Printf("database: persons migrate warning: %v", err)
|
||||
@@ -57,10 +95,22 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
|
||||
log.Printf("database: link_tags migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Chapter{}); err != nil {
|
||||
log.Printf("database: chapters migrate (hot_score_override) warning: %v", err)
|
||||
// 以下表业务大量使用,必须参与 AutoMigrate,否则旧库缺字段会导致订单/用户/VIP 等接口报错
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
log.Printf("database: users migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemConfig{}); err != nil {
|
||||
log.Printf("database: system_config migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Chapter{}); err != nil {
|
||||
log.Printf("database: chapters migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.GiftPayRequest{}); err != nil {
|
||||
log.Printf("database: gift_pay_requests migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
|
||||
log.Printf("database: user_rules migrate warning: %v", err)
|
||||
}
|
||||
seedDefaultRules(db)
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
@@ -69,27 +119,3 @@ func Init(dsn string) error {
|
||||
func DB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// seedDefaultRules 幂等写入默认用户旅程规则
|
||||
func seedDefaultRules(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&model.UserRule{}).Count(&count)
|
||||
if count > 0 {
|
||||
return // 已有规则,跳过
|
||||
}
|
||||
defaults := []model.UserRule{
|
||||
{Title: "注册完成 → 填写头像", Description: "用户完成注册后,引导填写头像和昵称,提升个人信息完整度", Trigger: "注册", Sort: 10, Enabled: true},
|
||||
{Title: "完成匹配 → 补充个人资料", Description: "用户完成 Soul 派对房匹配后,引导填写 MBTI、行业、职位等详细信息", Trigger: "完成匹配", Sort: 20, Enabled: true},
|
||||
{Title: "首次浏览章节 → 绑定手机号", Description: "用户点击阅读收费章节时,引导绑定手机号以完成身份验证", Trigger: "点击收费章节", Sort: 30, Enabled: true},
|
||||
{Title: "付款 ¥1980 → 填写完整信息", Description: "购买全书(1980元)后,需填写:真实姓名、联系方式、所在行业、MBTI,以便进入 VIP 群", Trigger: "完成付款", Sort: 40, Enabled: true},
|
||||
{Title: "加入派对房 → 填写项目介绍", Description: "进入 Soul 派对房前,引导填写个人项目介绍和核心需求,便于精准匹配", Trigger: "加入派对房", Sort: 50, Enabled: true},
|
||||
{Title: "浏览 5 个章节 → 分享推广", Description: "用户累计阅读 5 个章节后,触发分享引导,邀请好友可获得收益", Trigger: "累计浏览5章节", Sort: 60, Enabled: true},
|
||||
{Title: "绑定微信 → 开启分销", Description: "绑定微信后,提示用户开启分销功能,生成专属推广码", Trigger: "绑定微信", Sort: 70, Enabled: true},
|
||||
{Title: "收益达到 ¥50 → 申请提现", Description: "累计分销收益超过 50 元时,引导用户申请提现", Trigger: "收益满50元", Sort: 80, Enabled: true},
|
||||
{Title: "完善存客宝信息 → 进入流量池", Description: "引导用户授权存客宝信息同步,进入对应微信流量池,获得精准服务", Trigger: "手动触发", Sort: 90, Enabled: true},
|
||||
{Title: "浏览导师主页 → 预约咨询", Description: "用户浏览导师详情页超过 30 秒,引导预约一对一咨询", Trigger: "浏览导师页", Sort: 100, Enabled: true},
|
||||
}
|
||||
if err := db.CreateInBatches(&defaults, len(defaults)).Error; err != nil {
|
||||
log.Printf("database: seed user_rules warning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -11,9 +13,29 @@ import (
|
||||
|
||||
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
|
||||
func AdminChaptersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
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}})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"structure": []interface{}{},
|
||||
"records": []interface{}{},
|
||||
"stats": nil,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": 0,
|
||||
"total": 0,
|
||||
}})
|
||||
return
|
||||
}
|
||||
type section struct {
|
||||
@@ -25,6 +47,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type sectionRecord struct {
|
||||
ID string `json:"id"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Status string `json:"status"`
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type chapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -38,6 +73,7 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
partMap := make(map[string]*part)
|
||||
chapterMap := make(map[string]map[string]*chapter)
|
||||
records := make([]sectionRecord, 0, len(list))
|
||||
for _, row := range list {
|
||||
if partMap[row.PartID] == nil {
|
||||
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
|
||||
@@ -66,6 +102,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st,
|
||||
EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium,
|
||||
})
|
||||
records = append(records, sectionRecord{
|
||||
ID: row.ID,
|
||||
PartID: row.PartID,
|
||||
PartTitle: row.PartTitle,
|
||||
ChapterID: row.ChapterID,
|
||||
ChapterTitle: row.ChapterTitle,
|
||||
Title: row.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: isFree,
|
||||
Status: st,
|
||||
EditionStandard: row.EditionStandard,
|
||||
EditionPremium: row.EditionPremium,
|
||||
})
|
||||
}
|
||||
structure := make([]part, 0, len(partMap))
|
||||
for _, p := range partMap {
|
||||
@@ -73,9 +122,29 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = (int(total) + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(records) {
|
||||
start = len(records)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(records) {
|
||||
end = len(records)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
|
||||
"data": gin.H{
|
||||
"structure": structure,
|
||||
"records": records[start:end],
|
||||
"stats": gin.H{"totalSections": total},
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": totalPages,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +200,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
if body.Action == "delete" {
|
||||
id := resolveID()
|
||||
if id != "" {
|
||||
cache.InvalidateChapterContentByID(id)
|
||||
db.Where("id = ?", id).Delete(&model.Chapter{})
|
||||
}
|
||||
}
|
||||
@@ -156,5 +226,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -1,115 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var paidStatuses = []string{"paid", "completed", "success"}
|
||||
|
||||
// AdminDashboardStats GET /api/admin/dashboard/stats
|
||||
// 轻量聚合:总用户、付费订单数、付费用户数、总营收、转化率(无订单/用户明细)
|
||||
func AdminDashboardStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var (
|
||||
totalUsers int64
|
||||
paidOrderCount int64
|
||||
totalRevenue float64
|
||||
paidUserCount int64
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
go func() { defer wg.Done(); db.Model(&model.User{}).Count(&totalUsers) }()
|
||||
go func() { defer wg.Done(); db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Table("orders").Where("status IN ?", paidStatuses).
|
||||
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
|
||||
}()
|
||||
wg.Wait()
|
||||
conversionRate := 0.0
|
||||
if totalUsers > 0 && paidUserCount > 0 {
|
||||
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"totalUsers": totalUsers,
|
||||
"paidOrderCount": paidOrderCount,
|
||||
"paidUserCount": paidUserCount,
|
||||
"totalRevenue": totalRevenue,
|
||||
"conversionRate": conversionRate,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
|
||||
func AdminDashboardRecentOrders(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(10).Find(&recentOrders)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
|
||||
}
|
||||
|
||||
// AdminDashboardNewUsers GET /api/admin/dashboard/new-users
|
||||
func AdminDashboardNewUsers(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var newUsers []model.User
|
||||
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "newUsers": buildNewUsersOut(newUsers)})
|
||||
}
|
||||
|
||||
// AdminDashboardOverview GET /api/admin/dashboard/overview
|
||||
// 数据概览:总用户、订单数(仅已付款)、转化率(有付款行为的用户数/总用户数)、总收入;最近订单与新注册用户均为实时查询。
|
||||
// 数据概览:总用户、付费订单数、付费用户数、总营收、转化率、最近订单、新用户
|
||||
// 优化:6 组查询并行执行,减少总耗时
|
||||
func AdminDashboardOverview(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var totalUsers int64
|
||||
db.Model(&model.User{}).Count(&totalUsers)
|
||||
var (
|
||||
totalUsers int64
|
||||
paidOrderCount int64
|
||||
totalRevenue float64
|
||||
paidUserCount int64
|
||||
recentOrders []model.Order
|
||||
newUsers []model.User
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(6)
|
||||
|
||||
var paidOrderCount int64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Count(&paidOrderCount)
|
||||
|
||||
var paidUserCount int64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Distinct("user_id").Count(&paidUserCount)
|
||||
|
||||
var totalRevenue float64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Select("COALESCE(SUM(amount),0)").Scan(&totalRevenue)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.User{}).Count(&totalUsers)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Table("orders").Where("status IN ?", paidStatuses).
|
||||
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Where("status IN ?", paidStatuses).
|
||||
Order("created_at DESC").Limit(5).Find(&recentOrders)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
conversionRate := 0.0
|
||||
if totalUsers > 0 && paidUserCount > 0 {
|
||||
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
|
||||
}
|
||||
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", []string{"paid", "completed", "success"}).Order("created_at DESC").Limit(5).Find(&recentOrders)
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range recentOrders {
|
||||
userIDs[o.UserID] = true
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var orderUsers []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&orderUsers)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range orderUsers {
|
||||
userMap[orderUsers[i].ID] = &orderUsers[i]
|
||||
}
|
||||
|
||||
recentOut := make([]gin.H, 0, len(recentOrders))
|
||||
for _, o := range recentOrders {
|
||||
u := userMap[o.UserID]
|
||||
nickname := ""
|
||||
phone := ""
|
||||
avatar := ""
|
||||
if u != nil {
|
||||
if u.Nickname != nil {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
referrerCode := ""
|
||||
if o.ReferralCode != nil {
|
||||
referrerCode = *o.ReferralCode
|
||||
} else if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
if ru := userMap[*o.ReferrerID]; ru != nil && ru.ReferralCode != nil {
|
||||
referrerCode = *ru.ReferralCode
|
||||
}
|
||||
}
|
||||
recentOut = append(recentOut, gin.H{
|
||||
"id": o.ID, "orderSn": o.OrderSN, "userId": o.UserID, "userNickname": nickname, "userAvatar": avatar,
|
||||
"userPhone": phone,
|
||||
"amount": o.Amount, "status": ptrStr(o.Status), "productType": o.ProductType, "productId": o.ProductID, "description": o.Description,
|
||||
"referrerId": o.ReferrerID, "referralCode": referrerCode, "createdAt": o.CreatedAt, "paymentMethod": "微信",
|
||||
})
|
||||
}
|
||||
|
||||
var newUsers []model.User
|
||||
db.Order("created_at DESC").Limit(5).Find(&newUsers)
|
||||
newUsersOut := make([]gin.H, 0, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
nickname := ""
|
||||
if u.Nickname != nil {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
phone := ""
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
newUsersOut = append(newUsersOut, gin.H{
|
||||
"id": u.ID, "nickname": nickname, "phone": phone, "referralCode": u.ReferralCode, "createdAt": u.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 匹配统计
|
||||
var totalMatches int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records").Scan(&totalMatches)
|
||||
var matchRevenue float64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).
|
||||
Select("COALESCE(SUM(amount),0)").Scan(&matchRevenue)
|
||||
recentOut := buildRecentOrdersOut(db, recentOrders)
|
||||
newOut := buildNewUsersOut(newUsers)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -119,15 +132,84 @@ func AdminDashboardOverview(c *gin.Context) {
|
||||
"totalRevenue": totalRevenue,
|
||||
"conversionRate": conversionRate,
|
||||
"recentOrders": recentOut,
|
||||
"newUsers": newUsersOut,
|
||||
"totalMatches": totalMatches,
|
||||
"matchRevenue": matchRevenue,
|
||||
"newUsers": newOut,
|
||||
})
|
||||
}
|
||||
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
func dashStr(s *string) string {
|
||||
if s == nil || *s == "" {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
|
||||
if len(recentOrders) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range recentOrders {
|
||||
if o.UserID != "" {
|
||||
userIDs[o.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
out := make([]gin.H, 0, len(recentOrders))
|
||||
for _, o := range recentOrders {
|
||||
b, _ := json.Marshal(o)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if u := userMap[o.UserID]; u != nil {
|
||||
m["userNickname"] = dashStr(u.Nickname)
|
||||
m["userAvatar"] = dashStr(u.Avatar)
|
||||
} else {
|
||||
m["userNickname"] = ""
|
||||
m["userAvatar"] = ""
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
|
||||
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
|
||||
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error
|
||||
func AdminDashboardMerchantBalance(c *gin.Context) {
|
||||
bal, err := wechat.QueryMerchantBalance("BASIC")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
"message": "查询商户余额失败,可能未开通权限(请联系微信支付运营申请)",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"availableAmount": bal.AvailableAmount, // 单位:分
|
||||
"pendingAmount": bal.PendingAmount, // 单位:分
|
||||
})
|
||||
}
|
||||
|
||||
func buildNewUsersOut(newUsers []model.User) []gin.H {
|
||||
out := make([]gin.H, 0, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
out = append(out, gin.H{
|
||||
"id": u.ID,
|
||||
"nickname": dashStr(u.Nickname),
|
||||
"phone": dashStr(u.Phone),
|
||||
"referralCode": dashStr(u.ReferralCode),
|
||||
"createdAt": u.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
206
soul-api/internal/handler/admin_linked_mp.go
Normal file
206
soul-api/internal/handler/admin_linked_mp.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const linkedMpConfigKey = "linked_miniprograms"
|
||||
|
||||
// LinkedMpItem 关联小程序项,key 为 32 位密钥,链接标签存 key,小程序端用 key 查 appId
|
||||
type LinkedMpItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
|
||||
// AdminLinkedMpList GET /api/admin/linked-miniprograms 管理端-关联小程序列表
|
||||
func AdminLinkedMpList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []LinkedMpItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// AdminLinkedMpCreate POST /api/admin/linked-miniprograms 管理端-新增关联小程序
|
||||
func AdminLinkedMpCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AppID string `json:"appId" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写小程序名称和 AppID"})
|
||||
return
|
||||
}
|
||||
body.Name = trimSpace(body.Name)
|
||||
body.AppID = trimSpace(body.AppID)
|
||||
if body.Name == "" || body.AppID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
|
||||
return
|
||||
}
|
||||
key, err := genMpKey()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
item := LinkedMpItem{Key: key, Name: body.Name, AppID: body.AppID, Path: body.Path, Sort: body.Sort}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
var list []LinkedMpItem
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
list = []LinkedMpItem{}
|
||||
} else {
|
||||
_ = json.Unmarshal(row.ConfigValue, &list)
|
||||
if list == nil {
|
||||
list = []LinkedMpItem{}
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
valBytes, _ := json.Marshal(list)
|
||||
desc := "关联小程序列表,用于 wx.navigateToMiniProgram 跳转"
|
||||
if row.ConfigKey == "" {
|
||||
row = model.SystemConfig{ConfigKey: linkedMpConfigKey, ConfigValue: valBytes, Description: &desc}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
}
|
||||
|
||||
// AdminLinkedMpUpdate PUT /api/admin/linked-miniprograms 管理端-编辑关联小程序
|
||||
func AdminLinkedMpUpdate(c *gin.Context) {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
AppID string `json:"appId" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
body.Name = trimSpace(body.Name)
|
||||
body.AppID = trimSpace(body.AppID)
|
||||
if body.Name == "" || body.AppID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].Key == body.Key {
|
||||
list[i].Name = body.Name
|
||||
list[i].AppID = body.AppID
|
||||
list[i].Path = body.Path
|
||||
list[i].Sort = body.Sort
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
valBytes, _ := json.Marshal(list)
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminLinkedMpDelete DELETE /api/admin/linked-miniprograms/:id 管理端-删除(:id 实际传 key)
|
||||
func AdminLinkedMpDelete(c *gin.Context) {
|
||||
key := c.Param("id")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少密钥"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
|
||||
return
|
||||
}
|
||||
newList := make([]LinkedMpItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.Key != key {
|
||||
newList = append(newList, item)
|
||||
}
|
||||
}
|
||||
valBytes, _ := json.Marshal(newList)
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// genMpKey 生成 32 位英文+数字密钥,供链接标签引用
|
||||
func genMpKey() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// base64 编码后取 32 位,去掉 +/= 仅保留字母数字
|
||||
s := base64.URLEncoding.EncodeToString(b)
|
||||
s = strings.ReplaceAll(s, "+", "")
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "=", "")
|
||||
if len(s) >= 32 {
|
||||
return s[:32], nil
|
||||
}
|
||||
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
||||
}
|
||||
@@ -304,3 +304,70 @@ func DBUsersJourneyStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
|
||||
}
|
||||
|
||||
// journeyUserItem 用户旅程列表项
|
||||
type journeyUserItem struct {
|
||||
ID string `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Phone string `json:"phone"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// DBUsersJourneyUsers GET /api/db/users/journey-users?stage=xxx&limit=20 — 按阶段查用户
|
||||
func DBUsersJourneyUsers(c *gin.Context) {
|
||||
db := database.DB()
|
||||
stage := strings.TrimSpace(c.Query("stage"))
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
limit := 20
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
switch stage {
|
||||
case "register":
|
||||
db.Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "browse":
|
||||
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "view_chapter").Distinct("user_id")
|
||||
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "bind_phone":
|
||||
db.Where("phone IS NOT NULL AND phone != ''").Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "first_pay":
|
||||
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
|
||||
Where("status IN ?", []string{"paid", "success", "completed"})).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "fill_profile":
|
||||
db.Where("mbti IS NOT NULL OR industry IS NOT NULL").Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "match":
|
||||
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "match").Distinct("user_id")
|
||||
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "vip":
|
||||
db.Where("is_vip = ?", true).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "distribution":
|
||||
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]journeyUserItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
nick, phone := "", ""
|
||||
if u.Nickname != nil {
|
||||
nick = *u.Nickname
|
||||
}
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
list = append(list, journeyUserItem{
|
||||
ID: u.ID,
|
||||
Nickname: nick,
|
||||
Phone: phone,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "users": list})
|
||||
}
|
||||
|
||||
102
soul-api/internal/handler/admin_track.go
Normal file
102
soul-api/internal/handler/admin_track.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminTrackStats GET /api/admin/track/stats 管理端-按钮/标签点击统计(按模块+action聚合)
|
||||
func AdminTrackStats(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "today")
|
||||
db := database.DB()
|
||||
|
||||
now := time.Now()
|
||||
var since time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
case "week":
|
||||
since = now.AddDate(0, 0, -7)
|
||||
case "month":
|
||||
since = now.AddDate(0, -1, 0)
|
||||
default:
|
||||
since = time.Time{}
|
||||
}
|
||||
|
||||
type rawRow struct {
|
||||
Action string `gorm:"column:action"`
|
||||
Target string `gorm:"column:target"`
|
||||
ExtraData []byte `gorm:"column:extra_data"`
|
||||
}
|
||||
|
||||
query := db.Table("user_tracks").Select("action, COALESCE(target, '') as target, extra_data")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
query = query.Where("action NOT LIKE '%union%' AND action NOT LIKE '%jndi%' AND action NOT LIKE '%SLEEP%'")
|
||||
|
||||
var rawRows []rawRow
|
||||
query.Find(&rawRows)
|
||||
|
||||
type statKey struct {
|
||||
Module string
|
||||
Action string
|
||||
Target string
|
||||
}
|
||||
type statItem struct {
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Module string `json:"module"`
|
||||
Page string `json:"page"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
aggregated := make(map[statKey]*statItem)
|
||||
total := int64(0)
|
||||
|
||||
for _, r := range rawRows {
|
||||
module := "other"
|
||||
page := ""
|
||||
if len(r.ExtraData) > 0 {
|
||||
var extra map[string]interface{}
|
||||
if json.Unmarshal(r.ExtraData, &extra) == nil {
|
||||
if m, ok := extra["module"].(string); ok && m != "" {
|
||||
module = m
|
||||
}
|
||||
if p, ok := extra["page"].(string); ok && p != "" {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
}
|
||||
key := statKey{Module: module, Action: r.Action, Target: r.Target}
|
||||
if existing, ok := aggregated[key]; ok {
|
||||
existing.Count++
|
||||
} else {
|
||||
aggregated[key] = &statItem{
|
||||
Action: r.Action,
|
||||
Target: r.Target,
|
||||
Module: module,
|
||||
Page: page,
|
||||
Count: 1,
|
||||
}
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
byModule := make(map[string][]statItem)
|
||||
for _, item := range aggregated {
|
||||
byModule[item.Module] = append(byModule[item.Module], *item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"byModule": byModule,
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniprogramUserRules GET /api/miniprogram/user-rules(小程序端,只返回启用的规则)
|
||||
func MiniprogramUserRules(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
if err := db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&rules).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
out = append(out, gin.H{"id": r.ID, "title": r.Title, "description": r.Description, "trigger": r.Trigger, "sort": r.Sort})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": out})
|
||||
}
|
||||
|
||||
// DBUserRulesList GET /api/db/user-rules
|
||||
func DBUserRulesList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -22,6 +37,17 @@ func DBUserRulesList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
|
||||
func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
if err := db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&rules).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
|
||||
func DBUserRulesAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminAppUsersList GET /api/admin/users 普通用户列表兼容入口(转发到 DBUsersList)
|
||||
func AdminAppUsersList(c *gin.Context) {
|
||||
DBUsersList(c)
|
||||
}
|
||||
|
||||
// AdminUsersList GET /api/admin/users 管理员用户列表(仅 super_admin)
|
||||
func AdminUsersList(c *gin.Context) {
|
||||
claims := middleware.GetAdminClaims(c)
|
||||
|
||||
236
soul-api/internal/handler/autolink.go
Normal file
236
soul-api/internal/handler/autolink.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// sanitizeNameOrLabel 去除括号及内容,如 南风(管理) -> 南风
|
||||
func sanitizeNameOrLabel(s string) string {
|
||||
re := regexp.MustCompile(`\s*[((][^))]*(\)|))?`)
|
||||
return strings.TrimSpace(re.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
// isValidNameOrLabel 仅允许包含至少一个汉字/字母/数字,纯符号(如 "、,、!)则跳过,不创建
|
||||
func isValidNameOrLabel(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 至少包含一个 Unicode 字母或数字
|
||||
re := regexp.MustCompile(`[\p{L}\p{N}]`)
|
||||
return re.MatchString(s)
|
||||
}
|
||||
|
||||
// ensurePersonByName 按名称确保 Person 存在,不存在则创建(含存客宝计划),返回 token
|
||||
func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
clean := sanitizeNameOrLabel(name)
|
||||
if clean == "" || !isValidNameOrLabel(clean) {
|
||||
return "", nil
|
||||
}
|
||||
var p model.Person
|
||||
if db.Where("name = ?", clean).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
// 按净化后的名字再查一次
|
||||
if db.Where("name = ?", name).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
created, err := createPersonMinimal(db, clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return created.Token, nil
|
||||
}
|
||||
|
||||
// getPersonNameByToken 按 token 查 Person 返回 name,用于修复已损坏的 mention(显示 token 而非名字)
|
||||
func getPersonNameByToken(db *gorm.DB, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
var p model.Person
|
||||
if db.Select("name").Where("token = ?", token).First(&p).Error != nil {
|
||||
return ""
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// ensureLinkTagByLabel 按 label 确保 LinkTag 存在,不存在则创建
|
||||
func ensureLinkTagByLabel(db *gorm.DB, label string) (tagID string, err error) {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return "", nil
|
||||
}
|
||||
clean := sanitizeNameOrLabel(label)
|
||||
if clean == "" || !isValidNameOrLabel(clean) {
|
||||
return "", nil
|
||||
}
|
||||
var t model.LinkTag
|
||||
if db.Where("label = ?", clean).First(&t).Error == nil {
|
||||
return t.TagID, nil
|
||||
}
|
||||
if db.Where("label = ?", label).First(&t).Error == nil {
|
||||
return t.TagID, nil
|
||||
}
|
||||
t = model.LinkTag{TagID: clean, Label: clean, Type: "url"}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t.TagID, nil
|
||||
}
|
||||
|
||||
// ParseAutoLinkContent 解析 content 中的 @人物 和 #标签,确保存在并转为带 data-id 的 span,后端统一处理
|
||||
func ParseAutoLinkContent(content string) (string, error) {
|
||||
if content == "" || (!strings.Contains(content, "@") && !strings.Contains(content, "#")) {
|
||||
return content, nil
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 1. 提取所有 @name 和 #label(排除 <> 避免匹配到 HTML 标签内)
|
||||
mentionRe := regexp.MustCompile(`@([^\s@#<>]+)`)
|
||||
tagRe := regexp.MustCompile(`#([^\s@#<>]+)`)
|
||||
names := make(map[string]string) // cleanName -> token
|
||||
labels := make(map[string]string) // cleanLabel -> tagId
|
||||
|
||||
for _, m := range mentionRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
raw := m[1]
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
if clean == "" || !isValidNameOrLabel(clean) || names[clean] != "" {
|
||||
continue
|
||||
}
|
||||
token, err := ensurePersonByName(db, clean)
|
||||
if err == nil && token != "" {
|
||||
names[clean] = token
|
||||
names[raw] = token // 原始名也映射
|
||||
}
|
||||
}
|
||||
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
raw := m[1]
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
if clean == "" || !isValidNameOrLabel(clean) || labels[clean] != "" {
|
||||
continue
|
||||
}
|
||||
tagID, err := ensureLinkTagByLabel(db, clean)
|
||||
if err == nil && tagID != "" {
|
||||
labels[clean] = tagID
|
||||
labels[raw] = tagID
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 占位符替换:先把已有 mention/linkTag span 保护起来
|
||||
mentionSpanRe := regexp.MustCompile(`<span[^>]*data-type="mention"[^>]*>.*?</span>`)
|
||||
linkTagSpanRe := regexp.MustCompile(`<span[^>]*data-type="linkTag"[^>]*>.*?</span>`)
|
||||
placeholders := []string{}
|
||||
|
||||
content = mentionSpanRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
// 回填 data-id、data-label:TipTap 用 data-label 显示名字,缺则显示 token
|
||||
idRe := regexp.MustCompile(`data-id="([^"]*)"`)
|
||||
labelRe := regexp.MustCompile(`data-label="([^"]*)"`)
|
||||
innerRe := regexp.MustCompile(`>([^<]+)<`)
|
||||
nickname := ""
|
||||
if labelRe.MatchString(m) {
|
||||
sub := labelRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
|
||||
nickname = sanitizeNameOrLabel(sub[1])
|
||||
}
|
||||
}
|
||||
if nickname == "" && innerRe.MatchString(m) {
|
||||
sub := innerRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 {
|
||||
nickname = sanitizeNameOrLabel(strings.TrimPrefix(sub[1], "@"))
|
||||
}
|
||||
}
|
||||
// 若 inner 是 token(如已损坏显示 @pZefXfWlon...),用 token 查 Person 取真实名字
|
||||
if idRe.MatchString(m) {
|
||||
sub := idRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
|
||||
tokenVal := sub[1]
|
||||
innerSub := innerRe.FindStringSubmatch(m)
|
||||
innerText := ""
|
||||
if len(innerSub) >= 2 {
|
||||
innerText = strings.TrimPrefix(innerSub[1], "@")
|
||||
}
|
||||
// 若 inner 看起来像 token(长串字母数字)且与 data-id 一致,用 DB 查真实名字
|
||||
if innerText == tokenVal && len(tokenVal) >= 20 {
|
||||
if realName := getPersonNameByToken(db, tokenVal); realName != "" {
|
||||
nickname = realName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if nickname != "" {
|
||||
if token, ok := names[nickname]; ok && token != "" {
|
||||
if idRe.MatchString(m) {
|
||||
m = idRe.ReplaceAllString(m, `data-id="`+token+`"`)
|
||||
} else {
|
||||
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-id="`+token+`"`, 1)
|
||||
}
|
||||
}
|
||||
// 确保有 data-label,否则 TipTap 会显示 token 而非名字
|
||||
needLabel := !labelRe.MatchString(m)
|
||||
if !needLabel {
|
||||
if sub := labelRe.FindStringSubmatch(m); len(sub) >= 2 && strings.TrimSpace(sub[1]) == "" {
|
||||
needLabel = true
|
||||
}
|
||||
}
|
||||
if needLabel {
|
||||
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-label="`+nickname+`"`, 1)
|
||||
}
|
||||
}
|
||||
placeholders = append(placeholders, m)
|
||||
return "\x00PLACEHOLDER\x00"
|
||||
})
|
||||
|
||||
content = linkTagSpanRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
placeholders = append(placeholders, m)
|
||||
return "\x00PLACEHOLDER\x00"
|
||||
})
|
||||
|
||||
// 3. 替换纯文本 @name 和 #label
|
||||
content = mentionRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
raw := strings.TrimPrefix(m, "@")
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
token := names[clean]
|
||||
if token == "" {
|
||||
token = names[raw]
|
||||
}
|
||||
if token == "" {
|
||||
return m
|
||||
}
|
||||
return `<span data-type="mention" data-id="` + token + `" data-label="` + raw + `" class="mention-tag">@` + raw + `</span>`
|
||||
})
|
||||
|
||||
content = tagRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
raw := strings.TrimPrefix(m, "#")
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
tagID := labels[clean]
|
||||
if tagID == "" {
|
||||
tagID = labels[raw]
|
||||
}
|
||||
if tagID == "" {
|
||||
return m
|
||||
}
|
||||
return `<span data-type="linkTag" data-url="" data-tag-type="url" data-tag-id="` + tagID + `" data-page-path="" data-app-id="" class="link-tag-node">#` + raw + `</span>`
|
||||
})
|
||||
|
||||
// 4. 恢复占位符
|
||||
for _, p := range placeholders {
|
||||
content = strings.Replace(content, "\x00PLACEHOLDER\x00", p, 1)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
404
soul-api/internal/handler/balance.go
Normal file
404
soul-api/internal/handler/balance.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BalanceGet GET /api/miniprogram/balance?userId=
|
||||
func BalanceGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var ub model.UserBalance
|
||||
if err := db.Where("user_id = ?", userID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": 0}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": ub.Balance}})
|
||||
}
|
||||
|
||||
// BalanceTransactionsGet GET /api/miniprogram/balance/transactions?userId=&page=&pageSize=
|
||||
func BalanceTransactionsGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
db := database.DB()
|
||||
var total int64
|
||||
db.Model(&model.BalanceTransaction{}).Where("user_id = ?", userID).Count(&total)
|
||||
var list []model.BalanceTransaction
|
||||
if err := db.Where("user_id = ?", userID).Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, t := range list {
|
||||
orderID := ""
|
||||
if t.RelatedOrder != nil {
|
||||
orderID = *t.RelatedOrder
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": t.ID, "type": t.Type, "amount": t.Amount,
|
||||
"orderId": orderID, "createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
|
||||
}
|
||||
|
||||
// BalanceRechargePost POST /api/miniprogram/balance/recharge
|
||||
func BalanceRechargePost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
db := database.DB()
|
||||
desc := fmt.Sprintf("余额充值 ¥%.2f", req.Amount)
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: "balance_recharge",
|
||||
ProductID: &orderSn,
|
||||
Amount: req.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSn}})
|
||||
}
|
||||
|
||||
// BalanceRechargeConfirmPost POST /api/miniprogram/balance/recharge/confirm
|
||||
func BalanceRechargeConfirmPost(c *gin.Context) {
|
||||
var req struct {
|
||||
OrderSn string `json:"orderSn" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 orderSn"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.Order
|
||||
if err := tx.Where("order_sn = ? AND product_type = ?", req.OrderSn, "balance_recharge").First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := ""
|
||||
if order.Status != nil {
|
||||
status = *order.Status
|
||||
}
|
||||
if status != "paid" {
|
||||
return fmt.Errorf("订单未支付")
|
||||
}
|
||||
// 幂等:检查是否已处理
|
||||
var cnt int64
|
||||
tx.Model(&model.BalanceTransaction{}).Where("related_order = ? AND type = ?", req.OrderSn, "recharge").Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
|
||||
RelatedOrder: &req.OrderSn, CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// BalanceConsumePost POST /api/miniprogram/balance/consume
|
||||
func BalanceConsumePost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 后端价格校验
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
if req.Amount < standardPrice-0.01 || req.Amount > standardPrice+0.01 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "金额校验失败"})
|
||||
return
|
||||
}
|
||||
amount := standardPrice
|
||||
|
||||
// 解析推荐人
|
||||
var referrerID *string
|
||||
if req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
if referrerID == nil {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
_ = db.Raw("SELECT referrer_id FROM referral_bindings WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW() ORDER BY binding_date DESC LIMIT 1", req.UserID).Scan(&binding).Error
|
||||
if binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "fullbook":
|
||||
productID = "fullbook"
|
||||
default:
|
||||
productID = req.ProductID
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ub.Balance < amount {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", amount))
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
desc := ""
|
||||
switch req.ProductType {
|
||||
case "section":
|
||||
desc = "章节购买-" + productID
|
||||
case "fullbook":
|
||||
desc = "《一场Soul的创业实验》全书"
|
||||
case "vip":
|
||||
desc = "卡若创业派对VIP年度会员(365天)"
|
||||
default:
|
||||
desc = "余额消费"
|
||||
}
|
||||
pm := "balance"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
ReferrerID: referrerID,
|
||||
PayTime: &now,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: req.UserID, Type: "consume", Amount: -amount,
|
||||
RelatedOrder: &orderSn, CreatedAt: now,
|
||||
})
|
||||
// 激活权益
|
||||
if req.ProductType == "fullbook" {
|
||||
tx.Model(&model.User{}).Where("id = ?", req.UserID).Update("has_full_book", true)
|
||||
} else if req.ProductType == "vip" {
|
||||
activateVIP(tx, req.UserID, 365, now)
|
||||
}
|
||||
// 分佣
|
||||
if referrerID != nil {
|
||||
processReferralCommission(tx, req.UserID, amount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if err.Error() == "余额不足" {
|
||||
c.JSON(http.StatusOK, 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})
|
||||
}
|
||||
|
||||
// BalanceRefundPost POST /api/miniprogram/balance/refund
|
||||
func BalanceRefundPost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
// 首版简化:暂不实现微信原路退,仅扣减余额并记录
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("余额为零")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ub.Balance < req.Amount {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", req.Amount))
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: req.UserID, Type: "refund", Amount: -req.Amount,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "退款申请已提交,1-3个工作日内原路返回"}})
|
||||
}
|
||||
|
||||
// AdminUserBalanceGet GET /api/admin/users/:id/balance 管理端-用户余额与最近交易
|
||||
func AdminUserBalanceGet(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var ub model.UserBalance
|
||||
balance := 0.0
|
||||
if err := db.Where("user_id = ?", userID).First(&ub).Error; err == nil {
|
||||
balance = ub.Balance
|
||||
}
|
||||
var list []model.BalanceTransaction
|
||||
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(20).Find(&list)
|
||||
transactions := make([]gin.H, 0, len(list))
|
||||
for _, t := range list {
|
||||
orderID := ""
|
||||
if t.RelatedOrder != nil {
|
||||
orderID = *t.RelatedOrder
|
||||
}
|
||||
transactions = append(transactions, gin.H{
|
||||
"id": t.ID, "type": t.Type, "amount": t.Amount,
|
||||
"orderId": orderID, "createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": balance, "transactions": transactions}})
|
||||
}
|
||||
|
||||
// AdminUserBalanceAdjust POST /api/admin/users/:id/balance/adjust 管理端-人工调整用户余额
|
||||
func AdminUserBalanceAdjust(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"` // 正数增加,负数扣减
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if req.Amount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "调整金额不能为 0"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", userID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ub = model.UserBalance{UserID: userID, Balance: 0}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
newBalance := ub.Balance + req.Amount
|
||||
if newBalance < 0 {
|
||||
return fmt.Errorf("调整后余额不能为负,当前余额 %.2f", ub.Balance)
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = ?, updated_at = NOW()", userID, newBalance)
|
||||
return tx.Create(&model.BalanceTransaction{
|
||||
UserID: userID, Type: "admin_adjust", Amount: req.Amount,
|
||||
CreatedAt: time.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": "余额已调整"})
|
||||
}
|
||||
|
||||
// ConfirmBalanceRechargeByOrder 支付成功后确认充值(幂等),供 PayNotify 和 activateOrderBenefits 调用
|
||||
func ConfirmBalanceRechargeByOrder(db *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.ProductType != "balance_recharge" {
|
||||
return nil
|
||||
}
|
||||
orderSn := order.OrderSN
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var cnt int64
|
||||
tx.Model(&model.BalanceTransaction{}).Where("order_id = ? AND type = ?", orderSn, "recharge").Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return nil // 已处理,幂等
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
|
||||
return tx.Create(&model.BalanceTransaction{
|
||||
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
|
||||
RelatedOrder: &orderSn, CreatedAt: time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -15,15 +22,207 @@ import (
|
||||
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// sortChaptersByNaturalID 同 sort_order 时按 id 自然排序(9.1 < 9.2 < 9.10),调用 db_book 的 naturalLessSectionID
|
||||
func sortChaptersByNaturalID(list []model.Chapter) {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
if list[i].SortOrder != nil {
|
||||
soI = *list[i].SortOrder
|
||||
}
|
||||
if list[j].SortOrder != nil {
|
||||
soJ = *list[j].SortOrder
|
||||
}
|
||||
if soI != soJ {
|
||||
return soI < soJ
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
}
|
||||
|
||||
// allChaptersSelectCols 列表不加载 content(longtext),避免 502 超时
|
||||
var allChaptersSelectCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// chapterMetaCols 章节详情元数据(不含 content),用于 content 缓存命中时的轻量查询
|
||||
var chapterMetaCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// allChaptersCache 内存缓存,减轻 DB 压力,30 秒 TTL
|
||||
var allChaptersCache struct {
|
||||
mu sync.RWMutex
|
||||
data []model.Chapter
|
||||
expires time.Time
|
||||
key string // excludeFixed 不同则 key 不同
|
||||
}
|
||||
|
||||
const allChaptersCacheTTL = 30 * time.Second
|
||||
|
||||
// bookPartsCache 目录接口内存缓存,30 秒 TTL,减轻 DB 压力
|
||||
type cachedPartRow struct {
|
||||
PartID string `json:"id"`
|
||||
PartTitle string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
MinSortOrder int `json:"minSortOrder"`
|
||||
}
|
||||
type cachedFixedItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid"`
|
||||
SectionTitle string `json:"title"`
|
||||
}
|
||||
|
||||
// bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致
|
||||
type bookPartsRedisPayload struct {
|
||||
Parts []cachedPartRow `json:"parts"`
|
||||
TotalSections int64 `json:"totalSections"`
|
||||
FixedSections []cachedFixedItem `json:"fixedSections"`
|
||||
}
|
||||
|
||||
var bookPartsCache struct {
|
||||
mu sync.RWMutex
|
||||
parts []cachedPartRow
|
||||
total int64
|
||||
fixed []cachedFixedItem
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
allChaptersCache.key = "default"
|
||||
allChaptersCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// fetchAndCacheBookParts 执行 DB 查询并更新缓存,供 BookParts 与 WarmBookPartsCache 复用
|
||||
func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cachedFixedItem) {
|
||||
db := database.DB()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conds := make([]string, len(excludeParts))
|
||||
args := make([]interface{}, len(excludeParts))
|
||||
for i, p := range excludeParts {
|
||||
conds[i] = "part_title LIKE ?"
|
||||
args[i] = "%" + p + "%"
|
||||
}
|
||||
where := "(" + strings.Join(conds, " OR ") + ")"
|
||||
var rows []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
|
||||
Where(where, args...).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&rows).Error; err == nil {
|
||||
sortChaptersByNaturalID(rows)
|
||||
for _, r := range rows {
|
||||
fixed = append(fixed, cachedFixedItem{r.ID, r.MID, r.SectionTitle})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
where := "1=1"
|
||||
args := []interface{}{}
|
||||
for _, p := range excludeParts {
|
||||
where += " AND part_title NOT LIKE ?"
|
||||
args = append(args, "%"+p+"%")
|
||||
}
|
||||
sql := `SELECT part_id, part_title, '' as subtitle,
|
||||
COUNT(DISTINCT chapter_id) as chapter_count,
|
||||
MIN(COALESCE(sort_order, 999999)) as min_sort
|
||||
FROM chapters WHERE ` + where + `
|
||||
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
|
||||
var raw []struct {
|
||||
PartID string `gorm:"column:part_id"`
|
||||
PartTitle string `gorm:"column:part_title"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
ChapterCount int `gorm:"column:chapter_count"`
|
||||
MinSortOrder int `gorm:"column:min_sort"`
|
||||
}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Raw(sql, args...).Scan(&raw)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Chapter{}).Count(&total)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
parts = make([]cachedPartRow, len(raw))
|
||||
for i, r := range raw {
|
||||
parts[i] = cachedPartRow{
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
|
||||
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
bookPartsCache.mu.Lock()
|
||||
bookPartsCache.parts = parts
|
||||
bookPartsCache.total = total
|
||||
bookPartsCache.fixed = fixed
|
||||
bookPartsCache.expires = time.Now().Add(bookPartsCacheTTL)
|
||||
bookPartsCache.mu.Unlock()
|
||||
|
||||
return parts, total, fixed
|
||||
}
|
||||
|
||||
// WarmBookPartsCache 启动时预热目录缓存(内存+Redis),避免首请求慢
|
||||
func WarmBookPartsCache() {
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
||||
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
||||
}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
// 小程序目录页以此接口为准,与后台内容管理一致;含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 不过滤 status,后台配置的篇章均返回,由前端展示。
|
||||
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
cacheKey := "default"
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
cacheKey = "excludeFixed"
|
||||
}
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
data := allChaptersCache.data
|
||||
allChaptersCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
return
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
if cacheKey == "excludeFixed" {
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
@@ -33,7 +232,24 @@ func BookAllChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
allChaptersCache.key = cacheKey
|
||||
allChaptersCache.mu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
|
||||
@@ -48,6 +264,81 @@ func BookChapterByID(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
|
||||
// 返回 parts(排除序言/尾声/附录)、totalSections、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid)
|
||||
// 缓存优先级:Redis(10min,后台更新时失效)> 内存(30s)> DB;Redis 不可用时回退内存+DB
|
||||
func BookParts(c *gin.Context) {
|
||||
// 1. 优先 Redis(后台无更新时长期有效)
|
||||
var redisPayload bookPartsRedisPayload
|
||||
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": redisPayload.Parts,
|
||||
"totalSections": redisPayload.TotalSections,
|
||||
"fixedSections": redisPayload.FixedSections,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 内存缓存(30s,Redis 不可用时的容灾)
|
||||
bookPartsCache.mu.RLock()
|
||||
if time.Now().Before(bookPartsCache.expires) {
|
||||
parts := bookPartsCache.parts
|
||||
total := bookPartsCache.total
|
||||
fixed := bookPartsCache.fixed
|
||||
bookPartsCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
return
|
||||
}
|
||||
bookPartsCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询并更新 Redis + 内存
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
||||
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
}
|
||||
|
||||
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
||||
func BookChaptersByPart(c *gin.Context) {
|
||||
partId := c.Query("partId")
|
||||
if partId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var list []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Where("part_id = ?", partId).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
@@ -65,11 +356,144 @@ func BookChapterByMID(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
||||
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
for _, key := range []string{"free_chapters", "chapter_config"} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
if key == "free_chapters" {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
ids[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if key == "chapter_config" {
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
if arr, ok := m["freeChapters"].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
ids[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// checkUserChapterAccess 判断 userId 是否有权读取 chapterID(VIP / 全书购买 / 单章购买)
|
||||
// isPremium=true 表示增值版,fullbook 买断不含增值版
|
||||
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
// VIP:is_vip=1 且未过期
|
||||
var u model.User
|
||||
if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
return true
|
||||
}
|
||||
// 全书买断(不含增值版)
|
||||
if !isPremium && u.HasFullBook != nil && *u.HasFullBook {
|
||||
return true
|
||||
}
|
||||
// 全书订单(兜底)
|
||||
if !isPremium {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 单章购买
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'",
|
||||
userID, chapterID,
|
||||
).Count(&cnt)
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent,默认 20
|
||||
func getUnpaidPreviewPercent(db *gorm.DB) int {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
|
||||
return 20
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
return 20
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
p := int(v)
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
return p
|
||||
case int:
|
||||
if v < 1 {
|
||||
return 1
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
|
||||
func previewContent(content string, percent int) string {
|
||||
total := utf8.RuneCountInString(content)
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
if percent < 1 {
|
||||
percent = 1
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
limit := total * percent / 100
|
||||
if limit < 100 {
|
||||
limit = 100
|
||||
}
|
||||
const maxPreview = 500
|
||||
if limit > maxPreview {
|
||||
limit = maxPreview
|
||||
}
|
||||
if limit > total {
|
||||
limit = total
|
||||
}
|
||||
runes := []rune(content)
|
||||
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
// 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
|
||||
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content,否则返回 previewContent
|
||||
// content 缓存:优先 Redis,命中时仅查元数据(不含 LONGTEXT),未命中时查全量并回填缓存
|
||||
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
var ch model.Chapter
|
||||
db := database.DB()
|
||||
if err := whereFn(db).First(&ch).Error; err != nil {
|
||||
// 1. 先查元数据(不含 content,轻量)
|
||||
if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
@@ -77,25 +501,59 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 取 content:优先 Redis,未命中再查 DB
|
||||
if content, ok := cache.GetString(context.Background(), cache.KeyChapterContent(ch.MID)); ok && content != "" {
|
||||
ch.Content = content
|
||||
} else {
|
||||
if err := db.Model(&model.Chapter{}).Where("mid = ?", ch.MID).Pluck("content", &ch.Content).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.SetString(context.Background(), cache.KeyChapterContent(ch.MID), ch.Content, cache.ChapterContentTTL)
|
||||
}
|
||||
|
||||
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
|
||||
isFree := isFreeFromConfig
|
||||
if !isFree && ch.IsFree != nil && *ch.IsFree {
|
||||
isFree = true
|
||||
}
|
||||
if !isFree && ch.Price != nil && *ch.Price == 0 {
|
||||
isFree = true
|
||||
}
|
||||
|
||||
// 确定返回的 content:免费直接返回,付费须校验购买权限
|
||||
userID := c.Query("userId")
|
||||
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
|
||||
var returnContent string
|
||||
if isFree {
|
||||
returnContent = ch.Content
|
||||
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
|
||||
returnContent = ch.Content
|
||||
} else {
|
||||
percent := getUnpaidPreviewPercent(db)
|
||||
returnContent = previewContent(ch.Content, percent)
|
||||
}
|
||||
|
||||
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
|
||||
chForResponse := ch
|
||||
chForResponse.Content = returnContent
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"data": ch,
|
||||
"content": ch.Content,
|
||||
"data": chForResponse,
|
||||
"content": returnContent,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"partTitle": ch.PartTitle,
|
||||
"id": ch.ID,
|
||||
"mid": ch.MID,
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
"isFree": isFree,
|
||||
}
|
||||
if ch.IsFree != nil {
|
||||
out["isFree"] = *ch.IsFree
|
||||
}
|
||||
if ch.Price != nil {
|
||||
if isFreeFromConfig {
|
||||
out["price"] = float64(0)
|
||||
} else if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
// 价格为 0 元则自动视为免费
|
||||
if *ch.Price == 0 {
|
||||
out["isFree"] = true
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -195,7 +653,7 @@ func BookChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
// bookHotChaptersSorted 按阅读量优先排序(兼容旧逻辑);排除序言/尾声/附录
|
||||
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
|
||||
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
q := db.Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
@@ -205,6 +663,8 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
sortChaptersByNaturalID(all)
|
||||
// 从 reading_progress 统计阅读量
|
||||
ids := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
ids = append(ids, c.ID)
|
||||
@@ -219,9 +679,10 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
for _, r := range counts {
|
||||
countMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
// 按阅读量降序、同量按 updated_at 降序
|
||||
type withSort struct {
|
||||
ch model.Chapter
|
||||
cnt int64
|
||||
ch model.Chapter
|
||||
cnt int64
|
||||
}
|
||||
withCnt := make([]withSort, 0, len(all))
|
||||
for _, c := range all {
|
||||
@@ -242,162 +703,102 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
return out
|
||||
}
|
||||
|
||||
// bookRecommendedByScore 文章推荐算法:阅读量前20(50%) + 最近30篇(30%) + 付款数前20(20%),排除序言/尾声/附录
|
||||
func bookRecommendedByScore(db *gorm.DB, limit int) []model.Chapter {
|
||||
q := db.Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var all []model.Chapter
|
||||
if err := q.Find(&all).Error; err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
// 1. 阅读量:reading_progress 按 section_id 计数,前20名得 20,19,...,1 分
|
||||
var readCounts []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&readCounts)
|
||||
readMap := make(map[string]int64)
|
||||
for _, r := range readCounts {
|
||||
readMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
type idCnt struct {
|
||||
id string
|
||||
cnt int64
|
||||
}
|
||||
readSorted := make([]idCnt, 0, len(all))
|
||||
for _, c := range all {
|
||||
readSorted = append(readSorted, idCnt{c.ID, readMap[c.ID]})
|
||||
}
|
||||
for i := 0; i < len(readSorted)-1; i++ {
|
||||
for j := i + 1; j < len(readSorted); j++ {
|
||||
if readSorted[j].cnt > readSorted[i].cnt {
|
||||
readSorted[i], readSorted[j] = readSorted[j], readSorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
readScore := make(map[string]float64)
|
||||
for i := 0; i < 20 && i < len(readSorted); i++ {
|
||||
readScore[readSorted[i].id] = float64(20 - i)
|
||||
}
|
||||
// 2. 最近30篇:按 updated_at 降序,前30名得 30,29,...,1 分
|
||||
recencySorted := make([]model.Chapter, len(all))
|
||||
copy(recencySorted, all)
|
||||
for i := 0; i < len(recencySorted)-1; i++ {
|
||||
for j := i + 1; j < len(recencySorted); j++ {
|
||||
if recencySorted[j].UpdatedAt.After(recencySorted[i].UpdatedAt) {
|
||||
recencySorted[i], recencySorted[j] = recencySorted[j], recencySorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
recencyScore := make(map[string]float64)
|
||||
for i := 0; i < 30 && i < len(recencySorted); i++ {
|
||||
recencyScore[recencySorted[i].ID] = float64(30 - i)
|
||||
}
|
||||
// 3. 付款数前20:orders 中 product_type='section' 且 status='paid',按 product_id 计数
|
||||
var payCounts []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("orders").Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND status = ? AND product_id IN ?", "section", "paid", ids).
|
||||
Group("product_id").Scan(&payCounts)
|
||||
payMap := make(map[string]int64)
|
||||
for _, r := range payCounts {
|
||||
payMap[r.ProductID] = r.Cnt
|
||||
}
|
||||
paySorted := make([]idCnt, 0, len(payMap))
|
||||
for id, cnt := range payMap {
|
||||
paySorted = append(paySorted, idCnt{id, cnt})
|
||||
}
|
||||
for i := 0; i < len(paySorted)-1; i++ {
|
||||
for j := i + 1; j < len(paySorted); j++ {
|
||||
if paySorted[j].cnt > paySorted[i].cnt {
|
||||
paySorted[i], paySorted[j] = paySorted[j], paySorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
payScore := make(map[string]float64)
|
||||
for i := 0; i < 20 && i < len(paySorted); i++ {
|
||||
payScore[paySorted[i].id] = float64(20 - i)
|
||||
}
|
||||
// 4. 总分 = 0.5*阅读 + 0.3*新度 + 0.2*付款,按总分降序取 limit
|
||||
type withTotal struct {
|
||||
ch model.Chapter
|
||||
total float64
|
||||
}
|
||||
withTotalList := make([]withTotal, 0, len(all))
|
||||
for _, c := range all {
|
||||
t := 0.5*readScore[c.ID] + 0.3*recencyScore[c.ID] + 0.2*payScore[c.ID]
|
||||
withTotalList = append(withTotalList, withTotal{ch: c, total: t})
|
||||
}
|
||||
for i := 0; i < len(withTotalList)-1; i++ {
|
||||
for j := i + 1; j < len(withTotalList); j++ {
|
||||
if withTotalList[j].total > withTotalList[i].total {
|
||||
withTotalList[i], withTotalList[j] = withTotalList[j], withTotalList[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]model.Chapter, 0, limit)
|
||||
for i := 0; i < limit && i < len(withTotalList); i++ {
|
||||
out = append(out, withTotalList[i].ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
|
||||
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50)
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookHot(c *gin.Context) {
|
||||
list := bookHotChaptersSorted(database.DB(), 10)
|
||||
limit := 10
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
// 优先 Redis
|
||||
var cached []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookHot(limit), &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
|
||||
return
|
||||
}
|
||||
list := bookHotChaptersSorted(database.DB(), limit)
|
||||
if len(list) == 0 {
|
||||
// 兜底:按 sort_order 取前 10,同样排除序言/尾声/附录
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
|
||||
sortChaptersByNaturalID(list)
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(文章推荐算法:阅读50%+最近30篇30%+付款20%,排除序言/尾声/附录)
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookRecommended(c *gin.Context) {
|
||||
list := bookRecommendedByScore(database.DB(), 3)
|
||||
if len(list) == 0 {
|
||||
list = bookHotChaptersSorted(database.DB(), 3)
|
||||
var cached []gin.H
|
||||
if cache.Get(context.Background(), cache.KeyBookRecommended, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
|
||||
sections, err := computeArticleRankingSections(database.DB())
|
||||
if err != nil || len(sections) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
|
||||
return
|
||||
}
|
||||
limit := 3
|
||||
if len(sections) < limit {
|
||||
limit = len(sections)
|
||||
}
|
||||
tags := []string{"热门", "推荐", "精选"}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for i, ch := range list {
|
||||
out := make([]gin.H, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
s := sections[i]
|
||||
tag := "精选"
|
||||
if i < len(tags) {
|
||||
tag = tags[i]
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
|
||||
"chapterTitle": ch.ChapterTitle, "tag": tag,
|
||||
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
|
||||
"id": s.ID,
|
||||
"mid": s.MID,
|
||||
"sectionTitle": s.Title,
|
||||
"partTitle": s.PartTitle,
|
||||
"chapterTitle": s.ChapterTitle,
|
||||
"tag": tag,
|
||||
"isFree": s.IsFree,
|
||||
"price": s.Price,
|
||||
"isNew": s.IsNew,
|
||||
})
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters
|
||||
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var list []model.Chapter
|
||||
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
@@ -426,6 +827,7 @@ func BookSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
@@ -442,9 +844,18 @@ func BookSearch(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookStats GET /api/book/stats
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookStats(c *gin.Context) {
|
||||
var cached struct {
|
||||
TotalChapters int64 `json:"totalChapters"`
|
||||
}
|
||||
if cache.Get(context.Background(), cache.KeyBookStats, &cached) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": cached.TotalChapters}})
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,135 +16,29 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
// 存客宝 API Key 约定(详见 开发文档/8、部署/存客宝API-Key约定.md):
|
||||
// - 链接卡若(添加卡若好友):使用 CKB_LEAD_API_KEY(.env 配置),未配则用下方 ckbAPIKey
|
||||
// - 其他场景(join/match 等):使用 ckbAPIKey
|
||||
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||
|
||||
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
|
||||
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
|
||||
|
||||
type CKBRouteConfig struct {
|
||||
APIURL string `json:"apiUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Source string `json:"source"`
|
||||
Tags string `json:"tags"`
|
||||
SiteTags string `json:"siteTags"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type CKBConfigPayload struct {
|
||||
Routes map[string]CKBRouteConfig `json:"routes"`
|
||||
DocNotes string `json:"docNotes"`
|
||||
DocContent string `json:"docContent"`
|
||||
APIURL string `json:"apiUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
}
|
||||
|
||||
func defaultCKBRouteConfig(routeKey string) CKBRouteConfig {
|
||||
cfg := CKBRouteConfig{
|
||||
APIURL: ckbAPIURL,
|
||||
APIKey: ckbAPIKey,
|
||||
SiteTags: "创业实验APP",
|
||||
}
|
||||
switch routeKey {
|
||||
case "join_partner":
|
||||
cfg.Source = "创业实验-创业合伙"
|
||||
cfg.Tags = "创业合伙,创业伙伴"
|
||||
case "join_investor":
|
||||
cfg.Source = "创业实验-资源对接"
|
||||
cfg.Tags = "资源对接,资源群"
|
||||
case "join_mentor":
|
||||
cfg.Source = "创业实验-导师顾问"
|
||||
cfg.Tags = "导师顾问,咨询服务"
|
||||
case "join_team":
|
||||
cfg.Source = "创业实验-团队招募"
|
||||
cfg.Tags = "切片团队,团队招募"
|
||||
case "match":
|
||||
cfg.Source = "创业实验-找伙伴匹配"
|
||||
cfg.Tags = "找伙伴"
|
||||
cfg.SiteTags = "创业实验APP,匹配用户"
|
||||
case "lead":
|
||||
cfg.Source = "小程序-链接卡若"
|
||||
cfg.Tags = "链接卡若,创业实验"
|
||||
cfg.SiteTags = "创业实验APP,链接卡若"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getCKBConfigPayload() CKBConfigPayload {
|
||||
payload := CKBConfigPayload{
|
||||
Routes: map[string]CKBRouteConfig{},
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "ckb_config").First(&cfg).Error; err != nil {
|
||||
return payload
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &m); err != nil {
|
||||
return payload
|
||||
}
|
||||
if v, ok := m["docNotes"].(string); ok {
|
||||
payload.DocNotes = v
|
||||
}
|
||||
if v, ok := m["docContent"].(string); ok {
|
||||
payload.DocContent = v
|
||||
}
|
||||
if v, ok := m["apiKey"].(string); ok {
|
||||
payload.APIKey = v
|
||||
}
|
||||
if v, ok := m["apiUrl"].(string); ok {
|
||||
payload.APIURL = v
|
||||
}
|
||||
if routes, ok := m["routes"].(map[string]interface{}); ok {
|
||||
for key, raw := range routes {
|
||||
itemMap, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := defaultCKBRouteConfig(key)
|
||||
if v, ok := itemMap["apiUrl"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.APIURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["apiKey"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.APIKey = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["source"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.Source = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["tags"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.Tags = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["siteTags"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.SiteTags = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["notes"].(string); ok {
|
||||
item.Notes = v
|
||||
}
|
||||
payload.Routes[key] = item
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func getCKBRouteConfig(routeKey string) (cfg CKBRouteConfig, docNotes string, docContent string) {
|
||||
cfg = defaultCKBRouteConfig(routeKey)
|
||||
payload := getCKBConfigPayload()
|
||||
docNotes = payload.DocNotes
|
||||
docContent = payload.DocContent
|
||||
if item, ok := payload.Routes[routeKey]; ok {
|
||||
cfg = item
|
||||
} else {
|
||||
if strings.TrimSpace(payload.APIURL) != "" {
|
||||
cfg.APIURL = strings.TrimSpace(payload.APIURL)
|
||||
}
|
||||
if strings.TrimSpace(payload.APIKey) != "" {
|
||||
cfg.APIKey = strings.TrimSpace(payload.APIKey)
|
||||
}
|
||||
}
|
||||
return cfg, docNotes, docContent
|
||||
// ckbSubmitSave 加好友/留资类接口统一落库:记录 action、userId、昵称、用户提交的传参,写入 ckb_submit_records
|
||||
func ckbSubmitSave(action, userID, nickname string, params interface{}) {
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
_ = database.DB().Create(&model.CkbSubmitRecord{
|
||||
Action: action,
|
||||
UserID: userID,
|
||||
Nickname: nickname,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey)
|
||||
@@ -182,6 +77,23 @@ func ckbSign(params map[string]interface{}, apiKey string) string {
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
// getCkbLeadApiKey 链接卡若密钥优先级:system_config.site_settings.ckbLeadApiKey > .env CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
|
||||
func getCkbLeadApiKey() string {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "site_settings").First(&row).Error; err == nil && len(row.ConfigValue) > 0 {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err == nil {
|
||||
if v, ok := m["ckbLeadApiKey"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
|
||||
return cfg.CkbLeadAPIKey
|
||||
}
|
||||
return ckbAPIKey
|
||||
}
|
||||
|
||||
// CKBJoin POST /api/ckb/join
|
||||
func CKBJoin(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -206,31 +118,26 @@ func CKBJoin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
|
||||
return
|
||||
}
|
||||
routeCfg, _, _ := getCKBRouteConfig("join_" + body.Type)
|
||||
// 先写入 match_records(无论 CKB 是否成功,用户确实提交了表单)
|
||||
if body.UserID != "" {
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_ckb_%d", time.Now().UnixNano()),
|
||||
UserID: body.UserID,
|
||||
MatchType: body.Type,
|
||||
}
|
||||
if body.Phone != "" {
|
||||
rec.Phone = &body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
rec.WechatID = &body.Wechat
|
||||
}
|
||||
if err := database.DB().Create(&rec).Error; err != nil {
|
||||
fmt.Printf("[CKBJoin] 写入 match_records 失败: %v\n", err)
|
||||
nickname := strings.TrimSpace(body.Name)
|
||||
if nickname == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
}
|
||||
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("join", body.UserID, nickname, map[string]interface{}{
|
||||
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
|
||||
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
|
||||
})
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": routeCfg.Tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"source": "创业实验-" + ckbSourceMap[body.Type],
|
||||
"tags": ckbTagsMap[body.Type],
|
||||
"siteTags": "创业实验APP",
|
||||
"remark": body.Remark,
|
||||
}
|
||||
if body.Remark == "" {
|
||||
@@ -249,8 +156,8 @@ func CKBJoin(c *gin.Context) {
|
||||
if body.Name != "" {
|
||||
params["name"] = body.Name
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
sourceData := map[string]interface{}{
|
||||
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
|
||||
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
@@ -270,10 +177,9 @@ func CKBJoin(c *gin.Context) {
|
||||
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBJoin] CKB 请求失败: %v (match_records 已写入)\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已提交(存客宝暂不可达,稍后自动重试)"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -323,7 +229,6 @@ func CKBJoin(c *gin.Context) {
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
func CKBMatch(c *gin.Context) {
|
||||
routeCfg, _, _ := getCKBRouteConfig("match")
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -337,21 +242,25 @@ func CKBMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
nickname := strings.TrimSpace(body.Nickname)
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("match", body.UserID, nickname, map[string]interface{}{
|
||||
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
|
||||
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
|
||||
})
|
||||
ts := time.Now().Unix()
|
||||
label := ckbSourceMap[body.MatchType]
|
||||
if label == "" {
|
||||
label = "创业合伙"
|
||||
}
|
||||
tags := routeCfg.Tags
|
||||
if label != "" && tags != "" && !strings.Contains(tags, label) {
|
||||
tags = tags + "," + label
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
"source": "创业实验-找伙伴匹配",
|
||||
"tags": "找伙伴," + label,
|
||||
"siteTags": "创业实验APP,匹配用户",
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
@@ -362,8 +271,8 @@ func CKBMatch(c *gin.Context) {
|
||||
if body.Nickname != "" {
|
||||
params["name"] = body.Nickname
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": map[string]interface{}{
|
||||
@@ -374,7 +283,7 @@ func CKBMatch(c *gin.Context) {
|
||||
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
return
|
||||
@@ -398,11 +307,10 @@ func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友
|
||||
// 请求体:phone(可选)、wechatId(可选)、name(可选)、userId(可选,用于补全昵称)
|
||||
// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md
|
||||
func CKBLead(c *gin.Context) {
|
||||
routeCfg, _, _ := getCKBRouteConfig("lead")
|
||||
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
|
||||
// - 固定使用全局 CKB_LEAD_API_KEY,不受文章 @ 人物的 ckb_api_key 影响
|
||||
// - 请求体:userId(可选,用于补全昵称)、phone/wechatId(至少一个)、name(可选)
|
||||
func CKBIndexLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -412,28 +320,192 @@ func CKBLead(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
wechatId := strings.TrimSpace(body.WechatID)
|
||||
if phone == "" && wechatId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
|
||||
if phone == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
db := database.DB()
|
||||
if name == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "小程序用户"
|
||||
}
|
||||
|
||||
// 首页固定使用全局密钥:system_config > .env > 代码内置
|
||||
leadKey := getCkbLeadApiKey()
|
||||
|
||||
// 去重:同一用户只记录一次(首页链接卡若)
|
||||
repeatedSubmit := false
|
||||
if body.UserID != "" {
|
||||
var existCount int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND source = ?", body.UserID, "index_link_button").Count(&existCount)
|
||||
repeatedSubmit = existCount > 0
|
||||
}
|
||||
|
||||
source := "index_link_button"
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": routeCfg.Tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"remark": "首页点击「链接卡若」留资",
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
"apiKey": leadKey,
|
||||
}
|
||||
params["phone"] = phone
|
||||
params["sign"] = ckbSign(params, leadKey)
|
||||
q := url.Values{}
|
||||
q.Set("name", name)
|
||||
q.Set("timestamp", strconv.FormatInt(ts, 10))
|
||||
q.Set("apiKey", leadKey)
|
||||
q.Set("phone", phone)
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
fmt.Printf("[CKBIndexLead] 请求存客宝完整链接: %s\n", reqURL)
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if repeatedSubmit {
|
||||
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
if m, ok := result.Data.(map[string]interface{}); ok {
|
||||
data = m
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
// 存客宝返回失败,透传其错误信息与 code,便于前端/运营判断原因
|
||||
errMsg := strings.TrimSpace(result.Message)
|
||||
if errMsg == "" {
|
||||
errMsg = "提交失败,请稍后重试"
|
||||
}
|
||||
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errMsg,
|
||||
"ckbCode": result.Code,
|
||||
"ckbMessage": result.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention)
|
||||
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(被@的 personId)、targetNickname、source
|
||||
func CKBLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention 场景)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
Source string `json:"source"` // index_lead / article_mention
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
wechatId := strings.TrimSpace(body.WechatID)
|
||||
if phone == "" && wechatId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
db := database.DB()
|
||||
if name == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "小程序用户"
|
||||
}
|
||||
|
||||
// 存客宝 scenarios 内部 API 需要计划级 apiKey(persons.ckb_api_key),不是 token
|
||||
// 文章 @ 场景:targetUserId=token → 查 Person 取 CkbApiKey 作为 leadKey
|
||||
// 首页链接卡若:targetUserId 为空 → 用全局 getCkbLeadApiKey()
|
||||
leadKey := getCkbLeadApiKey()
|
||||
targetName := strings.TrimSpace(body.TargetNickname)
|
||||
if body.TargetUserID != "" {
|
||||
var p model.Person
|
||||
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.CkbApiKey) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
|
||||
return
|
||||
}
|
||||
leadKey = p.CkbApiKey
|
||||
if targetName == "" {
|
||||
targetName = p.Name
|
||||
}
|
||||
}
|
||||
|
||||
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
|
||||
repeatedSubmit := false
|
||||
if body.UserID != "" && body.TargetUserID != "" {
|
||||
var existCount int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND target_person_id = ?", body.UserID, body.TargetUserID).Count(&existCount)
|
||||
repeatedSubmit = existCount > 0
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(body.Source)
|
||||
if source == "" {
|
||||
source = "index_lead"
|
||||
}
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"targetUserId": body.TargetUserID, "source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
TargetPersonID: body.TargetUserID,
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
"apiKey": leadKey,
|
||||
}
|
||||
if phone != "" {
|
||||
params["phone"] = phone
|
||||
@@ -441,157 +513,72 @@ func CKBLead(c *gin.Context) {
|
||||
if wechatId != "" {
|
||||
params["wechatId"] = wechatId
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
raw, _ := json.Marshal(params)
|
||||
fmt.Printf("[CKBLead] 请求: phone=%s wechatId=%s name=%s\n", phone, wechatId, name)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
params["sign"] = ckbSign(params, leadKey)
|
||||
q := url.Values{}
|
||||
q.Set("name", name)
|
||||
q.Set("timestamp", strconv.FormatInt(ts, 10))
|
||||
q.Set("apiKey", leadKey)
|
||||
if phone != "" {
|
||||
q.Set("phone", phone)
|
||||
}
|
||||
if wechatId != "" {
|
||||
q.Set("wechatId", wechatId)
|
||||
}
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
fmt.Printf("[CKBLead] 请求存客宝完整链接: %s\n", reqURL)
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
fmt.Printf("[CKBLead] 响应: code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已留资,我们会尽快联系您"
|
||||
// 成功文案:有被@的人则用 TA 的名字,否则用"对方"
|
||||
who := targetName
|
||||
if who == "" {
|
||||
who = "对方"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
|
||||
msg := fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
||||
if repeatedSubmit {
|
||||
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s,请耐心等待添加", who)
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
if m, ok := result.Data.(map[string]interface{}); ok {
|
||||
data = m
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
errMsg := result.Message
|
||||
ckbMsg := strings.TrimSpace(result.Message)
|
||||
if ckbMsg == "" {
|
||||
ckbMsg = strings.TrimSpace(result.Msg)
|
||||
}
|
||||
errMsg := ckbMsg
|
||||
if errMsg == "" {
|
||||
errMsg = "提交失败,请稍后重试"
|
||||
}
|
||||
fmt.Printf("[CKBLead] 失败: phone=%s code=%d message=%s\n", phone, result.Code, result.Message)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
|
||||
}
|
||||
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 代理存客宝获客计划统计
|
||||
func CKBPlanStats(c *gin.Context) {
|
||||
routeCfg, docNotes, docContent := getCKBRouteConfig("lead")
|
||||
ts := time.Now().Unix()
|
||||
// 用 scenarios 接口查询方式不可行,存客宝 plan-stats 需要 JWT
|
||||
// 这里用本地 match_records + CKB 签名信息返回聚合统计
|
||||
db := database.DB()
|
||||
// 各类型提交数量(通过 CKBJoin 写入的 mr_ckb_ 开头的记录)
|
||||
type TypeStat struct {
|
||||
MatchType string `gorm:"column:match_type" json:"matchType"`
|
||||
Total int64 `gorm:"column:total" json:"total"`
|
||||
}
|
||||
var ckbStats []TypeStat
|
||||
db.Raw("SELECT match_type, COUNT(*) as total FROM match_records WHERE id LIKE 'mr_ckb_%' GROUP BY match_type").Scan(&ckbStats)
|
||||
var ckbTotal int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%'").Scan(&ckbTotal)
|
||||
// 各类型有联系方式的数量
|
||||
var withContact int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%' AND ((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))").Scan(&withContact)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"ckbTotal": ckbTotal,
|
||||
"withContact": withContact,
|
||||
"byType": ckbStats,
|
||||
"ckbApiKey": routeCfg.APIKey[:minInt(len(routeCfg.APIKey), 8)] + "...",
|
||||
"ckbApiUrl": routeCfg.APIURL,
|
||||
"lastSignTest": ts,
|
||||
"docNotes": docNotes,
|
||||
"docContent": docContent,
|
||||
"routes": getCKBConfigPayload().Routes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
func DBCKBLeadList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
mode := strings.TrimSpace(c.DefaultQuery("mode", "submitted")) // submitted|contact
|
||||
matchType := strings.TrimSpace(c.Query("matchType"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := db.Model(&model.MatchRecord{}).Where("id LIKE 'mr_ckb_%'")
|
||||
if matchType != "" {
|
||||
q = q.Where("match_type = ?", matchType)
|
||||
}
|
||||
if mode == "contact" {
|
||||
q = q.Where("((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))")
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var records []model.MatchRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
if r.UserID != "" {
|
||||
userIDs[r.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
safeNickname := func(u *model.User) string {
|
||||
if u == nil || u.Nickname == nil {
|
||||
return ""
|
||||
}
|
||||
return *u.Nickname
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"userNickname": safeNickname(userMap[r.UserID]),
|
||||
"matchType": r.MatchType,
|
||||
"phone": func() string { if r.Phone == nil { return "" }; return *r.Phone }(),
|
||||
"wechatId": func() string { if r.WechatID == nil { return "" }; return *r.WechatID }(),
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"records": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s\n", result.Code, ckbMsg, string(b))
|
||||
respObj := gin.H{
|
||||
"success": false,
|
||||
"message": errMsg,
|
||||
"ckbCode": result.Code,
|
||||
"ckbMessage": ckbMsg,
|
||||
}
|
||||
if ckbMsg == "" && len(b) > 0 {
|
||||
respObj["ckbRaw"] = string(b) // 存客保未返回 message/msg 时透传原始响应,供调试
|
||||
}
|
||||
c.JSON(http.StatusOK, respObj)
|
||||
}
|
||||
|
||||
460
soul-api/internal/handler/ckb_open.go
Normal file
460
soul-api/internal/handler/ckb_open.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"soul-api/internal/config"
|
||||
)
|
||||
|
||||
const ckbOpenBaseURL = "https://ckbapi.quwanzhi.com"
|
||||
|
||||
// ckbOpenSign 按 open-api-sign.md:sign = MD5(MD5(account+timestamp) + apiKey)
|
||||
func ckbOpenSign(account string, ts int64, apiKey string) string {
|
||||
plain := account + strconv.FormatInt(ts, 10)
|
||||
h := md5.Sum([]byte(plain))
|
||||
first := hex.EncodeToString(h[:])
|
||||
h2 := md5.Sum([]byte(first + apiKey))
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
func getCkbOpenConfig() (apiKey, account string) {
|
||||
cfg := config.Get()
|
||||
if cfg != nil {
|
||||
apiKey = cfg.CkbOpenAPIKey
|
||||
account = cfg.CkbOpenAccount
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ckbOpenGetToken 获取开放 API JWT
|
||||
func ckbOpenGetToken() (string, error) {
|
||||
apiKey, account := getCkbOpenConfig()
|
||||
if apiKey == "" || account == "" {
|
||||
return "", fmt.Errorf("CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试")
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
sign := ckbOpenSign(account, ts, apiKey)
|
||||
authBody := map[string]interface{}{
|
||||
"apiKey": apiKey,
|
||||
"account": account,
|
||||
"timestamp": ts,
|
||||
"sign": sign,
|
||||
}
|
||||
raw, _ := json.Marshal(authBody)
|
||||
authResp, err := http.Post(ckbOpenBaseURL+"/v1/open/auth/token", "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求存客宝鉴权失败: %w", err)
|
||||
}
|
||||
defer authResp.Body.Close()
|
||||
authBytes, _ := io.ReadAll(authResp.Body)
|
||||
var authResult struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(authBytes, &authResult)
|
||||
if authResult.Code != 200 || authResult.Data.Token == "" {
|
||||
msg := authResult.Message
|
||||
if msg == "" {
|
||||
msg = "存客宝鉴权失败"
|
||||
}
|
||||
return "", fmt.Errorf(msg)
|
||||
}
|
||||
return authResult.Data.Token, nil
|
||||
}
|
||||
|
||||
// ckbOpenCreatePlan 调用 /v1/plan/create 创建获客计划,返回 planId、存客宝原始 data、以及完整响应(失败时便于排查)
|
||||
func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int64, createData map[string]interface{}, ckbResponse map[string]interface{}, err error) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, ckbOpenBaseURL+"/v1/plan/create", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("构造创建计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("请求存客宝创建计划失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
// 始终组装完整响应,便于失败时返回给调用方查看存客宝实际返回
|
||||
ckbResponse = map[string]interface{}{
|
||||
"code": result.Code,
|
||||
"message": result.Message,
|
||||
"data": nil,
|
||||
}
|
||||
if len(result.Data) > 0 {
|
||||
var dataObj interface{}
|
||||
_ = json.Unmarshal(result.Data, &dataObj)
|
||||
ckbResponse["data"] = dataObj
|
||||
}
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "创建计划失败"
|
||||
}
|
||||
return 0, nil, ckbResponse, fmt.Errorf(result.Message)
|
||||
}
|
||||
// 原始 data 转为 map 供响应展示
|
||||
createData = make(map[string]interface{})
|
||||
_ = json.Unmarshal(result.Data, &createData)
|
||||
// 存客宝可能返回 planId 为数字或字符串(如 "629"),兼容解析
|
||||
planID = parsePlanIDFromData(createData)
|
||||
if planID != 0 {
|
||||
return planID, createData, ckbResponse, nil
|
||||
}
|
||||
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
|
||||
}
|
||||
|
||||
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey(若存客宝直接返回则复用,避免二次请求)
|
||||
func parseApiKeyFromCreateData(data map[string]interface{}) string {
|
||||
for _, key := range []string{"apiKey", "api_key"} {
|
||||
v, ok := data[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parsePlanIDFromData 从 data 中解析 planId,支持 number 或 string;若无则尝试 id
|
||||
func parsePlanIDFromData(data map[string]interface{}) int64 {
|
||||
for _, key := range []string{"planId", "id"} {
|
||||
v, ok := data[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int64:
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
case string:
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ckbOpenGetPlanDetail 调用 /v1/plan/detail?planId=,返回计划级 apiKey
|
||||
func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
|
||||
u := fmt.Sprintf("%s/v1/plan/detail?planId=%d", ckbOpenBaseURL, planID)
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("构造计划详情请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求存客宝计划详情失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "获取计划详情失败"
|
||||
}
|
||||
return "", fmt.Errorf(result.Message)
|
||||
}
|
||||
if result.Data.APIKey == "" {
|
||||
return "", fmt.Errorf("计划详情中缺少 apiKey")
|
||||
}
|
||||
return result.Data.APIKey, nil
|
||||
}
|
||||
|
||||
// ckbOpenGetDefaultDeviceID 获取默认设备 ID:拉设备列表,取第一个 memo 或 nickname 包含 "soul" 的设备;用于 deviceGroups 必填时的默认值
|
||||
func ckbOpenGetDefaultDeviceID(token string) (int64, error) {
|
||||
u := ckbOpenBaseURL + "/v1/devices?keyword=soul&page=1&limit=50"
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("构造设备列表请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("请求存客宝设备列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
return 0, fmt.Errorf("解析设备列表失败: %w", err)
|
||||
}
|
||||
var listAny interface{}
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
listAny = dataVal["list"]
|
||||
} else if la, ok := parsed["list"]; ok {
|
||||
listAny = la
|
||||
}
|
||||
arr, ok := listAny.([]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("设备列表格式异常")
|
||||
}
|
||||
// 优先匹配 memo/nickname 包含 soul 的设备;若无则取第一个(keyword 可能已过滤)
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memo := toString(m["memo"])
|
||||
if memo == "" {
|
||||
memo = toString(m["imei"])
|
||||
}
|
||||
nickname := toString(m["nickname"])
|
||||
lowerMemo := strings.ToLower(memo)
|
||||
lowerNick := strings.ToLower(nickname)
|
||||
if strings.Contains(lowerMemo, "soul") || strings.Contains(lowerNick, "soul") {
|
||||
id := parseDeviceID(m["id"])
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未找到含 soul 的,取第一个
|
||||
if len(arr) > 0 {
|
||||
if m, ok := arr[0].(map[string]interface{}); ok {
|
||||
id := parseDeviceID(m["id"])
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("未找到名为 soul 的设备,请先在存客宝添加设备并设置 memo 或 nickname 包含 soul")
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeviceID(v interface{}) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int64:
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
case string:
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ckbOpenDeletePlan 调用 DELETE /v1/plan/delete 删除存客宝获客计划
|
||||
func ckbOpenDeletePlan(token string, planID int64) error {
|
||||
payload := map[string]interface{}{"planId": planID}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodDelete, ckbOpenBaseURL+"/v1/plan/delete", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造删除计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求存客宝删除计划失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "删除计划失败"
|
||||
}
|
||||
return fmt.Errorf(result.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCKBDevices GET /api/admin/ckb/devices 管理端-存客宝设备列表(供链接人与事选择设备)
|
||||
// 通过开放 API 获取 JWT,再调用 /v1/devices,返回精简后的设备列表。
|
||||
func AdminCKBDevices(c *gin.Context) {
|
||||
token, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用 /v1/devices 获取设备列表
|
||||
pageStr := c.Query("page")
|
||||
if pageStr == "" {
|
||||
pageStr = "1"
|
||||
}
|
||||
limitStr := c.Query("limit")
|
||||
if limitStr == "" {
|
||||
limitStr = "20"
|
||||
}
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("page", pageStr)
|
||||
values.Set("limit", limitStr)
|
||||
if keyword != "" {
|
||||
values.Set("keyword", keyword)
|
||||
}
|
||||
deviceURL := ckbOpenBaseURL + "/v1/devices"
|
||||
if len(values) > 0 {
|
||||
deviceURL += "?" + values.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, deviceURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造设备列表请求失败"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求存客宝设备列表失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 设备返回结构:参考 Cunkebao getDeviceList 使用方式,形如 { code, msg, data: { list, total } } 或 { code, msg, list, total }
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析存客宝设备列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试从 data.list 或 list 提取设备列表与 total
|
||||
var listAny interface{}
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
listAny = dataVal["list"]
|
||||
if _, ok := parsed["total"]; !ok {
|
||||
if tv, ok := dataVal["total"]; ok {
|
||||
parsed["total"] = tv
|
||||
}
|
||||
}
|
||||
} else if la, ok := parsed["list"]; ok {
|
||||
listAny = la
|
||||
}
|
||||
|
||||
devices := make([]map[string]interface{}, 0)
|
||||
if arr, ok := listAny.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
id := m["id"]
|
||||
memo := m["memo"]
|
||||
if memo == nil || memo == "" {
|
||||
memo = m["imei"]
|
||||
}
|
||||
wechatID := m["wechatId"]
|
||||
status := "offline"
|
||||
if alive, ok := m["alive"].(float64); ok && int(alive) == 1 {
|
||||
status = "online"
|
||||
}
|
||||
devices = append(devices, map[string]interface{}{
|
||||
"id": id,
|
||||
"memo": memo,
|
||||
"imei": m["imei"],
|
||||
"wechatId": wechatID,
|
||||
"status": status,
|
||||
"avatar": m["avatar"],
|
||||
"nickname": m["nickname"],
|
||||
"usedInPlan": m["usedInPlans"],
|
||||
"totalFriend": m["totalFriend"],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
switch tv := parsed["total"].(type) {
|
||||
case float64:
|
||||
total = int(tv)
|
||||
case int:
|
||||
total = tv
|
||||
case string:
|
||||
if n, err := strconv.Atoi(tv); err == nil {
|
||||
total = n
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"devices": devices,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -69,10 +70,53 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
|
||||
}
|
||||
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
|
||||
if qerr != nil {
|
||||
// 微信返回「订单不存在」:说明该 out_trade_no 在微信侧已无效,直接将本地订单标记为关闭
|
||||
if strings.Contains(qerr.Error(), "ORDER_NOT_EXIST") {
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("微信提示订单不存在,标记订单 %s 为关闭失败: %v", o.OrderSN, err)
|
||||
} else {
|
||||
syncOrdersLogf("微信提示订单不存在,已将本地订单标记为关闭: %s", o.OrderSN)
|
||||
}
|
||||
continue
|
||||
}
|
||||
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
|
||||
continue
|
||||
}
|
||||
// 根据微信支付状态决定本地订单后续处理:
|
||||
// - SUCCESS:补齐漏单,发放权益
|
||||
// - NOTPAY/USERPAYING:在有效期内保持 created,超过一定时间自动标记为关闭
|
||||
// - 其他终态(CLOSED、REVOKED、PAYERROR 等):标记为关闭,避免无限轮询
|
||||
if tradeState != "SUCCESS" {
|
||||
// 对仍未支付的订单设置超时关闭(避免长时间轮询)
|
||||
if tradeState == "NOTPAY" || tradeState == "USERPAYING" {
|
||||
// 超过 30 分钟仍未支付,视为关闭
|
||||
if time.Since(o.CreatedAt) > 30*time.Minute {
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("标记超时未支付订单 %s 为关闭失败: %v", o.OrderSN, err)
|
||||
} else {
|
||||
syncOrdersLogf("订单超时未支付,标记为关闭: %s", o.OrderSN)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 其他非 SUCCESS 状态(如 CLOSED、REVOKED、PAYERROR 等),直接在本地标记为关闭
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("标记订单 %s 为关闭状态失败(trade_state=%s): %v", o.OrderSN, tradeState, err)
|
||||
} else {
|
||||
syncOrdersLogf("订单在微信已为终态 %s,本地标记为关闭: %s", tradeState, o.OrderSN)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 微信已支付,本地未更新 → 补齐
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -16,10 +18,15 @@ import (
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// 从 system_config 读取 chapter_config、feature_config、mp_config,合并后返回(免费以章节 is_free/price 为准)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||||
apiDomain = cfg.BaseURL
|
||||
@@ -101,6 +108,33 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要 type;miniprogram 类型存 mpKey,用 key 查 linkedMiniprograms 得 appId)
|
||||
var linkTagRows []model.LinkTag
|
||||
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
|
||||
tags := make([]gin.H, 0, len(linkTagRows))
|
||||
for _, t := range linkTagRows {
|
||||
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
|
||||
if t.Type == "miniprogram" {
|
||||
h["mpKey"] = t.AppID // miniprogram 类型时 AppID 列存的是密钥
|
||||
} else {
|
||||
h["appId"] = t.AppID
|
||||
}
|
||||
tags = append(tags, h)
|
||||
}
|
||||
out["linkTags"] = tags
|
||||
}
|
||||
// 关联小程序列表(key 为 32 位密钥,小程序用 key 查 appId 后 wx.navigateToMiniProgram)
|
||||
var linkedMpRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "linked_miniprograms").First(&linkedMpRow).Error; err == nil && len(linkedMpRow.ConfigValue) > 0 {
|
||||
var linkedList []gin.H
|
||||
if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err == nil && len(linkedList) > 0 {
|
||||
out["linkedMiniprograms"] = linkedList
|
||||
}
|
||||
}
|
||||
if _, has := out["linkedMiniprograms"]; !has {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -148,11 +182,12 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
"mpConfig": defaultMp,
|
||||
"ossConfig": gin.H{},
|
||||
}
|
||||
keys := []string{"feature_config", "site_settings", "mp_config"}
|
||||
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
@@ -182,6 +217,10 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
out["mpConfig"] = merged
|
||||
}
|
||||
case "oss_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["ossConfig"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
@@ -193,6 +232,7 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
||||
SiteSettings map[string]interface{} `json:"siteSettings"`
|
||||
MpConfig map[string]interface{} `json:"mpConfig"`
|
||||
OssConfig map[string]interface{} `json:"ossConfig"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -234,6 +274,13 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.OssConfig != nil {
|
||||
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
|
||||
}
|
||||
|
||||
@@ -317,6 +364,7 @@ func AdminReferralSettingsPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
||||
}
|
||||
|
||||
@@ -488,6 +536,7 @@ func DBConfigPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
|
||||
}
|
||||
|
||||
@@ -514,14 +563,12 @@ func DBUsersList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
|
||||
return
|
||||
}
|
||||
// 填充 hasFullBook(含 is_vip 或 orders)
|
||||
// 填充 hasFullBook(含 orders、is_vip、手动设置的 has_full_book)
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "completed", "fullbook", "vip").Count(&cnt)
|
||||
user.HasFullBook = ptrBool(cnt > 0)
|
||||
if user.IsVip != nil && *user.IsVip {
|
||||
user.HasFullBook = ptrBool(true)
|
||||
}
|
||||
hasFull := cnt > 0 || (user.IsVip != nil && *user.IsVip) || (user.HasFullBook != nil && *user.HasFullBook)
|
||||
user.HasFullBook = ptrBool(hasFull)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": user})
|
||||
return
|
||||
}
|
||||
@@ -644,11 +691,14 @@ func DBUsersList(c *gin.Context) {
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
// 购买状态(含手动设置的 VIP:is_vip=1 且 vip_expire_date>NOW)
|
||||
// 购买状态(含订单、is_vip、手动设置的 has_full_book)
|
||||
hasFull := hasFullBookMap[uid]
|
||||
if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) {
|
||||
hasFull = true
|
||||
}
|
||||
if users[i].HasFullBook != nil && *users[i].HasFullBook {
|
||||
hasFull = true
|
||||
}
|
||||
users[i].HasFullBook = ptrBool(hasFull)
|
||||
users[i].PurchasedSectionCount = sectionCountMap[uid]
|
||||
// 分销收益
|
||||
@@ -712,13 +762,14 @@ func DBUsersAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
|
||||
return
|
||||
}
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio)
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio;tags 存 ckb_tags)
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
@@ -763,6 +814,9 @@ func DBUsersAction(c *gin.Context) {
|
||||
if body.Avatar != nil {
|
||||
updates["avatar"] = *body.Avatar
|
||||
}
|
||||
if body.Tags != nil {
|
||||
updates["ckb_tags"] = *body.Tags
|
||||
}
|
||||
if body.HasFullBook != nil {
|
||||
updates["has_full_book"] = *body.HasFullBook
|
||||
}
|
||||
@@ -855,7 +909,26 @@ func DBUsersDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
db := database.DB()
|
||||
cleanupTables := []struct{ table, col string }{
|
||||
{"match_records", "user_id"},
|
||||
{"reading_progress", "user_id"},
|
||||
{"user_tracks", "user_id"},
|
||||
{"referral_bindings", "referrer_id"},
|
||||
{"referral_bindings", "referee_id"},
|
||||
{"referral_visits", "visitor_id"},
|
||||
{"ckb_submit_records", "user_id"},
|
||||
{"ckb_lead_records", "user_id"},
|
||||
{"user_addresses", "user_id"},
|
||||
{"user_balances", "user_id"},
|
||||
{"balance_transactions", "user_id"},
|
||||
{"withdrawals", "user_id"},
|
||||
{"orders", "user_id"},
|
||||
}
|
||||
for _, t := range cleanupTables {
|
||||
db.Exec("DELETE FROM "+t.table+" WHERE "+t.col+" = ?", id)
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -13,29 +17,273 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速;含 updated_at 用于热度算法
|
||||
var listSelectCols = []string{
|
||||
"id", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order", "updated_at",
|
||||
"hot_score_override",
|
||||
// naturalLessSectionID 对章节 id(如 9.1、9.2、9.10)做自然排序,避免 9.1 < 9.10 < 9.2 的字典序问题
|
||||
func naturalLessSectionID(a, b string) bool {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
for i := 0; i < len(partsA) && i < len(partsB); i++ {
|
||||
na, errA := strconv.Atoi(partsA[i])
|
||||
nb, errB := strconv.Atoi(partsB[i])
|
||||
if errA != nil || errB != nil {
|
||||
if partsA[i] != partsB[i] {
|
||||
return partsA[i] < partsB[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if na != nb {
|
||||
return na < nb
|
||||
}
|
||||
}
|
||||
return len(partsA) < len(partsB)
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击、付款与热度排名
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||
var listSelectCols = []string{
|
||||
"id", "mid", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
"hot_score", "updated_at",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
IsNew *bool `json:"isNew,omitempty"`
|
||||
IsNew *bool `json:"isNew,omitempty"` // stitch_soul:标记最新新增
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
ClickCount int `json:"clickCount,omitempty"` // 阅读/点击次数(reading_progress 条数)
|
||||
PayCount int `json:"payCount,omitempty"` // 付款笔数(orders 已支付)
|
||||
HotScore float64 `json:"hotScore,omitempty"` // 热度积分(文章排名算法算出)
|
||||
HotRank int `json:"hotRank,omitempty"` // 热度排名(1=最高)
|
||||
ClickCount int64 `json:"clickCount"` // 阅读次数(reading_progress)
|
||||
PayCount int64 `json:"payCount"` // 付款笔数(orders.product_type=section)
|
||||
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
|
||||
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
|
||||
}
|
||||
|
||||
// computeSectionListWithHotScore 计算章节列表(含 hotScore),保持 sort_order 顺序,供 章节管理 树使用
|
||||
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
|
||||
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
|
||||
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 读取置顶配置 pinned_section_ids
|
||||
pinnedIDs := []string{}
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
pinnedSet := make(map[string]int) // id -> 置顶顺序
|
||||
for i, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = i
|
||||
}
|
||||
}
|
||||
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
|
||||
sort.Slice(sections, func(i, j int) bool {
|
||||
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
|
||||
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
|
||||
if piOk && !pjOk {
|
||||
return true
|
||||
}
|
||||
if !piOk && pjOk {
|
||||
return false
|
||||
}
|
||||
if piOk && pjOk {
|
||||
return pi < pj
|
||||
}
|
||||
return sections[i].HotScore > sections[j].HotScore
|
||||
})
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeSectionsWithHotScore 内部:按排名分算法计算 hotScore
|
||||
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
|
||||
// 阅读量前20名: 第1名=20分...第20名=1分;最近更新前30篇: 第1名=30分...第30名=1分;付款数前20名: 第1名=20分...第20名=1分
|
||||
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 同 sort_order 时按 id 自然排序(9.1 < 9.2 < 9.10),避免字典序 9.1 < 9.10 < 9.2
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
if rows[i].SortOrder != nil {
|
||||
soI = *rows[i].SortOrder
|
||||
}
|
||||
if rows[j].SortOrder != nil {
|
||||
soJ = *rows[j].SortOrder
|
||||
}
|
||||
if soI != soJ {
|
||||
return soI < soJ
|
||||
}
|
||||
return naturalLessSectionID(rows[i].ID, rows[j].ID)
|
||||
})
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
readCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var rp []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&rp)
|
||||
for _, r := range rp {
|
||||
readCountMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
}
|
||||
payCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var op []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
|
||||
Group("product_id").Scan(&op)
|
||||
for _, r := range op {
|
||||
payCountMap[r.ProductID] = r.Cnt
|
||||
}
|
||||
}
|
||||
readWeight, payWeight, recencyWeight := 0.1, 0.4, 0.5 // 默认与截图一致
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
var v struct {
|
||||
ReadWeight float64 `json:"readWeight"`
|
||||
RecencyWeight float64 `json:"recencyWeight"`
|
||||
PayWeight float64 `json:"payWeight"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
|
||||
if v.ReadWeight >= 0 {
|
||||
readWeight = v.ReadWeight
|
||||
}
|
||||
if v.PayWeight >= 0 {
|
||||
payWeight = v.PayWeight
|
||||
}
|
||||
if v.RecencyWeight >= 0 {
|
||||
recencyWeight = v.RecencyWeight
|
||||
}
|
||||
}
|
||||
}
|
||||
weightSum := readWeight + payWeight + recencyWeight
|
||||
if weightSum > 3 {
|
||||
readWeight = readWeight / weightSum
|
||||
payWeight = payWeight / weightSum
|
||||
recencyWeight = recencyWeight / weightSum
|
||||
}
|
||||
pinnedIDs := []string{}
|
||||
if setPinned {
|
||||
var cfg2 model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
}
|
||||
pinnedSet := make(map[string]bool)
|
||||
for _, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 阅读量排名:按 readCount 降序,前20名得 20~1 分
|
||||
type idCnt struct {
|
||||
id string
|
||||
cnt int64
|
||||
}
|
||||
readRank := make([]idCnt, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
readRank = append(readRank, idCnt{r.ID, readCountMap[r.ID]})
|
||||
}
|
||||
sort.Slice(readRank, func(i, j int) bool { return readRank[i].cnt > readRank[j].cnt })
|
||||
readRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(readRank) && i < 20; i++ {
|
||||
readRankScoreMap[readRank[i].id] = float64(20 - i)
|
||||
}
|
||||
|
||||
// 2. 新度排名:按 updated_at 降序(最近更新在前),前30篇得 30~1 分
|
||||
recencyRank := make([]struct {
|
||||
id string
|
||||
updatedAt time.Time
|
||||
}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
recencyRank = append(recencyRank, struct {
|
||||
id string
|
||||
updatedAt time.Time
|
||||
}{r.ID, r.UpdatedAt})
|
||||
}
|
||||
sort.Slice(recencyRank, func(i, j int) bool {
|
||||
return recencyRank[i].updatedAt.After(recencyRank[j].updatedAt)
|
||||
})
|
||||
recencyRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(recencyRank) && i < 30; i++ {
|
||||
recencyRankScoreMap[recencyRank[i].id] = float64(30 - i)
|
||||
}
|
||||
|
||||
// 3. 付款数排名:按 payCount 降序,前20名得 20~1 分
|
||||
payRank := make([]idCnt, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
payRank = append(payRank, idCnt{r.ID, payCountMap[r.ID]})
|
||||
}
|
||||
sort.Slice(payRank, func(i, j int) bool { return payRank[i].cnt > payRank[j].cnt })
|
||||
payRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(payRank) && i < 20; i++ {
|
||||
payRankScoreMap[payRank[i].id] = float64(20 - i)
|
||||
}
|
||||
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
readCnt := readCountMap[r.ID]
|
||||
payCnt := payCountMap[r.ID]
|
||||
readRankScore := readRankScoreMap[r.ID]
|
||||
recencyRankScore := recencyRankScoreMap[r.ID]
|
||||
payRankScore := payRankScoreMap[r.ID]
|
||||
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
|
||||
hot := readWeight*readRankScore + recencyWeight*recencyRankScore + payWeight*payRankScore
|
||||
// 若章节有手动覆盖的 hot_score(>0),则优先使用
|
||||
if r.HotScore > 0 {
|
||||
hot = float64(r.HotScore)
|
||||
}
|
||||
item := sectionListItem{
|
||||
ID: r.ID,
|
||||
MID: r.MID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readCnt,
|
||||
PayCount: payCnt,
|
||||
HotScore: hot,
|
||||
}
|
||||
if setPinned {
|
||||
item.IsPinned = pinnedSet[r.ID]
|
||||
}
|
||||
sections = append(sections, item)
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
@@ -47,148 +295,65 @@ func DBBookAction(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
switch action {
|
||||
case "list":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
// 章节管理树:按 sort_order 顺序,含 hotScore
|
||||
sections, err := computeSectionListWithHotScore(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
// 点击量:与 reading_progress 表直接捆绑,按 section_id 计数(小程序打开章节会立即上报一条)
|
||||
type readCnt struct{ SectionID string `gorm:"column:section_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||||
var readCounts []readCnt
|
||||
if len(ids) > 0 {
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").Where("section_id IN ?", ids).Group("section_id").Scan(&readCounts)
|
||||
}
|
||||
readMap := make(map[string]int)
|
||||
for _, x := range readCounts {
|
||||
readMap[x.SectionID] = int(x.Cnt)
|
||||
}
|
||||
// 付款笔数:与 orders 表直接捆绑,兼容 paid/completed/success 等已支付状态
|
||||
type payCnt struct{ ProductID string `gorm:"column:product_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||||
var payCounts []payCnt
|
||||
if len(ids) > 0 {
|
||||
db.Table("orders").Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND status IN ? AND product_id IN ?", "section", []string{"paid", "completed", "success"}, ids).
|
||||
Group("product_id").Scan(&payCounts)
|
||||
}
|
||||
payMap := make(map[string]int)
|
||||
for _, x := range payCounts {
|
||||
payMap[x.ProductID] = int(x.Cnt)
|
||||
}
|
||||
// 文章排名算法:权重可从 config article_ranking_weights 读取,默认 阅读50% 新度30% 付款20%
|
||||
readWeight, recencyWeight, payWeight := 0.5, 0.3, 0.2
|
||||
var cfgRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfgRow).Error; err == nil && len(cfgRow.ConfigValue) > 0 {
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal(cfgRow.ConfigValue, &m) == nil {
|
||||
if v, ok := m["readWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
readWeight = f
|
||||
}
|
||||
}
|
||||
if v, ok := m["recencyWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
recencyWeight = f
|
||||
}
|
||||
}
|
||||
if v, ok := m["payWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
payWeight = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 热度 = readWeight*阅读排名分 + recencyWeight*新度排名分 + payWeight*付款排名分
|
||||
readScore := make(map[string]float64)
|
||||
idsByRead := make([]string, len(ids))
|
||||
copy(idsByRead, ids)
|
||||
sort.Slice(idsByRead, func(i, j int) bool { return readMap[idsByRead[i]] > readMap[idsByRead[j]] })
|
||||
for i := 0; i < 20 && i < len(idsByRead); i++ {
|
||||
readScore[idsByRead[i]] = float64(20 - i)
|
||||
}
|
||||
recencyScore := make(map[string]float64)
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].UpdatedAt.After(rows[j].UpdatedAt) })
|
||||
for i := 0; i < 30 && i < len(rows); i++ {
|
||||
recencyScore[rows[i].ID] = float64(30 - i)
|
||||
}
|
||||
payScore := make(map[string]float64)
|
||||
idsByPay := make([]string, len(ids))
|
||||
copy(idsByPay, ids)
|
||||
sort.Slice(idsByPay, func(i, j int) bool { return payMap[idsByPay[i]] > payMap[idsByPay[j]] })
|
||||
for i := 0; i < 20 && i < len(idsByPay); i++ {
|
||||
payScore[idsByPay[i]] = float64(20 - i)
|
||||
}
|
||||
type idTotal struct {
|
||||
id string
|
||||
total float64
|
||||
}
|
||||
overrideMap := make(map[string]float64)
|
||||
for _, r := range rows {
|
||||
if r.HotScoreOverride != nil && *r.HotScoreOverride > 0 {
|
||||
overrideMap[r.ID] = *r.HotScoreOverride
|
||||
}
|
||||
}
|
||||
totals := make([]idTotal, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := readWeight*readScore[r.ID] + recencyWeight*recencyScore[r.ID] + payWeight*payScore[r.ID]
|
||||
if v, ok := overrideMap[r.ID]; ok {
|
||||
t = v
|
||||
}
|
||||
totals = append(totals, idTotal{r.ID, t})
|
||||
}
|
||||
sort.Slice(totals, func(i, j int) bool { return totals[i].total > totals[j].total })
|
||||
hotRankMap := make(map[string]int)
|
||||
for i, t := range totals {
|
||||
hotRankMap[t.id] = i + 1
|
||||
}
|
||||
hotScoreMap := make(map[string]float64)
|
||||
for _, t := range totals {
|
||||
hotScoreMap[t.id] = t.total
|
||||
}
|
||||
// 恢复 rows 的 sort_order 顺序(上面 recency 排序打乱了)
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soi, soj := 0, 0
|
||||
if rows[i].SortOrder != nil {
|
||||
soi = *rows[i].SortOrder
|
||||
}
|
||||
if rows[j].SortOrder != nil {
|
||||
soj = *rows[j].SortOrder
|
||||
}
|
||||
if soi != soj {
|
||||
return soi < soj
|
||||
}
|
||||
return rows[i].ID < rows[j].ID
|
||||
})
|
||||
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,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readMap[r.ID],
|
||||
PayCount: payMap[r.ID],
|
||||
HotScore: hotScoreMap[r.ID],
|
||||
HotRank: hotRankMap[r.ID],
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "ranking":
|
||||
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
|
||||
sections, err := computeArticleRankingSections(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "read":
|
||||
midStr := c.Query("mid")
|
||||
if midStr != "" {
|
||||
// 优先用 mid 获取(管理端编辑、小程序跳转推荐)
|
||||
mid, err := strconv.Atoi(midStr)
|
||||
if err != nil || mid < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "mid 必须为正整数"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Where("mid = ?", mid).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,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或 mid"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
@@ -207,15 +372,17 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -235,10 +402,23 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
case "export":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
if rows[i].SortOrder != nil {
|
||||
soI = *rows[i].SortOrder
|
||||
}
|
||||
if rows[j].SortOrder != nil {
|
||||
soJ = *rows[j].SortOrder
|
||||
}
|
||||
if soI != soJ {
|
||||
return soI < soJ
|
||||
}
|
||||
return naturalLessSectionID(rows[i].ID, rows[j].ID)
|
||||
})
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
@@ -267,6 +447,8 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
case "import":
|
||||
@@ -280,20 +462,24 @@ func DBBookAction(c *gin.Context) {
|
||||
if item.IsFree != nil {
|
||||
isFree = *item.IsFree
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
processed, _ := ParseAutoLinkContent(item.Content)
|
||||
wordCount := len(processed)
|
||||
status := "published"
|
||||
editionStandard, editionPremium := true, false
|
||||
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,
|
||||
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: processed,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
EditionStandard: &editionStandard,
|
||||
EditionPremium: &editionPremium,
|
||||
}
|
||||
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -311,8 +497,11 @@ func DBBookAction(c *gin.Context) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cache.InvalidateChapterContentByID(item.ID)
|
||||
imported++
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
default:
|
||||
@@ -326,21 +515,24 @@ func DBBookAction(c *gin.Context) {
|
||||
IDs []string `json:"ids"`
|
||||
Items []reorderItem `json:"items"`
|
||||
// move-sections:批量移动节到目标篇/章
|
||||
SectionIds []string `json:"sectionIds"`
|
||||
TargetPartID string `json:"targetPartId"`
|
||||
TargetChapterID string `json:"targetChapterId"`
|
||||
TargetPartTitle string `json:"targetPartTitle"`
|
||||
TargetChapterTitle string `json:"targetChapterTitle"`
|
||||
ID string `json:"id"`
|
||||
NewID string `json:"newId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"`
|
||||
HotScore *float64 `json:"hotScore"`
|
||||
EditionStandard *bool `json:"editionStandard"`
|
||||
EditionPremium *bool `json:"editionPremium"`
|
||||
SectionIds []string `json:"sectionIds"`
|
||||
TargetPartID string `json:"targetPartId"`
|
||||
TargetChapterID string `json:"targetChapterId"`
|
||||
TargetPartTitle string `json:"targetPartTitle"`
|
||||
TargetChapterTitle string `json:"targetChapterTitle"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"` // stitch_soul:标记最新新增
|
||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
HotScore *float64 `json:"hotScore"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -373,6 +565,8 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
}
|
||||
@@ -387,6 +581,8 @@ func DBBookAction(c *gin.Context) {
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
}
|
||||
@@ -410,6 +606,8 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
|
||||
return
|
||||
}
|
||||
@@ -425,10 +623,16 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.IsFree != nil {
|
||||
isFree = *body.IsFree
|
||||
}
|
||||
wordCount := len(body.Content)
|
||||
// 后端统一解析 @/# 并转为带 data-id 的 span
|
||||
processedContent, err := ParseAutoLinkContent(body.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析 @/# 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
wordCount := len(processedContent)
|
||||
updates := map[string]interface{}{
|
||||
"section_title": body.Title,
|
||||
"content": body.Content,
|
||||
"content": processedContent,
|
||||
"word_count": wordCount,
|
||||
"price": price,
|
||||
"is_free": isFree,
|
||||
@@ -436,23 +640,98 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.IsNew != nil {
|
||||
updates["is_new"] = *body.IsNew
|
||||
}
|
||||
// 默认普通版:未传时按普通版处理
|
||||
if body.EditionStandard != nil {
|
||||
updates["edition_standard"] = *body.EditionStandard
|
||||
} else if body.EditionPremium == nil {
|
||||
updates["edition_standard"] = true
|
||||
updates["edition_premium"] = false
|
||||
}
|
||||
if body.EditionPremium != nil {
|
||||
updates["edition_premium"] = *body.EditionPremium
|
||||
}
|
||||
if body.HotScore != nil {
|
||||
updates["hot_score_override"] = *body.HotScore
|
||||
updates["hot_score"] = *body.HotScore
|
||||
}
|
||||
if body.NewID != "" && body.NewID != body.ID {
|
||||
updates["id"] = body.NewID
|
||||
if body.PartID != "" {
|
||||
updates["part_id"] = body.PartID
|
||||
}
|
||||
if body.PartTitle != "" {
|
||||
updates["part_title"] = body.PartTitle
|
||||
}
|
||||
if body.ChapterID != "" {
|
||||
updates["chapter_id"] = body.ChapterID
|
||||
}
|
||||
if body.ChapterTitle != "" {
|
||||
updates["chapter_title"] = body.ChapterTitle
|
||||
}
|
||||
var existing model.Chapter
|
||||
err = db.Where("id = ?", body.ID).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 新建:Create
|
||||
partID := body.PartID
|
||||
if partID == "" {
|
||||
partID = "part-1"
|
||||
}
|
||||
partTitle := body.PartTitle
|
||||
if partTitle == "" {
|
||||
partTitle = "未分类"
|
||||
}
|
||||
chapterID := body.ChapterID
|
||||
if chapterID == "" {
|
||||
chapterID = "chapter-1"
|
||||
}
|
||||
chapterTitle := body.ChapterTitle
|
||||
if chapterTitle == "" {
|
||||
chapterTitle = "未分类"
|
||||
}
|
||||
editionStandard, editionPremium := true, false
|
||||
if body.EditionPremium != nil && *body.EditionPremium {
|
||||
editionStandard, editionPremium = false, true
|
||||
} else if body.EditionStandard != nil {
|
||||
editionStandard = *body.EditionStandard
|
||||
}
|
||||
status := "published"
|
||||
ch := model.Chapter{
|
||||
ID: body.ID,
|
||||
PartID: partID,
|
||||
PartTitle: partTitle,
|
||||
ChapterID: chapterID,
|
||||
ChapterTitle: chapterTitle,
|
||||
SectionTitle: body.Title,
|
||||
Content: processedContent,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
EditionStandard: &editionStandard,
|
||||
EditionPremium: &editionPremium,
|
||||
}
|
||||
if body.IsNew != nil {
|
||||
ch.IsNew = body.IsNew
|
||||
}
|
||||
if err := db.Create(&ch).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateChapterContent(ch.MID)
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
cache.InvalidateChapterContentByID(body.ID)
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
@@ -493,9 +772,12 @@ func DBBookDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
cache.InvalidateChapterContentByID(id)
|
||||
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
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
240
soul-api/internal/handler/db_ckb_leads.go
Normal file
240
soul-api/internal/handler/db_ckb_leads.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
// mode=submitted: ckb_submit_records(join/match 提交)
|
||||
// mode=contact: ckb_lead_records(链接卡若留资,有 phone/wechat)
|
||||
func DBCKBLeadList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
mode := c.DefaultQuery("mode", "submitted")
|
||||
matchType := c.Query("matchType")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
dedup := c.DefaultQuery("dedup", "true")
|
||||
|
||||
if mode == "contact" {
|
||||
q := db.Model(&model.CkbLeadRecord{})
|
||||
var total int64
|
||||
var records []model.CkbLeadRecord
|
||||
if dedup == "true" {
|
||||
subQ := db.Model(&model.CkbLeadRecord{}).
|
||||
Select("MAX(id) as id").
|
||||
Group("COALESCE(NULLIF(user_id,''), COALESCE(NULLIF(phone,''), COALESCE(NULLIF(wechat_id,''), CAST(id AS CHAR))))")
|
||||
q = db.Model(&model.CkbLeadRecord{}).Where("id IN (?)", subQ)
|
||||
}
|
||||
q.Count(&total)
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"userNickname": r.Nickname,
|
||||
"matchType": "lead",
|
||||
"phone": r.Phone,
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
return
|
||||
}
|
||||
|
||||
q := db.Model(&model.CkbSubmitRecord{})
|
||||
if matchType != "" {
|
||||
if matchType == "join" || matchType == "match" {
|
||||
q = q.Where("action = ?", matchType)
|
||||
}
|
||||
}
|
||||
if dedup == "true" {
|
||||
subQ := db.Model(&model.CkbSubmitRecord{}).
|
||||
Select("MAX(id) as id").
|
||||
Group("COALESCE(NULLIF(user_id,''), CAST(id AS CHAR))")
|
||||
if matchType == "join" || matchType == "match" {
|
||||
subQ = subQ.Where("action = ?", matchType)
|
||||
}
|
||||
q = db.Model(&model.CkbSubmitRecord{}).Where("id IN (?)", subQ)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbSubmitRecord
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
if r.UserID != "" {
|
||||
userIDs[r.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
safeNickname := func(u *model.User) string {
|
||||
if u == nil || u.Nickname == nil {
|
||||
return ""
|
||||
}
|
||||
return *u.Nickname
|
||||
}
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"userNickname": safeNickname(userMap[r.UserID]),
|
||||
"matchType": r.Action,
|
||||
"nickname": r.Nickname,
|
||||
"params": r.Params,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
|
||||
func CKBPersonLeadStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
personToken := c.Query("token")
|
||||
|
||||
if personToken != "" {
|
||||
// 返回某人物的线索明细(通过 token → Person → 用 PersonID 和 Token 匹配 CkbLeadRecord.TargetPersonID)
|
||||
var person model.Person
|
||||
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id IN ?", []string{person.PersonID, person.Token})
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbLeadRecord
|
||||
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"nickname": r.Nickname,
|
||||
"phone": r.Phone,
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"source": r.Source,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"personName": person.Name,
|
||||
"records": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 无 token 参数:返回所有人物的获客数量汇总
|
||||
type PersonLeadStat struct {
|
||||
TargetPersonID string `gorm:"column:target_person_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
var stats []PersonLeadStat
|
||||
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
|
||||
|
||||
// 构建 personId/token → Person.Token 的映射,使前端能用 token 匹配
|
||||
var persons []model.Person
|
||||
db.Select("person_id, token").Find(&persons)
|
||||
pidToToken := make(map[string]string, len(persons))
|
||||
for _, p := range persons {
|
||||
pidToToken[p.PersonID] = p.Token
|
||||
pidToToken[p.Token] = p.Token
|
||||
}
|
||||
merged := make(map[string]int64)
|
||||
for _, s := range stats {
|
||||
key := pidToToken[s.TargetPersonID]
|
||||
if key == "" {
|
||||
key = s.TargetPersonID
|
||||
}
|
||||
merged[key] += s.Total
|
||||
}
|
||||
byPerson := make([]gin.H, 0, len(merged))
|
||||
for token, total := range merged {
|
||||
byPerson = append(byPerson, gin.H{"token": token, "total": total})
|
||||
}
|
||||
|
||||
// 同时统计全局(无特定人物的)线索
|
||||
var globalTotal int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"byPerson": byPerson,
|
||||
"globalLeads": globalTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records)
|
||||
func CKBPlanStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
type TypeStat struct {
|
||||
Action string `gorm:"column:action" json:"matchType"`
|
||||
Total int64 `gorm:"column:total" json:"total"`
|
||||
}
|
||||
var submitStats []TypeStat
|
||||
db.Raw("SELECT action, COUNT(*) as total FROM ckb_submit_records GROUP BY action").Scan(&submitStats)
|
||||
var submitTotal int64
|
||||
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
|
||||
var leadTotal int64
|
||||
db.Model(&model.CkbLeadRecord{}).Count(&leadTotal)
|
||||
withContact := leadTotal // ckb_lead_records 均有 phone 或 wechat
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"ckbTotal": submitTotal + leadTotal,
|
||||
"withContact": withContact,
|
||||
"byType": submitStats,
|
||||
"ckbApiKey": "***",
|
||||
"ckbApiUrl": "https://ckbapi.quwanzhi.com/v1/api/scenarios",
|
||||
"docNotes": "",
|
||||
"docContent": "",
|
||||
"routes": gin.H{},
|
||||
},
|
||||
})
|
||||
}
|
||||
99
soul-api/internal/handler/db_link_tag.go
Normal file
99
soul-api/internal/handler/db_link_tag.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBLinkTagList GET /api/db/link-tags 管理端-链接标签列表
|
||||
func DBLinkTagList(c *gin.Context) {
|
||||
var rows []model.LinkTag
|
||||
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
}
|
||||
|
||||
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
|
||||
func DBLinkTagSave(c *gin.Context) {
|
||||
var body struct {
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
Aliases string `json:"aliases"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId"`
|
||||
PagePath string `json:"pagePath"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.Label == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"})
|
||||
return
|
||||
}
|
||||
if !isValidNameOrLabel(strings.TrimSpace(body.Label)) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 只能包含汉字/字母/数字,不能为纯符号"})
|
||||
return
|
||||
}
|
||||
if body.TagID == "" {
|
||||
body.TagID = body.Label
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
||||
if body.Type == "miniprogram" {
|
||||
body.URL = ""
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
// 按 label 查找:文章编辑自动创建场景,若已存在则直接返回
|
||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
|
||||
existing.Label = body.Label
|
||||
existing.Aliases = body.Aliases
|
||||
existing.URL = body.URL
|
||||
existing.Type = body.Type
|
||||
existing.AppID = body.AppID
|
||||
existing.PagePath = body.PagePath
|
||||
db.Save(&existing)
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
// body.URL 已在 miniprogram 类型时置空
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
|
||||
}
|
||||
|
||||
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
|
||||
func DBLinkTagDelete(c *gin.Context) {
|
||||
tid := c.Query("tagId")
|
||||
if tid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 tagId"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("tag_id = ?", tid).Delete(&model.LinkTag{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
|
||||
func DBPersonList(c *gin.Context) {
|
||||
var rows []model.Person
|
||||
if err := database.DB().Order("name ASC").Find(&rows).Error; err != nil {
|
||||
@@ -20,11 +25,37 @@ func DBPersonList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "persons": rows})
|
||||
}
|
||||
|
||||
// DBPersonDetail GET /api/db/person 管理端-单个人物详情(编辑回显用)
|
||||
func DBPersonDetail(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
||||
return
|
||||
}
|
||||
var row model.Person
|
||||
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": row})
|
||||
}
|
||||
|
||||
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
|
||||
// 新增时自动生成 32 位唯一 token,文章 @ 时存 token,小程序点击时用 token 兑换真实密钥
|
||||
func DBPersonSave(c *gin.Context) {
|
||||
var body struct {
|
||||
PersonID string `json:"personId"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
PersonID string `json:"personId"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
|
||||
Greeting string `json:"greeting"`
|
||||
Tips string `json:"tips"`
|
||||
RemarkType string `json:"remarkType"`
|
||||
RemarkFormat string `json:"remarkFormat"`
|
||||
AddFriendInterval *int `json:"addFriendInterval"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
DeviceGroups []int64 `json:"deviceGroups"` // 设备ID列表,由管理端选择后传入
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -34,97 +65,271 @@ func DBPersonSave(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
|
||||
return
|
||||
}
|
||||
if body.PersonID == "" {
|
||||
body.PersonID = fmt.Sprintf("%s_%d", body.Name, time.Now().UnixMilli())
|
||||
if !isValidNameOrLabel(strings.TrimSpace(body.Name)) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 只能包含汉字/字母/数字,不能为纯符号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.Person
|
||||
// 按 name 查找:文章编辑自动创建场景,PersonID 为空时先查是否已存在
|
||||
if body.PersonID == "" {
|
||||
if db.Where("name = ?", strings.TrimSpace(body.Name)).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.PersonID == "" {
|
||||
body.PersonID = fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(strings.TrimSpace(body.Name), " ", "_")), time.Now().UnixMilli())
|
||||
}
|
||||
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
||||
existing.Name = body.Name
|
||||
existing.Label = body.Label
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
existing.Greeting = body.Greeting
|
||||
existing.Tips = body.Tips
|
||||
existing.RemarkType = body.RemarkType
|
||||
existing.RemarkFormat = body.RemarkFormat
|
||||
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
||||
existing.AddFriendInterval = *body.AddFriendInterval
|
||||
}
|
||||
if strings.TrimSpace(body.StartTime) != "" {
|
||||
existing.StartTime = strings.TrimSpace(body.StartTime)
|
||||
}
|
||||
if strings.TrimSpace(body.EndTime) != "" {
|
||||
existing.EndTime = strings.TrimSpace(body.EndTime)
|
||||
}
|
||||
if len(body.DeviceGroups) > 0 {
|
||||
ids := make([]string, 0, len(body.DeviceGroups))
|
||||
for _, id := range body.DeviceGroups {
|
||||
if id > 0 {
|
||||
ids = append(ids, fmt.Sprintf("%d", id))
|
||||
}
|
||||
}
|
||||
existing.DeviceGroups = strings.Join(ids, ",")
|
||||
} else {
|
||||
existing.DeviceGroups = ""
|
||||
}
|
||||
db.Save(&existing)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
||||
return
|
||||
}
|
||||
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label}
|
||||
if err := db.Create(&p).Error; err != nil {
|
||||
|
||||
// 新增:创建本地 Person 记录前,先在存客宝创建获客计划并获取 planId + apiKey
|
||||
tok, err := genPersonToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成 token 失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 获取开放 API token
|
||||
openToken, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": p})
|
||||
|
||||
// 2. 构造创建计划请求体
|
||||
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
|
||||
addInterval := 1
|
||||
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
||||
addInterval = *body.AddFriendInterval
|
||||
}
|
||||
startTime := "09:00"
|
||||
if strings.TrimSpace(body.StartTime) != "" {
|
||||
startTime = strings.TrimSpace(body.StartTime)
|
||||
}
|
||||
endTime := "18:00"
|
||||
if strings.TrimSpace(body.EndTime) != "" {
|
||||
endTime = strings.TrimSpace(body.EndTime)
|
||||
}
|
||||
deviceIDs := make([]int64, 0, len(body.DeviceGroups))
|
||||
for _, id := range body.DeviceGroups {
|
||||
if id > 0 {
|
||||
deviceIDs = append(deviceIDs, id)
|
||||
}
|
||||
}
|
||||
if len(deviceIDs) == 0 {
|
||||
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取默认设备失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
deviceIDs = []int64{defaultID}
|
||||
}
|
||||
planPayload := map[string]interface{}{
|
||||
"name": name,
|
||||
"planType": 1,
|
||||
"sceneId": 9,
|
||||
"scenario": 9,
|
||||
"status": 1,
|
||||
"remarkType": body.RemarkType,
|
||||
"greeting": body.Greeting,
|
||||
"addInterval": addInterval,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"enabled": true,
|
||||
"tips": body.Tips,
|
||||
"distributionEnabled": false,
|
||||
"deviceGroups": deviceIDs,
|
||||
}
|
||||
|
||||
planID, ckbCreateData, ckbResponse, err := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if err != nil {
|
||||
out := gin.H{"success": false, "error": "创建存客宝计划失败: " + err.Error()}
|
||||
if ckbResponse != nil {
|
||||
out["ckbResponse"] = ckbResponse
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := parseApiKeyFromCreateData(ckbCreateData)
|
||||
if apiKey == "" {
|
||||
var getErr error
|
||||
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
||||
if getErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + getErr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newPerson := model.Person{
|
||||
PersonID: body.PersonID,
|
||||
Token: tok,
|
||||
Name: body.Name,
|
||||
Label: body.Label,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
Greeting: body.Greeting,
|
||||
Tips: body.Tips,
|
||||
RemarkType: body.RemarkType,
|
||||
RemarkFormat: body.RemarkFormat,
|
||||
AddFriendInterval: addInterval,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
idsStr := make([]string, 0, len(deviceIDs))
|
||||
for _, id := range deviceIDs {
|
||||
idsStr = append(idsStr, fmt.Sprintf("%d", id))
|
||||
}
|
||||
newPerson.DeviceGroups = strings.Join(idsStr, ",")
|
||||
|
||||
if err := db.Create(&newPerson).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := gin.H{"success": true, "person": newPerson}
|
||||
if len(ckbCreateData) > 0 {
|
||||
resp["ckbCreateResult"] = ckbCreateData
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
|
||||
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name 必填")
|
||||
}
|
||||
personID := fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(name, " ", "_")), time.Now().UnixMilli())
|
||||
tok, err := genPersonToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 token 失败")
|
||||
}
|
||||
openToken, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planName := fmt.Sprintf("SOUL链接人与事-%s", name)
|
||||
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取默认设备失败: %w", err)
|
||||
}
|
||||
planPayload := map[string]interface{}{
|
||||
"name": planName,
|
||||
"planType": 1,
|
||||
"sceneId": 9,
|
||||
"scenario": 9,
|
||||
"status": 1,
|
||||
"addInterval": 1,
|
||||
"startTime": "09:00",
|
||||
"endTime": "18:00",
|
||||
"enabled": true,
|
||||
"distributionEnabled": false,
|
||||
"deviceGroups": []int64{defaultID},
|
||||
}
|
||||
planID, createData, _, err := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建存客宝计划失败: %w", err)
|
||||
}
|
||||
apiKey := parseApiKeyFromCreateData(createData)
|
||||
if apiKey == "" {
|
||||
var getErr error
|
||||
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("获取计划密钥失败: %w", getErr)
|
||||
}
|
||||
}
|
||||
newPerson := model.Person{
|
||||
PersonID: personID,
|
||||
Token: tok,
|
||||
Name: name,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
AddFriendInterval: 1,
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
DeviceGroups: fmt.Sprintf("%d", defaultID),
|
||||
}
|
||||
if err := db.Create(&newPerson).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &newPerson, nil
|
||||
}
|
||||
|
||||
func genPersonToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base64.URLEncoding.EncodeToString(b)
|
||||
s = strings.ReplaceAll(s, "+", "")
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "=", "")
|
||||
if len(s) >= 32 {
|
||||
return s[:32], nil
|
||||
}
|
||||
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
||||
}
|
||||
|
||||
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
||||
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地
|
||||
func DBPersonDelete(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
||||
return
|
||||
}
|
||||
var row model.Person
|
||||
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
// 若有存客宝计划,先调 CKB 删除
|
||||
if row.CkbPlanID > 0 {
|
||||
token, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取存客宝鉴权失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ckbOpenDeletePlan(token, row.CkbPlanID); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除存客宝计划失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func DBLinkTagList(c *gin.Context) {
|
||||
var rows []model.LinkTag
|
||||
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
|
||||
}
|
||||
|
||||
func DBLinkTagSave(c *gin.Context) {
|
||||
var body struct {
|
||||
TagID string `json:"tagId"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
AppID string `json:"appId"`
|
||||
PagePath string `json:"pagePath"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.TagID == "" || body.Label == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 和 label 必填"})
|
||||
return
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
|
||||
existing.Label = body.Label
|
||||
existing.URL = body.URL
|
||||
existing.Type = body.Type
|
||||
existing.AppID = body.AppID
|
||||
existing.PagePath = body.PagePath
|
||||
db.Save(&existing)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
|
||||
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, "linkTag": t})
|
||||
}
|
||||
|
||||
func DBLinkTagDelete(c *gin.Context) {
|
||||
tid := c.Query("tagId")
|
||||
if tid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 tagId"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("tag_id = ?", tid).Delete(&model.LinkTag{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
493
soul-api/internal/handler/gift_pay.go
Normal file
493
soul-api/internal/handler/gift_pay.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const giftPayExpireHours = 24
|
||||
|
||||
// giftPayPreviewContent 取内容前 20%,用于代付页营销展示
|
||||
func giftPayPreviewContent(content string) string {
|
||||
n := utf8.RuneCountInString(content)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
limit := n * 20 / 100
|
||||
if limit < 50 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > n {
|
||||
limit = n
|
||||
}
|
||||
runes := []rune(content)
|
||||
if limit >= n {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "……"
|
||||
}
|
||||
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
|
||||
func GiftPayCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 校验发起人
|
||||
var initiator model.User
|
||||
if err := db.Where("id = ?", req.UserID).First(&initiator).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 价格与商品校验
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "match":
|
||||
productID = "match"
|
||||
case "fullbook":
|
||||
productID = "fullbook"
|
||||
}
|
||||
}
|
||||
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
// 发起人若有推荐人绑定,享受好友优惠
|
||||
var referrerID *string
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT referrer_id FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
amount = amount * (1 - userDiscount/100)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = referrerID // 分佣在 PayNotify 时按发起人计算
|
||||
|
||||
// 校验发起人是否已拥有
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status IN ?",
|
||||
req.UserID, "section", productID, []string{"paid", "completed"}).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有该章节"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ProductType == "fullbook" || req.ProductType == "vip" {
|
||||
var u model.User
|
||||
db.Where("id = ?", req.UserID).Select("has_full_book", "is_vip", "vip_expire_date").First(&u)
|
||||
if u.HasFullBook != nil && *u.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有全书"})
|
||||
return
|
||||
}
|
||||
if req.ProductType == "vip" && u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已是有效VIP"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 描述
|
||||
desc := ""
|
||||
switch req.ProductType {
|
||||
case "fullbook":
|
||||
desc = "《一场Soul的创业实验》全书"
|
||||
case "vip":
|
||||
desc = "卡若创业派对VIP年度会员(365天)"
|
||||
case "match":
|
||||
desc = "购买匹配次数"
|
||||
case "section":
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
|
||||
desc = ch.SectionTitle
|
||||
} else {
|
||||
desc = fmt.Sprintf("章节-%s", productID)
|
||||
}
|
||||
default:
|
||||
desc = fmt.Sprintf("%s-%s", req.ProductType, productID)
|
||||
}
|
||||
|
||||
expireAt := time.Now().Add(giftPayExpireHours * time.Hour)
|
||||
requestSN := "GPR" + wechat.GenerateOrderSn()
|
||||
id := "gpr_" + fmt.Sprintf("%d", time.Now().UnixNano()%100000000000)
|
||||
|
||||
gpr := model.GiftPayRequest{
|
||||
ID: id,
|
||||
RequestSN: requestSN,
|
||||
InitiatorUserID: req.UserID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: productID,
|
||||
Amount: amount,
|
||||
Description: desc,
|
||||
Status: "pending",
|
||||
ExpireAt: expireAt,
|
||||
}
|
||||
if err := db.Create(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx 代付详情(代付人用)
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发起人昵称(脱敏)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname").First(&initiator).Error; err == nil && initiator.Nickname != nil {
|
||||
n := *initiator.Nickname
|
||||
if len(n) > 2 {
|
||||
n = string([]rune(n)[0]) + "**"
|
||||
}
|
||||
nickname = n
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览,吸引代付人
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"amount": gpr.Amount,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayPay POST /api/miniprogram/gift-pay/pay 代付人发起支付
|
||||
func GiftPayPay(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
UserID string `json:"userId"` // 代付人ID,用于校验不能自己付
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 不能自己给自己代付
|
||||
if req.UserID != "" && req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取代付人信息
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&payer).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if payer.ID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建订单(归属发起人,记录代付信息)
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
productID := gpr.ProductID
|
||||
desc := gpr.Description
|
||||
gprID := gpr.ID
|
||||
payerID := payer.ID
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: gpr.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &payerID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 唤起微信支付,attach 中 userId=发起人,giftPayRequestSn=请求号
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s","giftPayRequestSn":"%s"}`,
|
||||
gpr.ProductType, gpr.ProductID, gpr.InitiatorUserID, gpr.RequestSN)
|
||||
totalFee := int(gpr.Amount * 100)
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("支付请求失败: %v", err)})
|
||||
return
|
||||
}
|
||||
payParams, err := wechat.GetJSAPIPayParams(prepayID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成支付参数失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 预占:更新请求状态为 paying(可选,防并发)
|
||||
// 简化:不预占,PayNotify 时再更新
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"orderSn": orderSn,
|
||||
"prepayId": prepayID,
|
||||
"payParams": payParams,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
|
||||
func GiftPayCancel(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", req.RequestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
if gpr.InitiatorUserID != req.UserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
|
||||
return
|
||||
}
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&gpr).Update("status", "cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
|
||||
}
|
||||
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
|
||||
func GiftPayMyRequests(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// GiftPayMyPayments GET /api/miniprogram/gift-pay/my-payments?userId= 我帮付的
|
||||
func GiftPayMyPayments(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("payer_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// AdminGiftPayRequestsList GET /api/admin/gift-pay-requests 管理端-代付请求列表
|
||||
func AdminGiftPayRequestsList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.GiftPayRequest{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var list []model.GiftPayRequest
|
||||
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range list {
|
||||
userIDs[r.InitiatorUserID] = true
|
||||
if r.PayerUserID != nil && *r.PayerUserID != "" {
|
||||
userIDs[*r.PayerUserID] = true
|
||||
}
|
||||
}
|
||||
nicknames := make(map[string]string)
|
||||
if len(userIDs) > 0 {
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
db.Select("id, nickname").Where("id IN ?", ids).Find(&users)
|
||||
for _, u := range users {
|
||||
if u.Nickname != nil {
|
||||
nicknames[u.ID] = *u.Nickname
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
initiatorNick := nicknames[r.InitiatorUserID]
|
||||
payerNick := ""
|
||||
if r.PayerUserID != nil {
|
||||
payerNick = nicknames[*r.PayerUserID]
|
||||
}
|
||||
orderID := ""
|
||||
if r.OrderID != nil {
|
||||
orderID = *r.OrderID
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"requestSn": r.RequestSN,
|
||||
"initiatorUserId": r.InitiatorUserID,
|
||||
"initiatorNick": initiatorNick,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"payerUserId": r.PayerUserID,
|
||||
"payerNick": payerNick,
|
||||
"orderId": orderID,
|
||||
"expireAt": r.ExpireAt,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -13,10 +11,9 @@ import (
|
||||
)
|
||||
|
||||
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
|
||||
// 当 ?stats=true 时返回汇总统计(总匹配数、今日匹配、按类型分布、独立用户数)
|
||||
// 当 ?stats=true 时返回汇总统计
|
||||
func DBMatchRecordsList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
if c.Query("stats") == "true" {
|
||||
var totalMatches int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records").Scan(&totalMatches)
|
||||
@@ -30,14 +27,11 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
db.Raw("SELECT match_type, COUNT(*) as count FROM match_records GROUP BY match_type").Scan(&byType)
|
||||
var uniqueUsers int64
|
||||
db.Raw("SELECT COUNT(DISTINCT user_id) FROM match_records WHERE user_id IS NOT NULL AND user_id != ''").Scan(&uniqueUsers)
|
||||
|
||||
// 匹配收益:product_type=match 且 status=paid 的订单金额总和
|
||||
var matchRevenue float64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status = ?", "match", "paid").
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&matchRevenue)
|
||||
var paidMatchCount int64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status = ?", "match", "paid").Count(&paidMatchCount)
|
||||
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).Count(&paidMatchCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
@@ -51,7 +45,6 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
matchType := c.Query("matchType")
|
||||
@@ -77,8 +70,12 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
userIDs[r.UserID] = true
|
||||
userIDs[r.MatchedUserID] = true
|
||||
if r.UserID != "" {
|
||||
userIDs[r.UserID] = true
|
||||
}
|
||||
if r.MatchedUserID != "" {
|
||||
userIDs[r.MatchedUserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
@@ -100,28 +97,35 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
return *s
|
||||
}
|
||||
|
||||
safeNickname := func(u *model.User) string {
|
||||
if u == nil || u.Nickname == nil { return "" }
|
||||
return *u.Nickname
|
||||
}
|
||||
safeAvatar := func(u *model.User) string {
|
||||
if u == nil || u.Avatar == nil { return "" }
|
||||
return *u.Avatar
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
u := userMap[r.UserID]
|
||||
mu := userMap[r.MatchedUserID]
|
||||
userAvatar := ""
|
||||
matchedUserAvatar := ""
|
||||
if u != nil && u.Avatar != nil {
|
||||
userAvatar = *u.Avatar
|
||||
}
|
||||
if mu != nil && mu.Avatar != nil {
|
||||
matchedUserAvatar = *mu.Avatar
|
||||
}
|
||||
userNickname := ""
|
||||
if u != nil {
|
||||
userNickname = getStr(u.Nickname)
|
||||
}
|
||||
matchedNickname := ""
|
||||
if mu != nil {
|
||||
matchedNickname = getStr(mu.Nickname)
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "matchedUserId": r.MatchedUserID,
|
||||
"matchType": r.MatchType, "phone": getStr(r.Phone), "wechatId": getStr(r.WechatID),
|
||||
"userNickname": safeNickname(u),
|
||||
"matchedNickname": safeNickname(mu),
|
||||
"userAvatar": safeAvatar(u),
|
||||
"matchedUserAvatar": safeAvatar(mu),
|
||||
"matchScore": r.MatchScore,
|
||||
"createdAt": r.CreatedAt,
|
||||
"userNickname": userNickname,
|
||||
"matchedNickname": matchedNickname,
|
||||
"userAvatar": userAvatar,
|
||||
"matchedUserAvatar": matchedUserAvatar,
|
||||
"matchScore": r.MatchScore,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,34 +161,3 @@ func DBMatchPoolCounts(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DBMatchRecordInsertTest POST /api/db/match-records/test 插入测试匹配记录
|
||||
func DBMatchRecordInsertTest(c *gin.Context) {
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.MatchType == "" {
|
||||
body.MatchType = "team"
|
||||
}
|
||||
if body.Phone == "" {
|
||||
body.Phone = "13800000000"
|
||||
}
|
||||
db := database.DB()
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_test_%d", time.Now().UnixNano()),
|
||||
UserID: "admin_test",
|
||||
MatchType: body.MatchType,
|
||||
Phone: &body.Phone,
|
||||
}
|
||||
if body.WechatID != "" {
|
||||
rec.WechatID = &body.WechatID
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试记录已插入", "id": rec.ID})
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
// 记录注册行为到 user_tracks
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
|
||||
// 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用)
|
||||
AdminShensheShouAutoTag(userID, "")
|
||||
} else {
|
||||
@@ -134,7 +137,7 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
"id": user.ID,
|
||||
"openId": getStringValue(user.OpenID),
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
@@ -160,6 +163,86 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramDevLoginAs POST /api/miniprogram/dev/login-as 开发专用:按 userId 切换账号(仅 APP_ENV=development 可用)
|
||||
func MiniprogramDevLoginAs(c *gin.Context) {
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "仅开发环境可用"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "userId 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
openID := getStringValue(user.OpenID)
|
||||
if openID == "" {
|
||||
openID = user.ID // 部分用户 id 即 openId
|
||||
}
|
||||
tokenSuffix := openID
|
||||
if len(openID) >= 8 {
|
||||
tokenSuffix = openID[len(openID)-8:]
|
||||
}
|
||||
token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
|
||||
|
||||
var purchasedSections []string
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows)
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
if purchasedSections == nil {
|
||||
purchasedSections = []string{}
|
||||
}
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"openId": openID,
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
"hasFullBook": getBoolValue(user.HasFullBook),
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": getFloatValue(user.Earnings),
|
||||
"pendingEarnings": getFloatValue(user.PendingEarnings),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
"createdAt": user.CreatedAt,
|
||||
}
|
||||
if user.IsVip != nil {
|
||||
responseUser["isVip"] = *user.IsVip
|
||||
}
|
||||
if user.VipExpireDate != nil {
|
||||
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
@@ -168,6 +251,18 @@ func getStringValue(ptr *string) string {
|
||||
return *ptr
|
||||
}
|
||||
|
||||
// getUrlValue 取字符串指针值并修复缺少冒号的 URL("https//..." → "https://...")
|
||||
func getUrlValue(ptr *string) string {
|
||||
s := getStringValue(ptr)
|
||||
if strings.HasPrefix(s, "https//") {
|
||||
return "https://" + s[7:]
|
||||
}
|
||||
if strings.HasPrefix(s, "http//") {
|
||||
return "http://" + s[6:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getBoolValue(ptr *bool) bool {
|
||||
if ptr == nil {
|
||||
return false
|
||||
@@ -222,54 +317,81 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
var finalAmount float64
|
||||
var orderSn string
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode)
|
||||
finalAmount := req.Amount
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = req.Amount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
if req.ProductType == "balance_recharge" {
|
||||
// 充值:从已创建的订单取金额,productId=orderSn
|
||||
var existOrder model.Order
|
||||
if err := db.Where("order_sn = ? AND product_type = ? AND status = ?", req.ProductID, "balance_recharge", "created").First(&existOrder).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "充值订单不存在或已支付"})
|
||||
return
|
||||
}
|
||||
orderSn = existOrder.OrderSN
|
||||
finalAmount = existOrder.Amount
|
||||
if req.UserID != "" && existOrder.UserID != req.UserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "订单用户不匹配"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// -------- V1.1 后端价格:从 DB 读取标准价 --------
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
finalAmount = standardPrice
|
||||
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = finalAmount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
|
||||
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
|
||||
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
|
||||
}
|
||||
orderSn = wechat.GenerateOrderSn()
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
totalFee := int(finalAmount * 100) // 转为分
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
if req.ProductType == "fullbook" {
|
||||
if req.ProductType == "balance_recharge" {
|
||||
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
||||
} else if req.ProductType == "fullbook" {
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
} else if req.ProductType == "vip" {
|
||||
description = "卡若创业派对VIP年度会员(365天)"
|
||||
@@ -286,7 +408,6 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// 插入订单到数据库
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
@@ -304,24 +425,27 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
// 订单创建失败,但不中断支付流程
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
// 充值订单已存在,不重复创建
|
||||
if req.ProductType != "balance_recharge" {
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
PaymentMethod: &pm,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
|
||||
@@ -372,7 +496,7 @@ func miniprogramPayGet(c *gin.Context) {
|
||||
switch tradeState {
|
||||
case "SUCCESS":
|
||||
status = "paid"
|
||||
// 若微信已支付,主动同步到本地 orders(不等 PayNotify),便于购买次数即时生效
|
||||
// V1.3 修复:主动同步到本地 orders,并激活对应权益(VIP/全书),避免等待 PayNotify 延迟
|
||||
db := database.DB()
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
|
||||
@@ -382,7 +506,13 @@ func miniprogramPayGet(c *gin.Context) {
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
order.Status = strToPtr("paid")
|
||||
order.PayTime = &now
|
||||
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||||
// 激活权益
|
||||
if order.UserID != "" {
|
||||
activateOrderBenefits(db, &order, now)
|
||||
}
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
@@ -408,9 +538,10 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
}
|
||||
if attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
@@ -466,11 +597,12 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
@@ -479,35 +611,60 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
// 代付订单:更新 gift_pay_request、订单 payer_user_id
|
||||
// 权益归属与分佣:代付时归发起人(order.UserID),普通订单归 buyerUserID
|
||||
beneficiaryUserID := buyerUserID
|
||||
if attach.GiftPayRequestSn != "" && order.UserID != "" {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单,权益归属发起人: %s\n", beneficiaryUserID)
|
||||
}
|
||||
if attach.GiftPayRequestSn != "" {
|
||||
var payerUserID string
|
||||
if openID != "" {
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", openID).First(&payer).Error; err == nil {
|
||||
payerUserID = payer.ID
|
||||
db.Model(&order).Update("payer_user_id", payerUserID)
|
||||
}
|
||||
}
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"payer_user_id": payerUserID,
|
||||
"order_id": orderSn,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
if beneficiaryUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
db.Model(&model.User{}).Where("id = ?", beneficiaryUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", beneficiaryUserID)
|
||||
} else if attach.ProductType == "vip" {
|
||||
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at(排序:后付款在前)
|
||||
expireDate := time.Now().AddDate(0, 0, 365)
|
||||
vipActivatedAt := time.Now()
|
||||
if order.PayTime != nil {
|
||||
vipActivatedAt = *order.PayTime
|
||||
}
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": vipActivatedAt,
|
||||
})
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
expireDate := activateVIP(db, beneficiaryUserID, 365, vipActivatedAt)
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", beneficiaryUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", beneficiaryUserID, orderSn)
|
||||
} else if attach.ProductType == "balance_recharge" {
|
||||
if err := ConfirmBalanceRechargeByOrder(db, &order); err != nil {
|
||||
fmt.Printf("[PayNotify] 余额充值确认失败: %s, err=%v\n", orderSn, err)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 余额充值成功: %s, 金额 %.2f\n", beneficiaryUserID, totalAmount)
|
||||
}
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
beneficiaryUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||||
}
|
||||
}
|
||||
productID := attach.ProductID
|
||||
@@ -516,9 +673,9 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
beneficiaryUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
|
||||
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -630,7 +787,13 @@ func MiniprogramPhone(c *gin.Context) {
|
||||
if req.UserID != "" {
|
||||
db := database.DB()
|
||||
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
|
||||
// 记录绑定手机号行为到 user_tracks
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
|
||||
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
||||
// 记录绑定手机行为
|
||||
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
|
||||
// 绑定手机号后,异步调用神射手自动完善标签
|
||||
AdminShensheShouAutoTag(req.UserID, phoneNumber)
|
||||
}
|
||||
@@ -734,6 +897,45 @@ func MiniprogramQrcodeImage(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "image/png", imageData)
|
||||
}
|
||||
|
||||
// GiftLinkGet GET /api/miniprogram/gift/link 代付链接(需登录,传 userId)
|
||||
// 返回 path、ref、scene,供 gift-link 页展示与复制;qrcodeImageUrl 供生成小程序码
|
||||
func GiftLinkGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId,请先登录"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userID).First(&user).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
|
||||
}
|
||||
ref := getStringValue(user.ReferralCode)
|
||||
if ref == "" {
|
||||
suffix := userID
|
||||
if len(userID) >= 6 {
|
||||
suffix = userID[len(userID)-6:]
|
||||
}
|
||||
ref = "SOUL" + strings.ToUpper(suffix)
|
||||
}
|
||||
path := fmt.Sprintf("pages/gift-link/gift-link?ref=%s&gift=1", ref)
|
||||
scene := fmt.Sprintf("ref_%s_gift_1", strings.ReplaceAll(ref, "&", "_"))
|
||||
if len(scene) > 32 {
|
||||
scene = scene[:32]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"path": path,
|
||||
"ref": ref,
|
||||
"scene": scene,
|
||||
})
|
||||
}
|
||||
|
||||
// base64 编码
|
||||
func base64Encode(data []byte) string {
|
||||
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
@@ -783,14 +985,17 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "fullbook", "vip").Count(&cnt)
|
||||
// V4.1 修复:is_vip 同时校验过期时间(is_vip=1 且 vip_expire_date>NOW),而非仅凭订单数量
|
||||
isVipActive, _ := isVipFromUsers(db, id)
|
||||
if !isVipActive {
|
||||
// 兜底:orders 表有有效 VIP 订单
|
||||
isVipActive, _ = isVipFromOrders(db, id)
|
||||
}
|
||||
// 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示
|
||||
item := gin.H{
|
||||
"id": user.ID,
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"vipName": getStringValue(user.VipName),
|
||||
@@ -810,7 +1015,7 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
"helpOffer": getStringValue(user.HelpOffer),
|
||||
"helpNeed": getStringValue(user.HelpNeed),
|
||||
"projectIntro": getStringValue(user.ProjectIntro),
|
||||
"is_vip": cnt > 0,
|
||||
"is_vip": isVipActive,
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
return
|
||||
@@ -821,15 +1026,121 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
u.ID, "paid", "fullbook", "vip").Count(&cnt)
|
||||
// V4.1:is_vip 同时校验过期时间
|
||||
uvip, _ := isVipFromUsers(db, u.ID)
|
||||
if !uvip {
|
||||
uvip, _ = isVipFromOrders(db, u.ID)
|
||||
}
|
||||
list = append(list, gin.H{
|
||||
"id": u.ID,
|
||||
"nickname": getStringValue(u.Nickname),
|
||||
"avatar": getStringValue(u.Avatar),
|
||||
"is_vip": cnt > 0,
|
||||
"avatar": getUrlValue(u.Avatar),
|
||||
"is_vip": uvip,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// strToPtr 返回字符串指针(辅助函数)
|
||||
func strToPtr(s string) *string { return &s }
|
||||
|
||||
// activateVIP 为用户激活 VIP:续费时从 max(now, vip_expire_date) 累加 days 天
|
||||
// 返回最终过期时间
|
||||
func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) time.Time {
|
||||
var u model.User
|
||||
db.Select("id", "is_vip", "vip_expire_date").Where("id = ?", userID).First(&u)
|
||||
base := activatedAt
|
||||
if u.VipExpireDate != nil && u.VipExpireDate.After(base) {
|
||||
base = *u.VipExpireDate // 续费累加
|
||||
}
|
||||
expireDate := base.AddDate(0, 0, days)
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": activatedAt,
|
||||
})
|
||||
return expireDate
|
||||
}
|
||||
|
||||
// activateOrderBenefits 订单支付成功后激活对应权益(VIP / 全书 / 余额充值)
|
||||
func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
|
||||
if order == nil {
|
||||
return
|
||||
}
|
||||
userID := order.UserID
|
||||
productType := order.ProductType
|
||||
switch productType {
|
||||
case "fullbook":
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
|
||||
case "vip":
|
||||
activateVIP(db, userID, 365, payTime)
|
||||
case "balance_recharge":
|
||||
ConfirmBalanceRechargeByOrder(db, order)
|
||||
}
|
||||
}
|
||||
|
||||
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
|
||||
// productType: fullbook / vip / section / match
|
||||
// productId: 章节购买时为章节 ID
|
||||
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
|
||||
switch productType {
|
||||
case "fullbook", "vip", "match":
|
||||
// 从 system_config 读取
|
||||
configKey := "chapter_config"
|
||||
if productType == "vip" {
|
||||
configKey = "vip_config"
|
||||
}
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil {
|
||||
var cfg map[string]interface{}
|
||||
if json.Unmarshal(row.ConfigValue, &cfg) == nil {
|
||||
fieldMap := map[string]string{
|
||||
"fullbook": "fullbookPrice",
|
||||
"vip": "price",
|
||||
"match": "matchPrice",
|
||||
}
|
||||
if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底默认值
|
||||
defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68}
|
||||
if p, ok := defaults[productType]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||||
|
||||
case "section", "gift":
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("单章购买缺少 productId")
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Select("id", "price", "is_free").Where("id = ?", productID).First(&ch).Error; err != nil {
|
||||
return 0, fmt.Errorf("章节不存在: %s", productID)
|
||||
}
|
||||
if ch.IsFree != nil && *ch.IsFree {
|
||||
return 0, fmt.Errorf("该章节为免费章节,无需支付")
|
||||
}
|
||||
if ch.Price == nil || *ch.Price <= 0 {
|
||||
return 0, fmt.Errorf("章节价格未配置: %s", productID)
|
||||
}
|
||||
return *ch.Price, nil
|
||||
|
||||
case "balance_recharge":
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("充值订单号缺失")
|
||||
}
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ? AND product_type = ?", productID, "balance_recharge").First(&order).Error; err != nil {
|
||||
return 0, fmt.Errorf("充值订单不存在: %s", productID)
|
||||
}
|
||||
if order.Amount <= 0 {
|
||||
return 0, fmt.Errorf("充值金额无效")
|
||||
}
|
||||
return order.Amount, nil
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func OrdersList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 收集订单中的 user_id、referrer_id,查用户信息
|
||||
// 收集订单中的 user_id、referrer_id、payer_user_id(代付人),查用户信息
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range orders {
|
||||
if o.UserID != "" {
|
||||
@@ -112,6 +112,9 @@ func OrdersList(c *gin.Context) {
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
if o.PayerUserID != nil && *o.PayerUserID != "" {
|
||||
userIDs[*o.PayerUserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
@@ -156,6 +159,14 @@ func OrdersList(c *gin.Context) {
|
||||
m["referrerCode"] = getStr(u.ReferralCode)
|
||||
}
|
||||
}
|
||||
// 代付人信息(实际付款人)
|
||||
if o.PayerUserID != nil && *o.PayerUserID != "" {
|
||||
if u := userMap[*o.PayerUserID]; u != nil {
|
||||
m["payerNickname"] = getStr(u.Nickname)
|
||||
} else {
|
||||
m["payerNickname"] = ""
|
||||
}
|
||||
}
|
||||
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission(会员 20%/10%,内容 90%)
|
||||
status := getStr(o.Status)
|
||||
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
|
||||
148
soul-api/internal/handler/oss.go
Normal file
148
soul-api/internal/handler/oss.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
type ossConfigCache struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
func getOssConfig() *ossConfigCache {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg ossConfigCache
|
||||
if err := json.Unmarshal(row.ConfigValue, &cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.Endpoint == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
|
||||
return nil
|
||||
}
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func ossUploadFile(file multipart.File, folder, filename string) (string, error) {
|
||||
cfg := getOssConfig()
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("OSS 未配置")
|
||||
}
|
||||
|
||||
endpoint := cfg.Endpoint
|
||||
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
|
||||
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
|
||||
}
|
||||
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
|
||||
err = bucket.PutObject(objectKey, strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传 OSS 失败: %w", err)
|
||||
}
|
||||
|
||||
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
|
||||
if err != nil {
|
||||
host := cfg.Bucket + "." + cfg.Endpoint
|
||||
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
host = cfg.Bucket + "." + cfg.Endpoint
|
||||
} else {
|
||||
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
|
||||
}
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
return host + "/" + objectKey, nil
|
||||
}
|
||||
return normalizeOSSUrl(signedURL), nil
|
||||
}
|
||||
|
||||
// normalizeOSSUrl 修复 OSS SDK 可能返回的缺少冒号的 URL(如 "https//..." → "https://...")
|
||||
func normalizeOSSUrl(u string) string {
|
||||
if strings.HasPrefix(u, "https//") {
|
||||
return "https://" + u[7:]
|
||||
}
|
||||
if strings.HasPrefix(u, "http//") {
|
||||
return "http://" + u[6:]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func ossUploadBytes(data []byte, folder, filename, contentType string) (string, error) {
|
||||
cfg := getOssConfig()
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("OSS 未配置")
|
||||
}
|
||||
|
||||
endpoint := cfg.Endpoint
|
||||
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
|
||||
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
|
||||
}
|
||||
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
|
||||
|
||||
var opts []oss.Option
|
||||
if contentType != "" {
|
||||
opts = append(opts, oss.ContentType(contentType))
|
||||
}
|
||||
|
||||
err = bucket.PutObject(objectKey, strings.NewReader(string(data)), opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传 OSS 失败: %w", err)
|
||||
}
|
||||
|
||||
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
|
||||
if err != nil {
|
||||
host := cfg.Bucket + "." + cfg.Endpoint
|
||||
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
host = cfg.Bucket + "." + cfg.Endpoint
|
||||
} else {
|
||||
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
|
||||
}
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
return host + "/" + objectKey, nil
|
||||
}
|
||||
return normalizeOSSUrl(signedURL), nil
|
||||
}
|
||||
@@ -38,6 +38,17 @@ func ReferralBind(c *gin.Context) {
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// V3.1 修复:若同时提供了 openId 和 userId,校验 userId 对应的用户确实拥有该 openId
|
||||
// 防止攻击者伪造他人 userId 来绑定推荐关系
|
||||
if req.UserID != "" && req.OpenID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.User{}).Where("id = ? AND open_id = ?", req.UserID, req.OpenID).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户身份校验失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
bindingDays := defaultBindingDays
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
@@ -272,7 +283,7 @@ func ReferralData(c *gin.Context) {
|
||||
activeUsers = append(activeUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"daysRemaining": daysRemaining,
|
||||
"hasFullBook": getBoolValue(referee.HasFullBook),
|
||||
"bindingDate": b.BindingDate,
|
||||
@@ -301,7 +312,7 @@ func ReferralData(c *gin.Context) {
|
||||
convertedUsers = append(convertedUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"commission": commission,
|
||||
"orderAmount": orderAmount,
|
||||
"purchaseCount": getIntValue(b.PurchaseCount),
|
||||
@@ -325,7 +336,7 @@ func ReferralData(c *gin.Context) {
|
||||
expiredUsers = append(expiredUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"bindingDate": b.BindingDate,
|
||||
"expiryDate": b.ExpiryDate,
|
||||
"status": "expired",
|
||||
@@ -355,7 +366,7 @@ func ReferralData(c *gin.Context) {
|
||||
"productId": getStringValue(e.ProductID),
|
||||
"description": getStringValue(e.Description),
|
||||
"buyerNickname": getStringValue(buyer.Nickname),
|
||||
"buyerAvatar": getStringValue(buyer.Avatar),
|
||||
"buyerAvatar": getUrlValue(buyer.Avatar),
|
||||
"payTime": e.PayTime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
|
||||
return base * vipOrderShareNonVip
|
||||
}
|
||||
// 内容订单:若有推荐人且 userDiscount>0,反推原价;否则按实付
|
||||
// 设计意图:推广者拿的是折前原价的佣金,好友折扣由平台承担,不影响推广者收益
|
||||
commissionBase := order.Amount
|
||||
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
|
||||
if (1 - userDiscount) > 0 {
|
||||
|
||||
@@ -37,6 +37,7 @@ func SearchGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
@@ -70,7 +71,7 @@ func SearchGet(c *gin.Context) {
|
||||
price = *ch.Price
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/oss"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadDir = "uploads"
|
||||
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
|
||||
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
|
||||
|
||||
// UploadPost POST /api/upload 上传图片(表单 file)
|
||||
// 若管理端已配置 OSS,优先上传到 OSS;OSS 失败或未配置时回退本地磁盘(容灾)
|
||||
func UploadPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -40,16 +42,41 @@ func UploadPost(c *gin.Context) {
|
||||
if folder == "" {
|
||||
folder = "avatars"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
|
||||
|
||||
// 优先尝试 OSS(已配置时)
|
||||
if oss.IsEnabled() {
|
||||
f, err := file.Open()
|
||||
if err == nil {
|
||||
url, uploadErr := oss.Upload(objectKey, f)
|
||||
_ = f.Close()
|
||||
if uploadErr == nil && url != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
return
|
||||
}
|
||||
// OSS 失败,回退本地(容灾)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地磁盘存储(OSS 未配置或失败时)
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
dir := filepath.Join(uploadDir, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
relPath := "/uploads/" + filepath.ToSlash(filepath.Join(folder, name))
|
||||
fullURL := relPath
|
||||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||||
fullURL = cfg.BaseURLJoin(relPath)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": fullURL, "data": gin.H{"url": fullURL, "fileName": name, "size": file.Size, "type": ct}})
|
||||
}
|
||||
|
||||
func randomStrUpload(n int) string {
|
||||
@@ -62,17 +89,40 @@ func randomStrUpload(n int) string {
|
||||
}
|
||||
|
||||
// UploadDelete DELETE /api/upload
|
||||
// path 支持:/uploads/xxx(本地)或 https://bucket.oss-xxx.aliyuncs.com/uploads/xxx(OSS)
|
||||
func UploadDelete(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
|
||||
// OSS 公网 URL:从 OSS 删除
|
||||
if oss.IsOSSURL(path) {
|
||||
objectKey := oss.ParseObjectKeyFromURL(path)
|
||||
if objectKey != "" {
|
||||
if err := oss.Delete(objectKey); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "OSS 删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 本地路径:支持 /uploads/xxx、uploads/xxx 或含 /uploads/ 的完整 URL
|
||||
if idx := strings.Index(path, "/uploads/"); idx >= 0 {
|
||||
path = path[idx+1:] // 从 uploads/ 开始
|
||||
}
|
||||
rel := strings.TrimPrefix(path, "/uploads/")
|
||||
rel = strings.TrimPrefix(rel, "uploads/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
|
||||
return
|
||||
}
|
||||
fullPath := strings.TrimPrefix(path, "/")
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel))
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
|
||||
return
|
||||
|
||||
280
soul-api/internal/handler/upload_content.go
Normal file
280
soul-api/internal/handler/upload_content.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadDirContent = "uploads"
|
||||
maxImageBytes = 5 * 1024 * 1024 // 5MB
|
||||
maxVideoBytes = 100 * 1024 * 1024 // 100MB
|
||||
defaultImageQuality = 85
|
||||
)
|
||||
|
||||
var (
|
||||
allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
|
||||
}
|
||||
allowedVideoTypes = map[string]bool{
|
||||
"video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true,
|
||||
}
|
||||
)
|
||||
|
||||
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩),优先 OSS
|
||||
// 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85)
|
||||
func UploadImagePost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的图片"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxImageBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedImageTypes[ct] && !strings.HasPrefix(ct, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 jpg/png/gif/webp 格式"})
|
||||
return
|
||||
}
|
||||
quality := defaultImageQuality
|
||||
if q := c.PostForm("quality"); q != "" {
|
||||
if qn, e := strconv.Atoi(q); e == nil && qn >= 1 && qn <= 100 {
|
||||
quality = qn
|
||||
}
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "images"
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
data, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// JPEG 压缩
|
||||
var finalData []byte
|
||||
finalCt := ct
|
||||
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||||
if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(ct, "png") {
|
||||
if img, err := png.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(ct, "gif") {
|
||||
if img, err := gif.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, img, nil); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
if finalData == nil {
|
||||
finalData = data
|
||||
}
|
||||
|
||||
// 优先 OSS 上传
|
||||
if ossURL, err := ossUploadBytes(finalData, folder, name, finalCt); err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": ossURL,
|
||||
"data": gin.H{"url": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 回退本地存储
|
||||
dir := filepath.Join(uploadDirContent, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(dst, finalData, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"}})
|
||||
}
|
||||
|
||||
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传,优先 OSS
|
||||
// 表单:file(必填), folder(可选,默认 videos)
|
||||
func UploadVideoPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的视频"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxVideoBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过 100MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedVideoTypes[ct] && !strings.HasPrefix(ct, "video/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 mp4/mov/avi 等视频格式"})
|
||||
return
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "videos"
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".mp4"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
|
||||
|
||||
// 优先 OSS 上传
|
||||
if ossCfg := getOssConfig(); ossCfg != nil {
|
||||
src, err := file.Open()
|
||||
if err == nil {
|
||||
defer src.Close()
|
||||
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": ossURL,
|
||||
"data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss"},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退本地存储
|
||||
dir := filepath.Join(uploadDirContent, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": url,
|
||||
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminContentUpload POST /api/admin/content/upload 管理端-内容上传(通过 API 写入内容管理,不直接操作数据库)
|
||||
// 需 AdminAuth。Body: { "action": "import", "data": [ { "id","title","content","price","isFree","partId","partTitle","chapterId","chapterTitle" } ] }
|
||||
func AdminContentUpload(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
if body.Action != "import" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 import"})
|
||||
return
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "data 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
imported, failed := 0, 0
|
||||
for _, item := range body.Data {
|
||||
if item.ID == "" || item.Title == "" {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
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"
|
||||
editionStandard, editionPremium := true, false
|
||||
ch := model.Chapter{
|
||||
ID: item.ID,
|
||||
PartID: strPtrContent(item.PartID, "part-1"),
|
||||
PartTitle: strPtrContent(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtrContent(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtrContent(item.ChapterTitle, "未分类"),
|
||||
SectionTitle: item.Title,
|
||||
Content: item.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
EditionStandard: &editionStandard,
|
||||
EditionPremium: &editionPremium,
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
func randomStrContent(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func strPtrContent(s *string, def string) string {
|
||||
if s != nil && *s != "" {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -166,18 +167,48 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if hasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
// 超级VIP(管理端开通):is_vip=1 且 vip_expire_date>NOW 时,所有文章阅读免费,无需再查订单
|
||||
if user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}})
|
||||
return
|
||||
}
|
||||
if type_ == "fullbook" {
|
||||
// 9.9 买断:永久权益,写入 users.has_full_book;兜底再查订单
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
if type_ == "section" && productId != "" {
|
||||
// 章节:需要区分普通版/增值版
|
||||
var ch model.Chapter
|
||||
// 不加载 content,避免大字段
|
||||
_ = db.Select("id", "is_free", "price", "edition_standard", "edition_premium").Where("id = ?", productId).First(&ch).Error
|
||||
|
||||
// 免费章节:直接可读
|
||||
if ch.ID != "" {
|
||||
if (ch.IsFree != nil && *ch.IsFree) || (ch.Price != nil && *ch.Price == 0) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "free_section"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isPremium := ch.ID != "" && ch.EditionPremium != nil && *ch.EditionPremium
|
||||
// 默认普通版:未明确标记增值版时,按普通版处理
|
||||
isStandard := !isPremium
|
||||
|
||||
// 普通版:买断可读;增值版:买断不包含
|
||||
if isStandard {
|
||||
if user.HasFullBook != nil && *user.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}})
|
||||
@@ -377,8 +408,10 @@ func UserPurchaseStatus(c *gin.Context) {
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
// 9.9 买断:仅表示“普通版买断”,不等同 VIP(增值版仍需 VIP 或单章购买)
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
|
||||
"hasFullBook": hasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
"sectionMidMap": sectionMidMap,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
@@ -419,20 +452,44 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常)
|
||||
func parseDuration(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// UserReadingProgressPost POST /api/user/reading-progress
|
||||
func UserReadingProgressPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
duration := parseDuration(body.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
var existing model.ReadingProgress
|
||||
@@ -442,7 +499,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newDuration := existing.Duration + duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
@@ -469,94 +526,13 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"})
|
||||
}
|
||||
|
||||
// UserDashboardStatsGet GET /api/user/dashboard-stats?userId=
|
||||
// 返回我的页所需的真实统计:已读章节、阅读分钟、最近阅读、匹配次数
|
||||
func UserDashboardStatsGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
var progressList []model.ReadingProgress
|
||||
if err := db.Where("user_id = ?", userId).Order("last_open_at DESC").Find(&progressList).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"})
|
||||
return
|
||||
}
|
||||
|
||||
readCount := len(progressList)
|
||||
totalReadSeconds := 0
|
||||
recentIDs := make([]string, 0, 5)
|
||||
seenRecent := make(map[string]bool)
|
||||
readSectionIDs := make([]string, 0, len(progressList))
|
||||
for _, item := range progressList {
|
||||
totalReadSeconds += item.Duration
|
||||
if item.SectionID != "" {
|
||||
readSectionIDs = append(readSectionIDs, item.SectionID)
|
||||
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
|
||||
seenRecent[item.SectionID] = true
|
||||
recentIDs = append(recentIDs, item.SectionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalReadMinutes := totalReadSeconds / 60
|
||||
if totalReadSeconds > 0 && totalReadMinutes == 0 {
|
||||
totalReadMinutes = 1
|
||||
}
|
||||
|
||||
chapterMap := make(map[string]model.Chapter)
|
||||
if len(recentIDs) > 0 {
|
||||
var chapters []model.Chapter
|
||||
if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil {
|
||||
for _, ch := range chapters {
|
||||
chapterMap[ch.ID] = ch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentChapters := make([]gin.H, 0, len(recentIDs))
|
||||
for _, id := range recentIDs {
|
||||
ch, ok := chapterMap[id]
|
||||
title := id
|
||||
mid := 0
|
||||
if ok {
|
||||
if ch.SectionTitle != "" {
|
||||
title = ch.SectionTitle
|
||||
}
|
||||
mid = ch.MID
|
||||
}
|
||||
recentChapters = append(recentChapters, gin.H{
|
||||
"id": id,
|
||||
"mid": mid,
|
||||
"title": title,
|
||||
})
|
||||
}
|
||||
|
||||
var matchHistory int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ?", userId).Count(&matchHistory)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"readCount": readCount,
|
||||
"totalReadMinutes": totalReadMinutes,
|
||||
"recentChapters": recentChapters,
|
||||
"matchHistory": matchHistory,
|
||||
"readSectionIds": readSectionIDs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM)
|
||||
func UserTrackGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
@@ -644,6 +620,11 @@ func UserTrackPost(c *gin.Context) {
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if body.ExtraData != nil {
|
||||
if raw, err := json.Marshal(body.ExtraData); err == nil {
|
||||
t.ExtraData = raw
|
||||
}
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
@@ -651,6 +632,45 @@ func UserTrackPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
|
||||
}
|
||||
|
||||
// MiniprogramTrackPost POST /api/miniprogram/track 小程序埋点(userId 可选,支持匿名)
|
||||
func MiniprogramTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
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.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
|
||||
return
|
||||
}
|
||||
userId := body.UserID
|
||||
if userId == "" {
|
||||
userId = "anonymous"
|
||||
}
|
||||
db := database.DB()
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
chID := body.Target
|
||||
if body.Action == "view_chapter" && body.Target != "" {
|
||||
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) {
|
||||
var body struct {
|
||||
@@ -688,3 +708,95 @@ func UserUpdate(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
|
||||
}
|
||||
|
||||
// UserDashboardStats GET /api/miniprogram/user/dashboard-stats?userId=
|
||||
// 小程序「我的」页聚合统计:已读章节列表、最近阅读、总阅读时长、匹配历史数
|
||||
func UserDashboardStats(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 1. 拉取该用户所有阅读进度记录,按最近打开时间倒序
|
||||
var progressList []model.ReadingProgress
|
||||
if err := db.Where("user_id = ?", userID).Order("last_open_at DESC").Find(&progressList).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 遍历:统计 readSectionIds / totalReadSeconds,同时去重取最近 5 个不重复章节
|
||||
readCount := len(progressList)
|
||||
totalReadSeconds := 0
|
||||
recentIDs := make([]string, 0, 5)
|
||||
seenRecent := make(map[string]bool)
|
||||
readSectionIDs := make([]string, 0, len(progressList))
|
||||
for _, item := range progressList {
|
||||
totalReadSeconds += item.Duration
|
||||
if item.SectionID != "" {
|
||||
readSectionIDs = append(readSectionIDs, item.SectionID)
|
||||
// 去重:同一章节只保留最近一次
|
||||
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
|
||||
seenRecent[item.SectionID] = true
|
||||
recentIDs = append(recentIDs, item.SectionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
|
||||
totalReadMinutes := totalReadSeconds / 60
|
||||
if totalReadSeconds > 0 && totalReadMinutes == 0 {
|
||||
totalReadMinutes = 1
|
||||
}
|
||||
// 异常数据保护:历史 bug 导致累加错误可能产生超大值, cap 到 99999 分钟(约 69 天)
|
||||
if totalReadMinutes > 99999 {
|
||||
totalReadMinutes = 99999
|
||||
}
|
||||
|
||||
// 3. 批量查 chapters 获取真实标题与 mid
|
||||
chapterMap := make(map[string]model.Chapter)
|
||||
if len(recentIDs) > 0 {
|
||||
var chapters []model.Chapter
|
||||
if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil {
|
||||
for _, ch := range chapters {
|
||||
chapterMap[ch.ID] = ch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按最近阅读顺序组装,标题 fallback 为 section_id
|
||||
recentChapters := make([]gin.H, 0, len(recentIDs))
|
||||
for _, id := range recentIDs {
|
||||
ch, ok := chapterMap[id]
|
||||
title := id
|
||||
mid := 0
|
||||
if ok {
|
||||
if ch.SectionTitle != "" {
|
||||
title = ch.SectionTitle
|
||||
}
|
||||
mid = ch.MID
|
||||
}
|
||||
recentChapters = append(recentChapters, gin.H{
|
||||
"id": id,
|
||||
"mid": mid,
|
||||
"title": title,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 匹配历史数(该用户发起的匹配次数)
|
||||
var matchHistory int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&matchHistory)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"readCount": readCount,
|
||||
"totalReadMinutes": totalReadMinutes,
|
||||
"recentChapters": recentChapters,
|
||||
"matchHistory": matchHistory,
|
||||
"readSectionIds": readSectionIDs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
// isVipFromOrders 从 orders 表判断是否 VIP(兜底)
|
||||
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
|
||||
var order model.Order
|
||||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
userID, "paid", "completed", "fullbook", "vip").
|
||||
// 注意:fullbook=9.9 买断(永久权益),不等同于 VIP(365天)。
|
||||
// VIP 仅认 product_type=vip。
|
||||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND product_type = ?",
|
||||
userID, "paid", "completed", "vip").
|
||||
Order("pay_time DESC").First(&order).Error
|
||||
if err != nil || order.PayTime == nil {
|
||||
return false, nil
|
||||
@@ -97,7 +99,7 @@ func VipStatus(c *gin.Context) {
|
||||
daysRemaining := 0
|
||||
expStr := ""
|
||||
if expireDate != nil {
|
||||
daysRemaining = int(expireDate.Sub(time.Now()).Hours()/24) + 1
|
||||
daysRemaining = int(time.Until(*expireDate).Hours()/24) + 1
|
||||
if daysRemaining < 0 {
|
||||
daysRemaining = 0
|
||||
}
|
||||
@@ -284,12 +286,9 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if name == "" {
|
||||
name = "创业者"
|
||||
}
|
||||
avatar := ""
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
if avatar == "" && u.VipAvatar != nil && *u.VipAvatar != "" {
|
||||
avatar = *u.VipAvatar
|
||||
avatar := getUrlValue(u.Avatar)
|
||||
if avatar == "" {
|
||||
avatar = getUrlValue(u.VipAvatar)
|
||||
}
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
@@ -310,6 +309,10 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if u.VipRole != nil {
|
||||
vipRole = *u.VipRole
|
||||
}
|
||||
vipSort := 0
|
||||
if u.VipSort != nil {
|
||||
vipSort = *u.VipSort
|
||||
}
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
@@ -341,12 +344,14 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
"story_achievement": getStringValue(u.StoryAchievement),
|
||||
"storyTurning": getStringValue(u.StoryTurning),
|
||||
"story_turning": getStringValue(u.StoryTurning),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"help_offer": getStringValue(u.HelpOffer),
|
||||
"helpNeed": getStringValue(u.HelpNeed),
|
||||
"help_need": getStringValue(u.HelpNeed),
|
||||
"projectIntro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"vipSort": vipSort,
|
||||
"vip_sort": vipSort,
|
||||
"is_vip": isVip,
|
||||
}
|
||||
}
|
||||
|
||||
54
soul-api/internal/handler/vip_members_admin.go
Normal file
54
soul-api/internal/handler/vip_members_admin.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
|
||||
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
|
||||
func DBVipMembersList(c *gin.Context) {
|
||||
limit := 200
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 与 VipMembers 一致:优先 users 表(is_vip=1 且 vip_expire_date>NOW),排序使用 vip_sort
|
||||
var users []model.User
|
||||
err := db.Table("users").
|
||||
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
|
||||
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
||||
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
||||
Limit(limit).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil || len(users) == 0 {
|
||||
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
|
||||
var userIDs []string
|
||||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||||
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
||||
Pluck("user_id", &userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
db.Where("id IN ?", userIDs).Find(&users)
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
list = append(list, formatVipMember(&users[i], true))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -133,7 +134,12 @@ func WithdrawPost(c *gin.Context) {
|
||||
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
|
||||
// 参数:userId(默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg)、amount(默认 1)
|
||||
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
|
||||
// 生产环境禁用,避免误用
|
||||
func AdminWithdrawTest(c *gin.Context) {
|
||||
if cfg := config.Get(); cfg != nil && cfg.Mode == "release" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "生产环境禁用"})
|
||||
return
|
||||
}
|
||||
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
|
||||
amountStr := c.DefaultQuery("amount", "1")
|
||||
var amount float64
|
||||
|
||||
30
soul-api/internal/middleware/cron_auth.go
Normal file
30
soul-api/internal/middleware/cron_auth.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CronAuth 定时任务鉴权:校验 X-Cron-Secret 请求头或 ?secret= 参数与 CRON_SECRET 环境变量一致
|
||||
// 若 CRON_SECRET 未配置则直接放行(开发环境兼容)
|
||||
func CronAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
secret := strings.TrimSpace(os.Getenv("CRON_SECRET"))
|
||||
if secret == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
provided := c.GetHeader("X-Cron-Secret")
|
||||
if provided == "" {
|
||||
provided = c.Query("secret")
|
||||
}
|
||||
if provided != secret {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "cron secret 不匹配"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
44
soul-api/internal/model/balance.go
Normal file
44
soul-api/internal/model/balance.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type UserBalance struct {
|
||||
UserID string `gorm:"column:user_id;primaryKey;size:50" json:"userId"`
|
||||
Balance float64 `gorm:"column:balance;type:decimal(10,2);default:0" json:"balance"`
|
||||
TotalRecharged float64 `gorm:"column:total_recharged;type:decimal(10,2);default:0" json:"totalRecharged"`
|
||||
TotalGifted float64 `gorm:"column:total_gifted;type:decimal(10,2);default:0" json:"totalGifted"`
|
||||
TotalRefunded float64 `gorm:"column:total_refunded;type:decimal(10,2);default:0" json:"totalRefunded"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (UserBalance) TableName() string { return "user_balances" }
|
||||
|
||||
type BalanceTransaction struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
|
||||
Type string `gorm:"column:type;size:20" json:"type"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
BalanceAfter float64 `gorm:"column:balance_after;type:decimal(10,2)" json:"balanceAfter"`
|
||||
RelatedOrder *string `gorm:"column:related_order;size:50" json:"relatedOrder,omitempty"`
|
||||
TargetUserID *string `gorm:"column:target_user_id;size:50" json:"targetUserId,omitempty"`
|
||||
SectionID *string `gorm:"column:section_id;size:50" json:"sectionId,omitempty"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (BalanceTransaction) TableName() string { return "balance_transactions" }
|
||||
|
||||
type GiftUnlock struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
GiftCode string `gorm:"column:gift_code;uniqueIndex;size:32" json:"giftCode"`
|
||||
GiverID string `gorm:"column:giver_id;size:50;index" json:"giverId"`
|
||||
SectionID string `gorm:"column:section_id;size:50" json:"sectionId"`
|
||||
ReceiverID *string `gorm:"column:receiver_id;size:50" json:"receiverId,omitempty"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Status string `gorm:"column:status;size:20;default:pending" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
RedeemedAt *time.Time `gorm:"column:redeemed_at" json:"redeemedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (GiftUnlock) TableName() string { return "gift_unlocks" }
|
||||
@@ -19,9 +19,10 @@ type Chapter struct {
|
||||
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
|
||||
IsNew *bool `gorm:"column:is_new" json:"isNew,omitempty"` // stitch_soul:目录/首页「最新新增」标记
|
||||
// 普通版/增值版:两者分开互斥,添加文章时勾选归属
|
||||
HotScoreOverride *float64 `gorm:"column:hot_score_override;type:decimal(10,2)" json:"hotScoreOverride,omitempty"` // 手动覆盖热度分(>0 生效,覆盖算法值)
|
||||
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,omitempty"`
|
||||
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"` // 是否属于普通版
|
||||
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,omitempty"` // 是否属于增值版
|
||||
HotScore float64 `gorm:"column:hot_score;type:decimal(10,2);default:0" json:"hotScore"` // 热度分(加权计算),用于排名算法
|
||||
PreviewPercent *int `gorm:"column:preview_percent" json:"previewPercent,omitempty"` // 章节级预览比例(%),nil 表示使用全局设置
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import "time"
|
||||
|
||||
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
|
||||
type CkbLeadRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
|
||||
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
|
||||
Phone string `gorm:"column:phone;size:20" json:"phone"`
|
||||
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
|
||||
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
|
||||
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
|
||||
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
|
||||
Phone string `gorm:"column:phone;size:20" json:"phone"`
|
||||
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
|
||||
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
|
||||
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
|
||||
Source string `gorm:"column:source;size:50" json:"source"` // 来源:index_lead / article_mention
|
||||
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (CkbLeadRecord) TableName() string { return "ckb_lead_records" }
|
||||
|
||||
22
soul-api/internal/model/gift_pay_request.go
Normal file
22
soul-api/internal/model/gift_pay_request.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GiftPayRequest 代付请求表(美团式:发起人创建,好友支付,权益归发起人)
|
||||
type GiftPayRequest struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
RequestSN string `gorm:"column:request_sn;uniqueIndex;size:32" json:"requestSn"`
|
||||
InitiatorUserID string `gorm:"column:initiator_user_id;size:50;index" json:"initiatorUserId"`
|
||||
ProductType string `gorm:"column:product_type;size:30" json:"productType"`
|
||||
ProductID string `gorm:"column:product_id;size:50" json:"productId"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending / paid / cancelled / expired
|
||||
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
|
||||
OrderID *string `gorm:"column:order_id;size:50" json:"orderId,omitempty"`
|
||||
ExpireAt time.Time `gorm:"column:expire_at" json:"expireAt"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (GiftPayRequest) TableName() string { return "gift_pay_requests" }
|
||||
19
soul-api/internal/model/link_tag.go
Normal file
19
soul-api/internal/model/link_tag.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// LinkTag 链接标签配置(ContentPage 用)
|
||||
type LinkTag struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
Aliases string `gorm:"column:aliases;size:500;default:''" json:"aliases"` // comma-separated alternative labels
|
||||
URL string `gorm:"column:url;size:500" json:"url"`
|
||||
Type string `gorm:"column:type;size:20" json:"type"`
|
||||
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
|
||||
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (LinkTag) TableName() string { return "link_tags" }
|
||||
@@ -17,9 +17,13 @@ type Order struct {
|
||||
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"`
|
||||
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
|
||||
PaymentMethod *string `gorm:"column:payment_method;size:20" json:"paymentMethod,omitempty"`
|
||||
// 代付:关联代付请求、实际付款人
|
||||
GiftPayRequestID *string `gorm:"column:gift_pay_request_id;size:50" json:"giftPayRequestId,omitempty"`
|
||||
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,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" }
|
||||
|
||||
@@ -2,27 +2,34 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Person @提及人物配置(ContentPage 用)
|
||||
// token 为 32 位唯一密钥,文章 @ 时传入 token,小程序点击时用 token 兑换 ckb_api_key
|
||||
// 同时缓存与存客宝 API 获客计划相关的配置,便于管理端回显与二次编辑
|
||||
type Person struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
|
||||
Name string `gorm:"column:name;size:100" json:"name"`
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
|
||||
Token string `gorm:"column:token;size:36;index" json:"token"` // 32 位唯一 token,文章/小程序传此值
|
||||
Name string `gorm:"column:name;size:100" json:"name"`
|
||||
Aliases string `gorm:"column:aliases;size:500;default:''" json:"aliases"` // comma-separated alternative names (马甲)
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露
|
||||
|
||||
// 存客宝计划 ID(用于详情与跳转编辑)
|
||||
CkbPlanID int64 `gorm:"column:ckb_plan_id;default:0" json:"ckbPlanId"`
|
||||
|
||||
// 存客宝 API 获客配置缓存(与 PersonAddEditModal 对应)
|
||||
Greeting string `gorm:"column:greeting;size:255;default:''" json:"greeting"`
|
||||
Tips string `gorm:"column:tips;type:text" json:"tips"`
|
||||
RemarkType string `gorm:"column:remark_type;size:50;default:''" json:"remarkType"`
|
||||
RemarkFormat string `gorm:"column:remark_format;size:200;default:''" json:"remarkFormat"`
|
||||
AddFriendInterval int `gorm:"column:add_friend_interval;default:1" json:"addFriendInterval"`
|
||||
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Person) TableName() string { return "persons" }
|
||||
|
||||
type LinkTag struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
URL string `gorm:"column:url;size:500" json:"url"`
|
||||
Type string `gorm:"column:type;size:20" json:"type"`
|
||||
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
|
||||
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (LinkTag) TableName() string { return "link_tags" }
|
||||
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
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"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
// P3 资料扩展(stitch_soul)
|
||||
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
|
||||
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
|
||||
@@ -36,6 +37,8 @@ type User struct {
|
||||
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
|
||||
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
|
||||
|
||||
// 用户标签(管理端编辑、神射手回填共用 ckb_tags 列,JSON 数组字符串)
|
||||
CkbTags *string `gorm:"column:ckb_tags;type:text" json:"tags,omitempty"`
|
||||
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写)
|
||||
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
|
||||
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
|
||||
@@ -49,7 +52,8 @@ type User struct {
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
|
||||
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
|
||||
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
|
||||
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
|
||||
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
124
soul-api/internal/oss/oss.go
Normal file
124
soul-api/internal/oss/oss.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
alioss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
)
|
||||
|
||||
// Config 阿里云 OSS 配置,与管理端 ossConfig 字段对应
|
||||
type Config struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
}
|
||||
|
||||
// LoadConfig 从 system_config 读取 oss_config,配置不完整时返回 nil
|
||||
func LoadConfig() *Config {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg Config
|
||||
if v, ok := m["endpoint"].(string); ok && v != "" {
|
||||
cfg.Endpoint = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["bucket"].(string); ok && v != "" {
|
||||
cfg.Bucket = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["accessKeyId"].(string); ok && v != "" {
|
||||
cfg.AccessKeyID = v
|
||||
}
|
||||
if v, ok := m["accessKeySecret"].(string); ok && v != "" {
|
||||
cfg.AccessKeySecret = v
|
||||
}
|
||||
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||||
return nil
|
||||
}
|
||||
// endpoint 去掉 scheme,SDK 需要
|
||||
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// IsEnabled 是否已配置 OSS 且可用
|
||||
func IsEnabled() bool {
|
||||
return LoadConfig() != nil
|
||||
}
|
||||
|
||||
// Upload 上传文件到 OSS,objectKey 如 "uploads/avatars/xxx.jpg"
|
||||
// 返回公网访问 URL,如 https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/avatars/xxx.jpg
|
||||
func Upload(objectKey string, reader io.Reader, options ...alioss.Option) (string, error) {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return "", nil // 未配置,调用方需回退本地
|
||||
}
|
||||
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
log.Printf("oss: client init failed: %v", err)
|
||||
return "", err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
log.Printf("oss: bucket %s failed: %v", cfg.Bucket, err)
|
||||
return "", err
|
||||
}
|
||||
if err := bucket.PutObject(objectKey, reader, options...); err != nil {
|
||||
log.Printf("oss: PutObject %s failed: %v", objectKey, err)
|
||||
return "", err
|
||||
}
|
||||
// 公网 URL:https://{bucket}.{endpoint}/{objectKey}
|
||||
u := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/" + objectKey
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Delete 从 OSS 删除对象,objectKey 如 "uploads/avatars/xxx.jpg"
|
||||
func Delete(objectKey string) error {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.DeleteObject(objectKey)
|
||||
}
|
||||
|
||||
// ParseObjectKeyFromURL 从 OSS 公网 URL 解析出 objectKey
|
||||
// 格式: https://bucket.oss-cn-xxx.aliyuncs.com/uploads/avatars/xxx.jpg
|
||||
func ParseObjectKeyFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
return path
|
||||
}
|
||||
|
||||
// IsOSSURL 判断是否为 OSS 公网 URL(用于删除时区分本地/OSS)
|
||||
func IsOSSURL(rawURL string) bool {
|
||||
cfg := LoadConfig()
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
// 格式: https://{bucket}.{endpoint}/...
|
||||
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
|
||||
return strings.HasPrefix(rawURL, prefix)
|
||||
}
|
||||
41
soul-api/internal/redis/redis.go
Normal file
41
soul-api/internal/redis/redis.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var client *redis.Client
|
||||
|
||||
// Init 初始化 Redis 客户端,url 为空或 "disable" 时跳过
|
||||
func Init(url string) error {
|
||||
if url == "" || url == "disable" {
|
||||
return nil
|
||||
}
|
||||
opt, err := redis.ParseURL(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client = redis.NewClient(opt)
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("redis: connected to %s", opt.Addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client 返回 Redis 客户端,未初始化时返回 nil
|
||||
func Client() *redis.Client {
|
||||
return client
|
||||
}
|
||||
|
||||
// Close 关闭连接(优雅退出时调用)
|
||||
func Close() error {
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/handler"
|
||||
"soul-api/internal/middleware"
|
||||
"soul-api/internal/redis"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,7 +32,11 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
rateLimiter := middleware.NewRateLimiter(100, 200)
|
||||
r.Use(rateLimiter.Middleware())
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
uploadDir := cfg.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./uploads"
|
||||
}
|
||||
r.Static("/uploads", uploadDir)
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -48,8 +56,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.POST("/content", handler.AdminContent)
|
||||
admin.PUT("/content", handler.AdminContent)
|
||||
admin.DELETE("/content", handler.AdminContent)
|
||||
admin.GET("/dashboard/stats", handler.AdminDashboardStats)
|
||||
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
|
||||
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
|
||||
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
|
||||
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
|
||||
admin.GET("/dashboard/merchant-balance", handler.AdminDashboardMerchantBalance)
|
||||
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
|
||||
admin.GET("/payment", handler.AdminPayment)
|
||||
admin.POST("/payment", handler.AdminPayment)
|
||||
admin.PUT("/payment", handler.AdminPayment)
|
||||
@@ -65,34 +77,44 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
|
||||
admin.GET("/settings", handler.AdminSettingsGet)
|
||||
admin.POST("/settings", handler.AdminSettingsPost)
|
||||
admin.GET("/linked-miniprograms", handler.AdminLinkedMpList)
|
||||
admin.POST("/linked-miniprograms", handler.AdminLinkedMpCreate)
|
||||
admin.PUT("/linked-miniprograms", handler.AdminLinkedMpUpdate)
|
||||
admin.DELETE("/linked-miniprograms/:id", handler.AdminLinkedMpDelete)
|
||||
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
|
||||
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
|
||||
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
|
||||
admin.GET("/ckb/devices", handler.AdminCKBDevices)
|
||||
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
|
||||
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
|
||||
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
|
||||
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
|
||||
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
|
||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
|
||||
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
|
||||
admin.GET("/users", handler.AdminUsersList)
|
||||
admin.POST("/users", handler.AdminUsersAction)
|
||||
admin.PUT("/users", handler.AdminUsersAction)
|
||||
admin.DELETE("/users", handler.AdminUsersAction)
|
||||
// 神射手 / 用户资料完善
|
||||
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
|
||||
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
|
||||
admin.POST("/shensheshou/batch", handler.AdminShensheShouBatchQuery)
|
||||
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
|
||||
admin.GET("/orders", handler.OrdersList)
|
||||
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
|
||||
admin.GET("/user/track", handler.UserTrackGet)
|
||||
admin.GET("/track/stats", handler.AdminTrackStats)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
api.POST("/auth/login", handler.AuthLogin)
|
||||
api.POST("/auth/reset-password", handler.AuthResetPassword)
|
||||
|
||||
// ----- 书籍/章节 -----
|
||||
// ----- 书籍/章节(只读,写操作由 /api/db/book 管理端路由承担) -----
|
||||
api.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
api.GET("/book/parts", handler.BookParts)
|
||||
api.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
api.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
api.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
api.GET("/book/chapters", handler.BookChapters)
|
||||
api.POST("/book/chapters", handler.BookChapters)
|
||||
api.PUT("/book/chapters", handler.BookChapters)
|
||||
api.DELETE("/book/chapters", handler.BookChapters)
|
||||
// POST/PUT/DELETE /api/book/chapters 已移除:写操作须由管理端 /api/db/book(AdminAuth)完成
|
||||
api.GET("/book/hot", handler.BookHot)
|
||||
api.GET("/book/recommended", handler.BookRecommended)
|
||||
api.GET("/book/latest-chapters", handler.BookLatestChapters)
|
||||
@@ -115,11 +137,15 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
// ----- 内容 -----
|
||||
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)
|
||||
// ----- 定时任务(须携带 X-Cron-Secret 请求头,与 .env CRON_SECRET 一致) -----
|
||||
cron := api.Group("/cron")
|
||||
cron.Use(middleware.CronAuth())
|
||||
{
|
||||
cron.GET("/sync-orders", handler.CronSyncOrders)
|
||||
cron.POST("/sync-orders", handler.CronSyncOrders)
|
||||
cron.GET("/unbind-expired", handler.CronUnbindExpired)
|
||||
cron.POST("/unbind-expired", handler.CronUnbindExpired)
|
||||
}
|
||||
|
||||
// ----- 数据库(管理端) -----
|
||||
db := api.Group("/db")
|
||||
@@ -144,36 +170,33 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.PUT("/users", handler.DBUsersAction)
|
||||
db.DELETE("/users", handler.DBUsersDelete)
|
||||
db.GET("/users/referrals", handler.DBUsersReferrals)
|
||||
db.GET("/users/rfm", handler.DBUsersRFM)
|
||||
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
|
||||
db.GET("/vip-roles", handler.DBVipRolesList)
|
||||
db.POST("/vip-roles", handler.DBVipRolesAction)
|
||||
db.PUT("/vip-roles", handler.DBVipRolesAction)
|
||||
db.DELETE("/vip-roles", handler.DBVipRolesAction)
|
||||
db.GET("/vip-members", handler.DBVipMembersList)
|
||||
db.GET("/match-records", handler.DBMatchRecordsList)
|
||||
db.POST("/match-records/test", handler.DBMatchRecordInsertTest)
|
||||
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
|
||||
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
|
||||
db.GET("/ckb-leads", handler.DBCKBLeadList)
|
||||
db.GET("/persons", handler.DBPersonList)
|
||||
db.POST("/persons", handler.DBPersonSave)
|
||||
db.DELETE("/persons", handler.DBPersonDelete)
|
||||
db.GET("/link-tags", handler.DBLinkTagList)
|
||||
db.POST("/link-tags", handler.DBLinkTagSave)
|
||||
db.DELETE("/link-tags", handler.DBLinkTagDelete)
|
||||
db.GET("/mentors", handler.DBMentorsList)
|
||||
db.POST("/mentors", handler.DBMentorsAction)
|
||||
db.PUT("/mentors", handler.DBMentorsAction)
|
||||
db.DELETE("/mentors", handler.DBMentorsAction)
|
||||
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
|
||||
// 用户旅程规则
|
||||
db.GET("/persons", handler.DBPersonList)
|
||||
db.GET("/person", handler.DBPersonDetail)
|
||||
db.POST("/persons", handler.DBPersonSave)
|
||||
db.DELETE("/persons", handler.DBPersonDelete)
|
||||
db.GET("/link-tags", handler.DBLinkTagList)
|
||||
db.POST("/link-tags", handler.DBLinkTagSave)
|
||||
db.DELETE("/link-tags", handler.DBLinkTagDelete)
|
||||
db.GET("/ckb-leads", handler.DBCKBLeadList)
|
||||
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
|
||||
db.GET("/user-rules", handler.DBUserRulesList)
|
||||
db.POST("/user-rules", handler.DBUserRulesAction)
|
||||
db.PUT("/user-rules", handler.DBUserRulesAction)
|
||||
db.DELETE("/user-rules", handler.DBUserRulesAction)
|
||||
// RFM 估值
|
||||
db.GET("/users/rfm", handler.DBUsersRFM)
|
||||
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
|
||||
// 用户旅程总览统计
|
||||
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
|
||||
}
|
||||
|
||||
// ----- 分销 -----
|
||||
@@ -197,8 +220,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
// ----- 菜单 -----
|
||||
api.GET("/menu", handler.MenuGet)
|
||||
|
||||
// ----- 订单 -----
|
||||
api.GET("/orders", handler.OrdersList)
|
||||
// /api/orders 已移入 admin 组(需鉴权),见下方
|
||||
|
||||
// ----- 支付 -----
|
||||
api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
|
||||
@@ -255,6 +277,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/config", handler.GetPublicDBConfig)
|
||||
miniprogram.POST("/login", handler.MiniprogramLogin)
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
|
||||
miniprogram.POST("/phone", handler.MiniprogramPhone)
|
||||
miniprogram.GET("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay", handler.MiniprogramPay)
|
||||
@@ -262,6 +285,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
|
||||
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
|
||||
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
miniprogram.GET("/book/parts", handler.BookParts)
|
||||
miniprogram.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
miniprogram.GET("/book/hot", handler.BookHot)
|
||||
@@ -278,6 +303,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
miniprogram.POST("/ckb/match", handler.CKBMatch)
|
||||
miniprogram.POST("/ckb/lead", handler.CKBLead)
|
||||
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
miniprogram.DELETE("/upload", handler.UploadDelete)
|
||||
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
|
||||
@@ -286,10 +312,10 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
|
||||
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStats)
|
||||
miniprogram.GET("/user/profile", handler.UserProfileGet)
|
||||
miniprogram.POST("/user/profile", handler.UserProfilePost)
|
||||
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
|
||||
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStatsGet)
|
||||
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
|
||||
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
|
||||
miniprogram.POST("/user/update", handler.UserUpdate)
|
||||
@@ -311,6 +337,25 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
|
||||
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
|
||||
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
|
||||
// 埋点
|
||||
miniprogram.POST("/track", handler.MiniprogramTrackPost)
|
||||
// 规则引擎(用户旅程引导)
|
||||
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
|
||||
// 余额
|
||||
miniprogram.GET("/balance", handler.BalanceGet)
|
||||
miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet)
|
||||
miniprogram.POST("/balance/recharge", handler.BalanceRechargePost)
|
||||
miniprogram.POST("/balance/recharge/confirm", handler.BalanceRechargeConfirmPost)
|
||||
miniprogram.POST("/balance/refund", handler.BalanceRefundPost)
|
||||
miniprogram.POST("/balance/consume", handler.BalanceConsumePost)
|
||||
miniprogram.GET("/gift/link", handler.GiftLinkGet)
|
||||
// 代付(美团式:代付页面)
|
||||
miniprogram.POST("/gift-pay/create", handler.GiftPayCreate)
|
||||
miniprogram.GET("/gift-pay/detail", handler.GiftPayDetail)
|
||||
miniprogram.POST("/gift-pay/pay", handler.GiftPayPay)
|
||||
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
|
||||
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
|
||||
miniprogram.GET("/gift-pay/my-payments", handler.GiftPayMyPayments)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
@@ -332,11 +377,29 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
c.Status(204)
|
||||
})
|
||||
|
||||
// 健康检查:返回状态与版本号(版本号从 .env 的 APP_VERSION 读取,打包/上传前写入)
|
||||
// 健康检查:返回状态、版本号、数据库与 Redis 连接状态
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
dbStatus := "ok"
|
||||
if sqlDB, err := database.DB().DB(); err != nil {
|
||||
dbStatus = "error"
|
||||
} else if err := sqlDB.Ping(); err != nil {
|
||||
dbStatus = "disconnected"
|
||||
}
|
||||
|
||||
redisStatus := "disabled"
|
||||
if redis.Client() != nil {
|
||||
if err := redis.Client().Ping(context.Background()).Err(); err != nil {
|
||||
redisStatus = "disconnected"
|
||||
} else {
|
||||
redisStatus = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
"database": dbStatus,
|
||||
"redis": redisStatus,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
56
soul-api/internal/wechat/balance.go
Normal file
56
soul-api/internal/wechat/balance.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/wechat/transferv3"
|
||||
)
|
||||
|
||||
// MerchantBalance 商户余额(微信返回,单位:分)
|
||||
type MerchantBalance struct {
|
||||
AvailableAmount int64 `json:"available_amount"` // 可用余额(分)
|
||||
PendingAmount int64 `json:"pending_amount"` // 不可用/待结算余额(分)
|
||||
}
|
||||
|
||||
// QueryMerchantBalance 查询商户平台账户实时余额(API: GET /v3/merchant/fund/balance/{account_type})
|
||||
// accountType: BASIC(基本户) | OPERATION(运营账户) | FEES(手续费账户),默认 BASIC
|
||||
// 注意:普通商户可能需向微信申请开通权限,403 NO_AUTH 时表示未开通
|
||||
func QueryMerchantBalance(accountType string) (*MerchantBalance, error) {
|
||||
if accountType == "" {
|
||||
accountType = "BASIC"
|
||||
}
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("配置未加载")
|
||||
}
|
||||
key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载商户私钥失败: %w", err)
|
||||
}
|
||||
client := transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key)
|
||||
data, status, err := client.GetMerchantBalance(accountType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
if status != 200 {
|
||||
// 403 NO_AUTH 时返回友好提示,便于管理端展示
|
||||
if status == 403 {
|
||||
var errResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &errResp)
|
||||
if errResp.Code == "NO_AUTH" {
|
||||
return nil, fmt.Errorf("NO_AUTH: 当前商户号未开通余额查询权限,请登录微信商户平台联系客服申请")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("微信返回 %d: %s", status, string(data))
|
||||
}
|
||||
var bal MerchantBalance
|
||||
if err := json.Unmarshal(data, &bal); err != nil {
|
||||
return nil, fmt.Errorf("解析余额响应失败: %w", err)
|
||||
}
|
||||
return &bal, nil
|
||||
}
|
||||
@@ -118,3 +118,11 @@ func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int,
|
||||
"/details/detail-id/" + url.PathEscape(outDetailNo)
|
||||
return c.do("GET", path, "")
|
||||
}
|
||||
|
||||
// GetMerchantBalance 查询商户平台账户实时余额(文档:GET /v3/merchant/fund/balance/{account_type})
|
||||
// accountType: BASIC(基本户) | OPERATION(运营账户) | FEES(手续费账户)
|
||||
// 注意:普通商户可能需向微信申请开通权限,403 NO_AUTH 表示未开通
|
||||
func (c *Client) GetMerchantBalance(accountType string) ([]byte, int, error) {
|
||||
path := "/v3/merchant/fund/balance/" + url.PathEscape(accountType)
|
||||
return c.do("GET", path, "")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
|
||||
- 打包使用 .env.development 作为服务器 .env
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
|
||||
- 打包使用 .env.production 作为服务器 .env
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/self/soul-dev
|
||||
- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
- 上传到 /www/wwwroot/self/soul-api
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ except ImportError:
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_cfg():
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"bt_panel_url": bt_url,
|
||||
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
|
||||
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def run_build(root):
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
DEPLOY_PORT = 8081
|
||||
DEPLOY_PORT = 8080
|
||||
|
||||
|
||||
def set_env_port(env_path, port=DEPLOY_PORT):
|
||||
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
env_src = os.path.join(root, ".env.development")
|
||||
env_src = os.path.join(root, ".env.production")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env.development -> .env")
|
||||
print(" [已包含] .env.production -> .env")
|
||||
else:
|
||||
env_example = os.path.join(root, ".env.example")
|
||||
if os.path.isfile(env_example):
|
||||
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
if os.path.isfile(staging_env):
|
||||
set_env_port(staging_env, DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "developer")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT)
|
||||
set_env_mini_program_state(staging_env, "formal")
|
||||
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT)
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
|
||||
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)"""
|
||||
url = cfg.get("bt_panel_url") or ""
|
||||
key = cfg.get("bt_api_key") or ""
|
||||
name = cfg.get("bt_go_project_name", "soulDev")
|
||||
name = cfg.get("bt_go_project_name", "soulApi")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
@@ -255,32 +255,57 @@ def restart_via_bt_api(cfg):
|
||||
# ==================== SSH 上传 ====================
|
||||
|
||||
|
||||
def _connect_ssh(cfg):
|
||||
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
transport = client.get_transport()
|
||||
if transport:
|
||||
transport.set_keepalive(15)
|
||||
return client
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
|
||||
"""上传 tar.gz 到服务器并解压、重启"""
|
||||
print("[3/4] SSH 上传并解压 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
client = None
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], key_filename=cfg["ssh_key"],
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
client.connect(
|
||||
cfg["host"], port=DEFAULT_SSH_PORT,
|
||||
username=cfg["user"], password=cfg["password"],
|
||||
timeout=15,
|
||||
)
|
||||
sftp = client.open_sftp()
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
# SFTP 上传易因网络抖动 EOF,失败时重连并重试最多 3 次
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
client = _connect_ssh(cfg)
|
||||
sftp = client.open_sftp()
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
break
|
||||
except (EOFError, ConnectionResetError, OSError) as e:
|
||||
if attempt < 3:
|
||||
print(" [重试 %d/3] 上传中断: %s,5 秒后重连 ..." % (attempt, e))
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise
|
||||
|
||||
cmd = (
|
||||
"mkdir -p %s && cd %s && tar -xzf %s && "
|
||||
@@ -295,7 +320,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [成功] 已解压到: %s" % project_path)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soulDev 服务 ...")
|
||||
print("[4/4] 重启 soulApp 服务 ...")
|
||||
ok = False
|
||||
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
|
||||
ok = restart_via_bt_api(cfg)
|
||||
@@ -315,7 +340,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulDev 已通过 SSH 重启")
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
@@ -323,10 +348,17 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
err_msg = str(e) or repr(e) or type(e).__name__
|
||||
print(" [失败] SSH 错误:", err_msg)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
@@ -334,7 +366,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
@@ -353,7 +385,7 @@ def main():
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-api 部署到宝塔,重启 soulDev")
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
62
soul-api/scripts/README-migrate-base64-images.md
Normal file
62
soul-api/scripts/README-migrate-base64-images.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 文章 base64 图片迁移脚本
|
||||
|
||||
将 `chapters` 表中 `content` 字段内嵌的 base64 图片提取为独立文件,并替换为 `/uploads/book-images/xxx` 的 URL,减小文章体积。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 历史文章中有大量粘贴的 base64 图片
|
||||
- 保存时因 content 过大导致超时或失败
|
||||
- 需要将 base64 转为文件存储
|
||||
|
||||
## 执行方式
|
||||
|
||||
### 1. 测试环境(建议先执行)
|
||||
|
||||
```bash
|
||||
cd soul-api
|
||||
|
||||
# 加载测试环境配置(.env.development)
|
||||
$env:APP_ENV="development"
|
||||
|
||||
# 先 dry-run 预览,不写入
|
||||
go run ./cmd/migrate-base64-images --dry-run
|
||||
|
||||
# 确认无误后正式执行
|
||||
go run ./cmd/migrate-base64-images
|
||||
```
|
||||
|
||||
### 2. 生产环境
|
||||
|
||||
```bash
|
||||
cd soul-api
|
||||
$env:APP_ENV="production"
|
||||
go run ./cmd/migrate-base64-images --dry-run # 先预览
|
||||
go run ./cmd/migrate-base64-images # 正式执行
|
||||
```
|
||||
|
||||
### 3. 指定 DSN(覆盖 .env)
|
||||
|
||||
```bash
|
||||
$env:DB_DSN="user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True"
|
||||
go run ./cmd/migrate-base64-images --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--dry-run` | 仅统计和预览,不写入文件与数据库 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
1. 查询 `content LIKE '%data:image%'` 的章节
|
||||
2. 用正则提取 `src="data:image/xxx;base64,..."` 或 `src='...'`
|
||||
3. 解码 base64,保存到 `uploads/book-images/{timestamp}_{random}.{ext}`
|
||||
4. 将 content 中的 base64 src 替换为 `/uploads/book-images/xxx`
|
||||
5. 更新数据库
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **务必先在测试环境验证**,确认无误后再跑生产
|
||||
- 脚本依赖 `UPLOAD_DIR` 或默认 `uploads` 目录
|
||||
- 图片格式支持:png、jpeg、jpg、gif、webp
|
||||
96
soul-api/scripts/README-schema-sync.md
Normal file
96
soul-api/scripts/README-schema-sync.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 数据库与 Go Model 字段对照检查报告
|
||||
|
||||
> 后端工程师对照 `soul-api/internal/model` 与 `soul_miniprogram.sql` 建表结构,列出**数据库表里可能缺失、但代码里在用**的字段。
|
||||
> 若当前库是由旧版 SQL 导入或从未执行过迁移脚本,按本报告执行 `sync-users-vip-and-schema.sql` 或依赖 AutoMigrate 即可补全。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
| 表名 | 是否缺字段 | 缺失字段(Model 有、SQL 无) | 影响 |
|
||||
|------|------------|-----------------------------|------|
|
||||
| **users** | 是(旧库可能缺) | is_vip, vip_expire_date, vip_activated_at, vip_sort, vip_role | 订单列表、用户列表、VIP 设置、提现、匹配记录等接口报错(不含 vip_name/vip_avatar 等,小程序已改为直接读用户资料) |
|
||||
| **chapters** | 是(SQL 导出无此列) | hot_score | 文章排名、热门章节等依赖热度分的接口报错 |
|
||||
| 其他业务表 | 否 | - | 与当前 SQL 一致 |
|
||||
|
||||
---
|
||||
|
||||
## 二、users 表
|
||||
|
||||
- **Model 文件**:`internal/model/user.go`
|
||||
- **SQL 表**:`soul_miniprogram.sql` 中 `CREATE TABLE users` 已包含 VIP 相关列;若你的库是**更早的备份**或**未导入最新 SQL**,可能缺少以下列。
|
||||
|
||||
| 列名(蛇形) | 类型 | 说明 |
|
||||
|-------------|------|------|
|
||||
| is_vip | TINYINT(1) NULL DEFAULT 0 | 是否 VIP |
|
||||
| vip_expire_date | DATETIME(3) NULL | VIP 到期时间 |
|
||||
| vip_activated_at | DATETIME(3) NULL | 成为 VIP 时间,排序用 |
|
||||
| vip_sort | INT NULL | 手动排序,越小越前 |
|
||||
| vip_role | VARCHAR(50) NULL | 角色:从 vip_roles 选或手动填写 |
|
||||
|
||||
(vip_name、vip_avatar、vip_project、vip_contact、vip_bio 不再在迁移中新增,小程序已改为直接读用户资料 nickname/avatar/projectIntro/phone 等;已有库可保留该五列作兼容。)
|
||||
|
||||
**修复**:执行 `scripts/sync-users-vip-and-schema.sql` 中 users 部分,或重启 soul-api(未设 `SKIP_AUTO_MIGRATE` 时 AutoMigrate 会补列)。
|
||||
|
||||
---
|
||||
|
||||
## 三、chapters 表
|
||||
|
||||
- **Model 文件**:`internal/model/chapter.go`
|
||||
- **SQL 表**:当前 `soul_miniprogram.sql` 中 `chapters` 仅有 `hot_score_override`(decimal),**没有** `hot_score`(int)。
|
||||
Model 使用的是 `hot_score`(热度分,用于排名算法),因此仅按该 SQL 建表时,数据库**缺少** `hot_score`。
|
||||
|
||||
| 列名(蛇形) | 类型 | 说明 |
|
||||
|-------------|------|------|
|
||||
| hot_score | INT NOT NULL DEFAULT 0 | 热度分,用于排名算法 |
|
||||
|
||||
**修复**:执行 `scripts/sync-users-vip-and-schema.sql` 中 chapters 部分,或执行 `scripts/add-hot-score.sql`,或依赖 soul-api 启动时对 Chapter 的 AutoMigrate。
|
||||
|
||||
---
|
||||
|
||||
## 四、已核对无缺列的表
|
||||
|
||||
以下表在 `soul_miniprogram.sql` 中的列与对应 Model 一致,**无需补列**(仅列名与类型一致即可,顺序可不同):
|
||||
|
||||
- **orders**:与 `model.Order` 一致
|
||||
- **withdrawals**:与 `model.Withdrawal` 一致(库中多出的 transaction_id、error_message 不影响)
|
||||
- **admin_users**:与 `model.AdminUser` 一致
|
||||
- **system_config**:与 `model.SystemConfig` 一致
|
||||
- **referral_bindings**:Model 字段在表中均存在
|
||||
- **referral_visits**:与 `model.ReferralVisit` 一致
|
||||
- **user_rules**:与 `model.UserRule` 一致
|
||||
- **user_tracks**:与 `model.UserTrack` 一致
|
||||
- **reading_progress**:与 `model.ReadingProgress` 一致(表为 section_id,Model 为 section_id)
|
||||
- **match_records**:与 `model.MatchRecord` 一致
|
||||
- **mentor_consultations**:与 `model.MentorConsultation` 一致
|
||||
- **mentors**:与 `model.Mentor` 一致
|
||||
- **link_tags**:与 `model.LinkTag` 一致
|
||||
- **persons**:与 `model.Person` 一致
|
||||
- **author_config**:与 `model.AuthorConfig` 一致
|
||||
- **ckb_lead_records**:与 `model.CkbLeadRecord` 一致
|
||||
- **ckb_submit_records**:与 `model.CkbSubmitRecord` 一致
|
||||
- **user_addresses**:与 `model.UserAddress` 一致
|
||||
- **vip_roles**:与 `model.VipRole` 一致
|
||||
- **wechat_callback_logs**:与 `model.WechatCallbackLog` 一致
|
||||
|
||||
---
|
||||
|
||||
## 五、推荐操作
|
||||
|
||||
1. **一次性补全(推荐)**
|
||||
在备份后执行:
|
||||
```bash
|
||||
mysql -u 用户 -p 数据库名 < soul-api/scripts/sync-users-vip-and-schema.sql
|
||||
```
|
||||
若某条报 `Duplicate column name`,表示该列已存在,可跳过。
|
||||
|
||||
2. **依赖 AutoMigrate**
|
||||
确保 soul-api 的 `database.Init` 中已对 `User`、`SystemConfig`、`Chapter` 执行 `AutoMigrate`,且未设置 `SKIP_AUTO_MIGRATE`,重启服务后会自动补全缺失列。
|
||||
|
||||
3. **新建库**
|
||||
若从零建库,建议用**最新**的 `soul_miniprogram.sql` 导入后,再执行一次 `sync-users-vip-and-schema.sql`,确保 users 与 chapters 与 Model 完全一致。
|
||||
|
||||
---
|
||||
|
||||
**检查日期**:按代码与 SQL 导出时点生成
|
||||
**检查范围**:soul-api 全部 `internal/model` 与 soul_miniprogram.sql 中对应表结构
|
||||
28
soul-api/scripts/add-balance-tables.sql
Normal file
28
soul-api/scripts/add-balance-tables.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- 余额相关表(新版迁移)
|
||||
-- 执行:mysql -u user -p database < soul-api/scripts/add-balance-tables.sql
|
||||
|
||||
-- user_balances
|
||||
CREATE TABLE IF NOT EXISTS user_balances (
|
||||
user_id VARCHAR(50) PRIMARY KEY,
|
||||
balance DECIMAL(10,2) DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- balance_transactions
|
||||
CREATE TABLE IF NOT EXISTS balance_transactions (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
order_id VARCHAR(50) DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_created (user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- orders 增加 payment_method
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE table_schema = DATABASE() AND table_name = 'orders' AND column_name = 'payment_method');
|
||||
SET @sql = IF(@col_exists = 0, 'ALTER TABLE orders ADD COLUMN payment_method VARCHAR(20) DEFAULT NULL AFTER referrer_id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
5
soul-api/scripts/add-chapters-index-for-all-chapters.sql
Normal file
5
soul-api/scripts/add-chapters-index-for-all-chapters.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 为 all-chapters 接口加速:sort_order + id 排序索引
|
||||
-- 执行:node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-chapters-index-for-all-chapters.sql
|
||||
-- 若索引已存在会报错,可忽略
|
||||
|
||||
CREATE INDEX idx_chapters_sort_id ON chapters(sort_order, id);
|
||||
4
soul-api/scripts/add-chapters-preview-percent.sql
Normal file
4
soul-api/scripts/add-chapters-preview-percent.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 为 chapters 表添加 preview_percent 列(章节级预览比例,NULL 表示使用全局 unpaid_preview_percent)
|
||||
-- 执行: mysql -u user -p db < soul-api/scripts/add-chapters-preview-percent.sql
|
||||
|
||||
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS preview_percent INT NULL COMMENT '章节级预览比例(%),NULL 表示使用全局设置' AFTER hot_score;
|
||||
2
soul-api/scripts/add-ckb-plan-id-only.sql
Normal file
2
soul-api/scripts/add-ckb-plan-id-only.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 仅添加 ckb_plan_id(若 add-persons-ckb-fields.sql 已部分执行或需单独补列)
|
||||
ALTER TABLE `persons` ADD COLUMN `ckb_plan_id` BIGINT NOT NULL DEFAULT 0 COMMENT '存客宝获客计划ID';
|
||||
25
soul-api/scripts/add-gift-pay-requests.sql
Normal file
25
soul-api/scripts/add-gift-pay-requests.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 代付请求表 + 订单表代付字段
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-gift-pay-requests.sql
|
||||
-- 注:orders 表新增字段由 GORM AutoMigrate 自动添加;若需手动执行:
|
||||
-- ALTER TABLE orders ADD COLUMN gift_pay_request_id VARCHAR(50) DEFAULT NULL;
|
||||
-- ALTER TABLE orders ADD COLUMN payer_user_id VARCHAR(50) DEFAULT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gift_pay_requests (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
request_sn VARCHAR(32) NOT NULL UNIQUE,
|
||||
initiator_user_id VARCHAR(50) NOT NULL,
|
||||
product_type VARCHAR(30) NOT NULL,
|
||||
product_id VARCHAR(50) NOT NULL DEFAULT '',
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
description VARCHAR(200) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
payer_user_id VARCHAR(50) DEFAULT NULL,
|
||||
order_id VARCHAR(50) DEFAULT NULL,
|
||||
expire_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_initiator (initiator_user_id),
|
||||
INDEX idx_payer (payer_user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_request_sn (request_sn)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
2
soul-api/scripts/add-hot-score.sql
Normal file
2
soul-api/scripts/add-hot-score.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 为 chapters 表新增 hot_score 字段(热度分,用于排名算法)
|
||||
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;
|
||||
6
soul-api/scripts/add-persons-ckb-api-key.sql
Normal file
6
soul-api/scripts/add-persons-ckb-api-key.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- persons 表新增 ckb_api_key 字段
|
||||
-- 作用:存储该 @人物 在存客宝的接入密钥,点击加好友时用该 Key 推线索;留空则 fallback 全局 CKB_LEAD_API_KEY
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-persons-ckb-api-key.sql
|
||||
|
||||
ALTER TABLE persons
|
||||
ADD COLUMN ckb_api_key VARCHAR(100) NOT NULL DEFAULT '' AFTER label;
|
||||
12
soul-api/scripts/add-persons-ckb-fields.sql
Normal file
12
soul-api/scripts/add-persons-ckb-fields.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 为 persons 表增加存客宝 API 获客相关字段,便于管理端回显与二次编辑
|
||||
ALTER TABLE `persons`
|
||||
ADD COLUMN `greeting` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '存客宝打招呼语' AFTER `ckb_api_key`,
|
||||
ADD COLUMN `tips` TEXT NULL COMMENT '获客成功提示' AFTER `greeting`,
|
||||
ADD COLUMN `remark_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '备注类型phone/nickname/source' AFTER `tips`,
|
||||
ADD COLUMN `remark_format` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '备注格式' AFTER `remark_type`,
|
||||
ADD COLUMN `add_friend_interval` INT NOT NULL DEFAULT 1 COMMENT '添加好友间隔(分钟)' AFTER `remark_format`,
|
||||
ADD COLUMN `start_time` VARCHAR(10) NOT NULL DEFAULT '09:00' COMMENT '允许加人开始时间 HH:MM' AFTER `add_friend_interval`,
|
||||
ADD COLUMN `end_time` VARCHAR(10) NOT NULL DEFAULT '18:00' COMMENT '允许加人结束时间 HH:MM' AFTER `start_time`,
|
||||
ADD COLUMN `device_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '参与计划的设备ID列表,逗号分隔' AFTER `end_time`,
|
||||
ADD COLUMN `ckb_plan_id` BIGINT NOT NULL DEFAULT 0 COMMENT '存客宝获客计划ID' AFTER `device_groups`;
|
||||
|
||||
23
soul-api/scripts/add-persons-link-tags.sql
Normal file
23
soul-api/scripts/add-persons-link-tags.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- persons、link_tags 表,供 ContentPage @提及人物与链接标签配置
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-persons-link-tags.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
person_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS link_tags (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
tag_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||
url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'url',
|
||||
app_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||
page_path VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
6
soul-api/scripts/add-persons-token.sql
Normal file
6
soul-api/scripts/add-persons-token.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- persons 表新增 token 字段(32 位唯一,@ 时存此值,小程序用此兑换 ckb_api_key)
|
||||
-- 执行:cd 项目根目录 && node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-persons-token.sql
|
||||
-- 或:mysql -u user -p db < soul-api/scripts/add-persons-token.sql
|
||||
|
||||
ALTER TABLE persons ADD COLUMN token VARCHAR(36) NOT NULL DEFAULT '' AFTER person_id;
|
||||
ALTER TABLE persons ADD UNIQUE INDEX idx_persons_token (token);
|
||||
8
soul-api/scripts/add-user-rules-default.sql
Normal file
8
soul-api/scripts/add-user-rules-default.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 规则引擎默认数据:插入「注册」规则,供登录后完善头像引导
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-user-rules-default.sql
|
||||
-- 幂等:若已存在 trigger='注册' 则跳过
|
||||
|
||||
INSERT INTO user_rules (title, description, `trigger`, sort, enabled, created_at, updated_at)
|
||||
SELECT '完善个人信息', '设置头像和昵称,让其他创业者更容易认识你', '注册', 1, 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM user_rules WHERE `trigger` = '注册' LIMIT 1);
|
||||
9
soul-api/scripts/backfill-persons-ckb-api-key.md
Normal file
9
soul-api/scripts/backfill-persons-ckb-api-key.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 补全 persons.ckb_api_key
|
||||
|
||||
若存在 ckb_plan_id 但 ckb_api_key 为空的 Person,可手动调用 plan/detail 补全。
|
||||
|
||||
**执行前**:确保 soul-api 可连接存客宝(CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT 已配置)。
|
||||
|
||||
**方式一**:管理端逐个编辑保存(会触发存客宝同步,若 Person 无 ckb_api_key 需在编辑弹窗填写或重新创建)。
|
||||
|
||||
**方式二**:写一次性脚本,遍历 `ckb_plan_id > 0 AND (ckb_api_key IS NULL OR ckb_api_key = '')` 的 Person,调 ckbOpenGetPlanDetail 获取 apiKey 并 UPDATE。
|
||||
16
soul-api/scripts/sync-users-vip-and-schema.sql
Normal file
16
soul-api/scripts/sync-users-vip-and-schema.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- ============================================================
|
||||
-- 同步 users 表与 Go Model(仅 VIP 身份/状态字段,不含单独 VIP 资料列)
|
||||
-- 小程序已改为直接读用户资料(nickname/avatar/projectIntro/phone),不再单独存 vip_name/vip_avatar 等。
|
||||
-- 若某条 ALTER 报 Duplicate column name,说明该列已存在,跳过即可。
|
||||
-- 也可直接重启 soul-api(未设 SKIP_AUTO_MIGRATE 时会自动补列)。
|
||||
-- ============================================================
|
||||
|
||||
-- users 表:VIP 身份与状态(与 internal/model/user.go 一致)
|
||||
ALTER TABLE users ADD COLUMN is_vip TINYINT(1) NULL DEFAULT 0 COMMENT '是否 VIP';
|
||||
ALTER TABLE users ADD COLUMN vip_expire_date DATETIME(3) NULL DEFAULT NULL COMMENT 'VIP 到期时间';
|
||||
ALTER TABLE users ADD COLUMN vip_activated_at DATETIME(3) NULL DEFAULT NULL COMMENT '成为 VIP 时间,排序用';
|
||||
ALTER TABLE users ADD COLUMN vip_sort INT NULL DEFAULT NULL COMMENT '手动排序,越小越前';
|
||||
ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL DEFAULT NULL COMMENT '角色:从 vip_roles 选或手动填写';
|
||||
|
||||
-- chapters 表:Model 使用 hot_score(热度分),SQL 导出里只有 hot_score_override,缺则排名等接口报错
|
||||
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0 COMMENT '热度分,用于排名算法';
|
||||
BIN
soul-api/server
Executable file
BIN
soul-api/server
Executable file
Binary file not shown.
34958
soul-api/wechat/info.log
34958
soul-api/wechat/info.log
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user