chore: 清理敏感与开发文档,仅同步代码

- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 17:50:12 +08:00
parent 868b0a10d9
commit 76965adb23
443 changed files with 24175 additions and 64154 deletions

View File

@@ -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
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://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

View File

@@ -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
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://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

View File

@@ -1,44 +0,0 @@
# 正式环境配置(部署时复制为 .envdevlop.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
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://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
View File

@@ -1,5 +1,7 @@
tmp/
log/
soul-api
soul-api-linux
server.exe
soul-api.exe
wechat/info.log

View 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)
}
}

View File

@@ -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)
}

View File

@@ -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"])

View 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:

View File

@@ -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

View File

@@ -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
View 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 书籍相关接口 TTLhot/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)
}

View File

@@ -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 将路径拼接到 BaseURLpath 应以 / 开头
@@ -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))
}
// RedisREDIS_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
}

View File

@@ -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)
}
}

View File

@@ -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})
}

View File

@@ -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
}

View 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
}

View File

@@ -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})
}

View 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,
})
}

View File

@@ -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()

View File

@@ -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)

View 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-labelTipTap 用 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
}

View 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
})
}

View File

@@ -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 列表不加载 contentlongtext避免 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、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
// 缓存优先级Redis10min后台更新时失效> 内存30s> DBRedis 不可用时回退内存+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. 内存缓存30sRedis 不可用时的容灾)
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 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_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. 付款数前20orders 中 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}})
}

View File

@@ -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可选用于补全昵称
// 至少传 phonewechatId 之一;签名规则同 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 需要计划级 apiKeypersons.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)
}

View 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.mdsign = 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,
})
}

View File

@@ -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
}
// 微信已支付,本地未更新 → 补齐

View File

@@ -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 需要 typeminiprogram 类型存 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
}
// 填充 hasFullBookis_vip 或 orders
// 填充 hasFullBookorders、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
// 购买状态(含手动设置的 VIPis_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_biotags 存 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
}

View File

@@ -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})
}

View 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_recordsjoin/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{},
},
})
}

View 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})
}

View File

@@ -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})
}

View 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})
}

View File

@@ -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})
}

View File

@@ -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.1is_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)
}
}

View File

@@ -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 != "" {

View 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
}

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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,
})
}

View File

@@ -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优先上传到 OSSOSS 失败或未配置时回退本地磁盘(容灾)
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/xxxOSS
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

View 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
}

View File

@@ -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,
},
})
}

View File

@@ -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 买断(永久权益),不等同于 VIP365天
// 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,
}
}

View 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)})
}

View File

@@ -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

View 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()
}
}

View 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" }

View File

@@ -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"`
}

View File

@@ -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" }

View 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" }

View 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" }

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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" }

View 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 去掉 schemeSDK 需要
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
return &cfg
}
// IsEnabled 是否已配置 OSS 且可用
func IsEnabled() bool {
return LoadConfig() != nil
}
// Upload 上传文件到 OSSobjectKey 如 "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
}
// 公网 URLhttps://{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)
}

View 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
}

View File

@@ -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/bookAdminAuth完成
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,
})
})

View 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
}

View File

@@ -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, "")
}

View File

@@ -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] 上传中断: %s5 秒后重连 ..." % (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"])

View 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

View 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_idModel 为 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 中对应表结构

View 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;

View 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);

View 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;

View 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';

View 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;

View File

@@ -0,0 +1,2 @@
-- 为 chapters 表新增 hot_score 字段(热度分,用于排名算法)
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;

View 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;

View 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`;

View 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;

View 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);

View 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);

View 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。

View 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

Binary file not shown.

File diff suppressed because it is too large Load Diff