Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	.cursor/agent/软件测试/evolution/索引.md   resolved by yongxu-dev version
#	.cursor/skills/testing/SKILL.md   resolved by yongxu-dev version
#	.gitignore   resolved by yongxu-dev version
#	miniprogram/app.js   resolved by yongxu-dev version
#	miniprogram/app.json   resolved by yongxu-dev version
#	miniprogram/pages/chapters/chapters.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.wxml   resolved by yongxu-dev version
#	miniprogram/pages/match/match.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxml   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxss   resolved by yongxu-dev version
#	miniprogram/pages/read/read.js   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxml   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxss   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.js   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxml   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxss   resolved by yongxu-dev version
#	miniprogram/utils/ruleEngine.js   resolved by yongxu-dev version
#	miniprogram/utils/trackClick.js   resolved by yongxu-dev version
#	soul-admin/dist/index.html   resolved by yongxu-dev version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu-dev version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/api-docs/ApiDocsPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu-dev version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu-dev version
#	soul-api/.env.production   resolved by yongxu-dev version
#	soul-api/internal/database/database.go   resolved by yongxu-dev version
#	soul-api/internal/handler/balance.go   resolved by yongxu-dev version
#	soul-api/internal/handler/book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/ckb_open.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_person.go   resolved by yongxu-dev version
#	soul-api/internal/handler/search.go   resolved by yongxu-dev version
#	soul-api/internal/handler/upload.go   resolved by yongxu-dev version
#	soul-api/internal/router/router.go   resolved by yongxu-dev version
#	soul-api/wechat/info.log   resolved by yongxu-dev version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu-dev version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu-dev version
This commit is contained in:
Alex-larget
2026-03-17 14:23:26 +08:00
231 changed files with 14492 additions and 6576 deletions

View File

@@ -10,6 +10,12 @@ APP_VERSION=0.0.0
# 数据库(测试环境 souldev
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
# Redis
# 本地开发无密码docker compose up -d 后使用 redis://localhost:6379/0
# 测试服/服务器(有密码 ckb@!redis://:ckb%40%21@localhost:6379/0
REDIS_URL=redis://localhost:6379/0
# REDIS_URL=redis://:ckb%40%21@localhost:6379/0
# 远程库启动时跳过 AutoMigrate表已存在避免 information_schema 慢查询);首次建表时注释掉
SKIP_AUTO_MIGRATE=1
# 慢查询阈值(ms),默认 5000避免 200ms 刷屏;设为 0 可恢复 GORM 默认

View File

@@ -11,14 +11,25 @@ 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
# Redis服务器实例端口 6379密码 ckb@!;同机用 localhost跨机用 Redis 服务器 IP
# 密码含特殊字符需 URL 编码:@ -> %40, ! -> %21
REDIS_URL=redis://:ckb%40%21@localhost:6379/0
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
# 存客宝配置
#添加卡若(内部 API用于 /v1/api/scenarios
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 存客宝开放 API创建/更新/删除获客计划、拉取设备列表
# - CKB_OPEN_API_KEY开放 API Key开发文档中的 mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
# - CKB_OPEN_ACCOUNT对应的存客宝登录账号手机号或用户名
CKB_OPEN_API_KEY=mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
CKB_OPEN_ACCOUNT=karuo1
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

Binary file not shown.

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

@@ -16,6 +16,8 @@ require (
gorm.io/gorm v1.25.12
)
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/bytedance/sonic v1.11.6 // indirect
@@ -43,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

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

@@ -49,6 +49,12 @@ type Config struct {
// 订单对账定时任务间隔分钟0 表示不启动内置定时任务
SyncOrdersIntervalMinutes int
// 上传目录绝对路径air 运行时避免相对路径解析错误)
UploadDir string
// Redis 连接地址(如 redis://localhost:6379/0空表示不使用 Redis
RedisURL string
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -239,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,
@@ -265,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

@@ -65,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)
}
@@ -89,15 +95,6 @@ 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.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.GiftUnlock{}); err != nil {
log.Printf("database: gift_unlocks migrate warning: %v", err)
}
// 以下表业务大量使用,必须参与 AutoMigrate否则旧库缺字段会导致订单/用户/VIP 等接口报错
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Printf("database: users migrate warning: %v", err)
@@ -108,15 +105,12 @@ func Init(dsn string) error {
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)
}
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
log.Printf("database: user_tracks migrate warning: %v", err)
}
seedDefaultRules(db)
seedHistoryTracks(db)
fixBrokenImageUrls(db)
log.Println("database: connected")
return nil
}
@@ -125,74 +119,3 @@ func Init(dsn string) error {
func DB() *gorm.DB {
return db
}
func seedDefaultRules(d *gorm.DB) {
var count int64
d.Model(&model.UserRule{}).Count(&count)
if count > 0 {
return
}
defaults := []model.UserRule{
{Title: "注册完成 → 填写头像", Description: "用户完成注册后,引导填写头像和昵称", Trigger: "注册", Sort: 10, Enabled: true},
{Title: "完成匹配 → 补充个人资料", Description: "完成派对房匹配后,引导填写 MBTI、行业、职位", Trigger: "完成匹配", Sort: 20, Enabled: true},
{Title: "首次浏览章节 → 绑定手机号", Description: "点击阅读收费章节时,引导绑定手机号", Trigger: "点击收费章节", Sort: 30, Enabled: true},
{Title: "付款 ¥1980 → 填写完整信息", Description: "购买全书后,需填写完整信息以进入 VIP 群", Trigger: "完成付款", Sort: 40, Enabled: true},
{Title: "加入派对房 → 填写项目介绍", Description: "进入派对房前,引导填写项目介绍和核心需求", 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 := d.CreateInBatches(&defaults, len(defaults)).Error; err != nil {
log.Printf("database: seed user_rules warning: %v", err)
}
}
// fixBrokenImageUrls 修复数据库中 URL 缺少冒号的脏数据("https//..." → "https://..."
func fixBrokenImageUrls(d *gorm.DB) {
cols := []struct{ table, col string }{
{"users", "avatar"},
{"users", "vip_avatar"},
{"author_config", "avatar_img"},
{"mentors", "avatar"},
}
for _, c := range cols {
res := d.Exec(
"UPDATE "+c.table+" SET "+c.col+" = REPLACE("+c.col+", 'https//', 'https://') WHERE "+c.col+" LIKE 'https//%'",
)
if res.RowsAffected > 0 {
log.Printf("database: fixed %d broken URL(s) in %s.%s", res.RowsAffected, c.table, c.col)
}
res = d.Exec(
"UPDATE "+c.table+" SET "+c.col+" = REPLACE("+c.col+", 'http//', 'http://') WHERE "+c.col+" LIKE 'http//%'",
)
if res.RowsAffected > 0 {
log.Printf("database: fixed %d broken http URL(s) in %s.%s", res.RowsAffected, c.table, c.col)
}
}
}
func seedHistoryTracks(d *gorm.DB) {
var trackCount int64
d.Model(&model.UserTrack{}).Count(&trackCount)
if trackCount > 5 {
return
}
// 为所有已有用户回填 register track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_reg_', id), id, 'register', created_at FROM users
WHERE id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'register')`)
// 为已绑定手机的用户回填 bind_phone track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_phone_', id), id, 'bind_phone', updated_at FROM users
WHERE phone IS NOT NULL AND phone != ''
AND id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'bind_phone')`)
// 为有订单的用户回填 purchase track
d.Exec(`INSERT IGNORE INTO user_tracks (id, user_id, action, created_at)
SELECT CONCAT('seed_pay_', o.user_id), o.user_id, 'purchase', MIN(o.created_at)
FROM orders o WHERE o.status IN ('paid','success','completed')
AND o.user_id NOT IN (SELECT user_id FROM user_tracks WHERE action = 'purchase')
GROUP BY o.user_id`)
log.Println("database: seeded history tracks from existing data")
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -199,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{})
}
}
@@ -224,5 +226,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -7,6 +7,7 @@ import (
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -179,6 +180,26 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
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 {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -94,6 +95,7 @@ func AdminLinkedMpCreate(c *gin.Context) {
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
@@ -148,6 +150,7 @@ func AdminLinkedMpUpdate(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})
}
@@ -181,6 +184,7 @@ func AdminLinkedMpDelete(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})
}

View File

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

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

@@ -1,20 +1,20 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"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"
)
// GET /api/miniprogram/balance 小程序-查询余额
// BalanceGet GET /api/miniprogram/balance?userId=
func BalanceGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
@@ -22,454 +22,389 @@ func BalanceGet(c *gin.Context) {
return
}
db := database.DB()
var bal model.UserBalance
if err := db.Where("user_id = ?", userID).First(&bal).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"userId": userID, "balance": 0, "totalRecharged": 0, "totalGifted": 0, "totalRefunded": 0}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": bal})
}
// POST /api/miniprogram/balance/recharge 小程序-充值(创建充值订单)
func BalanceRecharge(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误: " + err.Error()})
return
}
db := database.DB()
orderSN := fmt.Sprintf("BAL_%d", time.Now().UnixNano())
order := model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.UserID,
ProductType: "balance_recharge",
Amount: body.Amount,
}
desc := fmt.Sprintf("余额充值 ¥%.2f", body.Amount)
status := "pending"
order.Description = &desc
order.Status = &status
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建充值订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSN, "amount": body.Amount}})
}
// POST /api/miniprogram/balance/recharge/confirm 充值完成回调(内部或手动确认)
func BalanceRechargeConfirm(c *gin.Context) {
var body struct {
OrderSN string `json:"orderSn" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", body.OrderSN, "balance_recharge").First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "订单不存在"})
return
}
if order.Status != nil && *order.Status == "paid" {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
paid := "paid"
now := time.Now()
if err := tx.Model(&order).Updates(map[string]interface{}{"status": paid, "pay_time": now}).Error; err != nil {
return err
}
var bal model.UserBalance
if err := tx.Where("user_id = ?", order.UserID).First(&bal).Error; err != nil {
bal = model.UserBalance{UserID: order.UserID}
tx.Create(&bal)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", order.Amount),
"total_recharged": gorm.Expr("total_recharged + ?", order.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: order.UserID,
Type: "recharge",
Amount: order.Amount,
BalanceAfter: bal.Balance + order.Amount,
RelatedOrder: &order.OrderSN,
Description: fmt.Sprintf("充值 ¥%.2f", order.Amount),
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "确认失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "充值成功"})
}
// POST /api/miniprogram/balance/gift 小程序-代付解锁(用余额帮他人解锁章节)
func BalanceGift(c *gin.Context) {
var body struct {
GiverID string `json:"giverId" binding:"required"`
SectionID string `json:"sectionId" binding:"required"`
PaidViaWechat bool `json:"paidViaWechat"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var chapter model.Chapter
if err := db.Where("id = ?", body.SectionID).First(&chapter).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
price := float64(1)
if chapter.Price != nil {
price = *chapter.Price
}
if price <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "该章节免费,无需代付"})
return
}
var giftCode string
code := make([]byte, 16)
rand.Read(code)
giftCode = hex.EncodeToString(code)
if body.PaidViaWechat {
db.Create(&model.GiftUnlock{
GiftCode: giftCode,
GiverID: body.GiverID,
SectionID: body.SectionID,
Amount: price,
Status: "pending",
})
} else {
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", price),
"total_gifted": gorm.Expr("total_gifted + ?", price),
}).Error; err != nil {
return err
}
tx.Create(&model.GiftUnlock{
GiftCode: giftCode,
GiverID: body.GiverID,
SectionID: body.SectionID,
Amount: price,
Status: "pending",
})
tx.Create(&model.BalanceTransaction{
UserID: body.GiverID,
Type: "gift",
Amount: -price,
BalanceAfter: bal.Balance - price,
SectionID: &body.SectionID,
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
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{
"giftCode": giftCode,
"sectionId": body.SectionID,
"amount": price,
}})
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": ub.Balance}})
}
// POST /api/miniprogram/balance/gift/redeem 领取代付礼物
func BalanceGiftRedeem(c *gin.Context) {
var body struct {
GiftCode string `json:"giftCode" binding:"required"`
ReceiverID string `json:"receiverId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", body.GiftCode).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
if gift.Status != "pending" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "礼物已被领取"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
tx.Model(&gift).Updates(map[string]interface{}{
"receiver_id": body.ReceiverID,
"status": "redeemed",
"redeemed_at": now,
})
orderSN := fmt.Sprintf("GIFT_%s", body.GiftCode[:8])
paid := "paid"
desc := fmt.Sprintf("来自好友的代付解锁")
tx.Create(&model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.ReceiverID,
ProductType: "section",
ProductID: &gift.SectionID,
Amount: 0,
Description: &desc,
Status: &paid,
PayTime: &now,
ReferrerID: &gift.GiverID,
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "领取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"sectionId": gift.SectionID,
"message": "解锁成功!",
}})
}
// POST /api/miniprogram/balance/refund 申请余额退款(全额原路返回)
func BalanceRefund(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.UserID).First(&bal).Error; err != nil || bal.Balance < body.Amount {
return fmt.Errorf("余额不足")
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", body.Amount),
"total_refunded": gorm.Expr("total_refunded + ?", body.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: body.UserID,
Type: "refund",
Amount: -body.Amount,
BalanceAfter: bal.Balance - body.Amount,
Description: fmt.Sprintf("全额退款 ¥%.2f", body.Amount),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"deducted": body.Amount,
"refundAmount": body.Amount,
"message": fmt.Sprintf("退款成功,¥%.2f 将原路返回", body.Amount),
}})
}
// GET /api/miniprogram/balance/transactions 交易记录(含余额变动 + 阅读消费订单)
func BalanceTransactions(c *gin.Context) {
// 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 txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
var orders []model.Order
db.Where("user_id = ? AND product_type = ? AND status IN ?", userID, "section", []string{"paid", "completed", "success"}).
Order("created_at DESC").Limit(50).Find(&orders)
type txRow struct {
ID string `json:"id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
CreatedAt interface{} `json:"createdAt"`
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
}
merged := make([]txRow, 0, len(txns)+len(orders))
for _, t := range txns {
merged = append(merged, txRow{
ID: fmt.Sprintf("bal_%d", t.ID), Type: t.Type, Amount: t.Amount,
Description: t.Description, CreatedAt: t.CreatedAt,
})
}
for _, o := range orders {
desc := "阅读消费"
if o.Description != nil && *o.Description != "" {
desc = *o.Description
out := make([]gin.H, 0, len(list))
for _, t := range list {
orderID := ""
if t.OrderID != nil {
orderID = *t.OrderID
}
merged = append(merged, txRow{
ID: o.ID, Type: "consume", Amount: -o.Amount,
Description: desc, CreatedAt: o.CreatedAt,
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})
}
// 按时间倒序排列
for i := 0; i < len(merged); i++ {
for j := i + 1; j < len(merged); j++ {
ti := fmt.Sprintf("%v", merged[i].CreatedAt)
tj := fmt.Sprintf("%v", merged[j].CreatedAt)
if ti < tj {
merged[i], merged[j] = merged[j], merged[i]
// 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("order_id = ? 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)
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, UserID: order.UserID, Type: "recharge", Amount: order.Amount,
OrderID: &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
}
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, UserID: req.UserID, Type: "consume", Amount: -amount,
OrderID: &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))
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, 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.OrderID != nil {
orderID = *t.OrderID
}
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
}
}
}
if len(merged) > 50 {
merged = merged[:50]
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": merged})
}
// GET /api/admin/balance/summary 管理端-余额统计
func BalanceSummary(c *gin.Context) {
db := database.DB()
type Summary struct {
TotalUsers int64 `json:"totalUsers"`
TotalBalance float64 `json:"totalBalance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalGifted float64 `json:"totalGifted"`
TotalRefunded float64 `json:"totalRefunded"`
GiftCount int64 `json:"giftCount"`
PendingGifts int64 `json:"pendingGifts"`
}
var s Summary
db.Model(&model.UserBalance{}).Count(&s.TotalUsers)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(balance),0)").Scan(&s.TotalBalance)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_recharged),0)").Scan(&s.TotalRecharged)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_gifted),0)").Scan(&s.TotalGifted)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_refunded),0)").Scan(&s.TotalRefunded)
db.Model(&model.GiftUnlock{}).Count(&s.GiftCount)
db.Model(&model.GiftUnlock{}).Where("status = ?", "pending").Count(&s.PendingGifts)
c.JSON(200, gin.H{"success": true, "data": s})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息
func BalanceGiftInfo(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", code).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
var chapter model.Chapter
db.Where("id = ?", gift.SectionID).First(&chapter)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": gift.GiftCode,
"sectionId": gift.SectionID,
"sectionTitle": chapter.SectionTitle,
"amount": gift.Amount,
"status": gift.Status,
"giverId": gift.GiverID,
"mid": chapter.MID,
}})
}
// GET /api/miniprogram/balance/gifts?userId=xxx 我的代付列表
func BalanceGiftList(c *gin.Context) {
userId := c.Query("userId")
if userId == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var gifts []model.GiftUnlock
db.Where("giver_id = ?", userId).Order("created_at DESC").Limit(50).Find(&gifts)
type giftItem struct {
GiftCode string `json:"giftCode"`
SectionID string `json:"sectionId"`
SectionTitle string `json:"sectionTitle"`
Amount float64 `json:"amount"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
var result []giftItem
for _, g := range gifts {
var ch model.Chapter
title := g.SectionID
if db.Where("id = ?", g.SectionID).First(&ch).Error == nil && ch.SectionTitle != "" {
title = ch.SectionTitle
newBalance := ub.Balance + req.Amount
if newBalance < 0 {
return fmt.Errorf("调整后余额不能为负,当前余额 %.2f", ub.Balance)
}
result = append(result, giftItem{
GiftCode: g.GiftCode,
SectionID: g.SectionID,
SectionTitle: title,
Amount: g.Amount,
Status: g.Status,
CreatedAt: g.CreatedAt.Format("2006-01-02 15:04"),
})
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = ?, updated_at = NOW()", userID, newBalance)
txID := fmt.Sprintf("bt_adj_%d", time.Now().UnixNano()%100000000000)
return tx.Create(&model.BalanceTransaction{
ID: txID, 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, "data": gin.H{"gifts": result}})
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)
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
return tx.Create(&model.BalanceTransaction{
ID: txID, UserID: order.UserID, Type: "recharge", Amount: order.Amount,
OrderID: &orderSn, CreatedAt: time.Now(),
}).Error
})
}

View File

@@ -1,13 +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"
@@ -18,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 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
func BookAllChapters(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Chapter{}).
Select("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")
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+"%")
}
@@ -36,7 +232,8 @@ func BookAllChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
freeIDs := getEffectiveFreeChapterIDs(db)
sortChaptersByNaturalID(list)
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
t := true
@@ -45,6 +242,13 @@ func BookAllChapters(c *gin.Context) {
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})
}
@@ -60,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")
@@ -112,24 +391,6 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
return ids
}
func getEffectiveFreeChapterIDs(db *gorm.DB) map[string]bool {
ids := getFreeChapterIDs(db)
var rows []struct {
ID string `gorm:"column:id"`
}
if err := db.Model(&model.Chapter{}).
Select("id").
Where("is_free = ? OR price = 0", true).
Find(&rows).Error; err == nil {
for _, row := range rows {
if row.ID != "" {
ids[row.ID] = true
}
}
}
return ids
}
// checkUserChapterAccess 判断 userId 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
@@ -197,7 +458,7 @@ func getUnpaidPreviewPercent(db *gorm.DB) int {
return 20
}
// previewContent 取内容的前 percent%,上限 500 字(手动设置 percent 也受此限制),不少于 100 字
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
@@ -227,10 +488,12 @@ func previewContent(content string, percent int) string {
// 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
@@ -239,6 +502,17 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
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 {
@@ -258,9 +532,6 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
returnContent = ch.Content
} else {
percent := getUnpaidPreviewPercent(db)
if ch.PreviewPercent != nil && *ch.PreviewPercent >= 1 && *ch.PreviewPercent <= 100 {
percent = *ch.PreviewPercent
}
returnContent = previewContent(ch.Content, percent)
}
@@ -352,7 +623,7 @@ func BookChapters(c *gin.Context) {
updates := map[string]interface{}{
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
"sort_order": body.SortOrder, "status": body.Status, "hot_score": body.HotScore,
"sort_order": body.SortOrder, "status": body.Status,
}
if body.EditionStandard != nil {
updates["edition_standard"] = body.EditionStandard
@@ -392,6 +663,7 @@ 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 {
@@ -431,64 +703,42 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
return out
}
// BookHot GET /api/book/hot 热门章节(按 hot_score 降序,使用与管理端相同的排名算法
// 支持 ?limit=N 参数,默认 20最大 100
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50
// Redis 缓存 5min章节更新时失效
func BookHot(c *gin.Context) {
db := database.DB()
sections, err := computeArticleRankingSections(db)
if err != nil || len(sections) == 0 {
var list []model.Chapter
q := db.Model(&model.Chapter{}).
Select("mid, id, part_id, part_title, chapter_id, chapter_title, section_title, is_free, price, sort_order, hot_score, updated_at")
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 {
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("hot_score DESC, sort_order ASC, id ASC").Limit(20).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
sortChaptersByNaturalID(list)
}
limit := 20
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
limit = l
if limit > 100 {
limit = 100
}
}
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
result := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := ""
if i < len(tags) {
tag = tags[i]
}
result = append(result, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"price": s.Price,
"isFree": s.IsFree,
"clickCount": s.ClickCount,
"payCount": s.PayCount,
"hotScore": s.HotScore,
"hotRank": i + 1,
"isPinned": s.IsPinned,
"tag": tag,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
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 精选推荐(首页「为你推荐」前 3 章)
// 与内容排行榜完全同源:使用 computeArticleRankingSections取前 3 条,保证顺序一致
// Redis 缓存 5min章节更新时失效
func BookRecommended(c *gin.Context) {
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
}
sections, err := computeArticleRankingSections(database.DB())
if err != nil || len(sections) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
@@ -518,13 +768,37 @@ func BookRecommended(c *gin.Context) {
"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})
}
@@ -535,8 +809,7 @@ func escapeLikeBook(s string) string {
return s
}
// BookSearch GET /api/book/search?q= 章节搜索
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
func BookSearch(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -544,78 +817,46 @@ func BookSearch(c *gin.Context) {
return
}
pattern := "%" + escapeLikeBook(q) + "%"
db := database.DB()
type row struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
IsFree *bool `gorm:"column:is_free"`
}
var titleHits []row
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("section_title LIKE ?", pattern).
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(15).
Find(&titleHits)
titleIDs := make(map[string]bool, len(titleHits))
for _, h := range titleHits {
titleIDs[h.ID] = true
Limit(20).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
return
}
remaining := 20 - len(titleHits)
var contentHits []row
if remaining > 0 {
cq := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
cq = cq.Where("id NOT IN ?", ids)
sortChaptersByNaturalID(list)
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
}
cq.Order("sort_order ASC, id ASC").Limit(remaining).Find(&contentHits)
}
results := make([]gin.H, 0, len(titleHits)+len(contentHits))
for _, ch := range titleHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": "title",
})
}
for _, ch := range contentHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": "content",
"isFree": ch.IsFree, "matchType": matchType,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})
}
// BookStats GET /api/book/stats
// Redis 缓存 5min章节更新时失效
func BookStats(c *gin.Context) {
db := database.DB()
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
db.Model(&model.Chapter{}).Count(&total)
freeCount := len(getEffectiveFreeChapterIDs(db))
var totalWords struct{ S int64 }
db.Model(&model.Chapter{}).Select("COALESCE(SUM(word_count),0) as s").Scan(&totalWords)
var userCount int64
db.Model(&model.User{}).Count(&userCount)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"totalChapters": total,
"freeChapters": freeCount,
"totalWordCount": totalWords.S,
"totalUsers": userCount,
}})
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}})
}
// BookSync GET/POST /api/book/sync

View File

@@ -378,6 +378,7 @@ func CKBIndexLead(c *gin.Context) {
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)
@@ -452,18 +453,24 @@ func CKBLead(c *gin.Context) {
name = "小程序用户"
}
// 确定使用哪个存客宝密钥:被@人物的 ckb_api_key > 全局system_config > .env > 代码内置)
// 存客宝 scenarios 内部 API 需要计划级 apiKeypersons.ckb_api_key不是 token
// 文章 @ 场景targetUserId=token → 查 Person 取 CkbApiKey 作为 leadKey
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname) // 被@人的显示名,用于成功文案
targetName := strings.TrimSpace(body.TargetNickname)
if body.TargetUserID != "" {
var p model.Person
if db.Where("token = ?", body.TargetUserID).First(&p).Error == nil {
if p.CkbApiKey != "" {
leadKey = p.CkbApiKey
}
if targetName == "" {
targetName = p.Name
}
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
}
}
@@ -519,6 +526,7 @@ func CKBLead(c *gin.Context) {
}
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)
@@ -530,6 +538,7 @@ func CKBLead(c *gin.Context) {
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
@@ -553,10 +562,23 @@ func CKBLead(c *gin.Context) {
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] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
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

@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -126,6 +127,20 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
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"} {
@@ -193,6 +208,144 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
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) {
@@ -273,14 +426,14 @@ func AdminCKBDevices(c *gin.Context) {
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"],
"id": id,
"memo": memo,
"imei": m["imei"],
"wechatId": wechatID,
"status": status,
"avatar": m["avatar"],
"nickname": m["nickname"],
"usedInPlan": m["usedInPlans"],
"totalFriend": m["totalFriend"],
})
}
@@ -305,199 +458,3 @@ func AdminCKBDevices(c *gin.Context) {
"total": total,
})
}
// AdminCKBPlans GET /api/admin/ckb/plans 管理端-存客宝获客计划列表
func AdminCKBPlans(c *gin.Context) {
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "50")
keyword := c.Query("keyword")
values := url.Values{}
values.Set("page", pageStr)
values.Set("limit", limitStr)
if keyword != "" {
values.Set("keyword", keyword)
}
planURL := ckbOpenBaseURL + "/v1/plan/list?" + values.Encode()
req, err := http.NewRequest(http.MethodGet, planURL, 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)
var parsed map[string]interface{}
if err := json.Unmarshal(b, &parsed); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析计划列表失败"})
return
}
code, _ := parsed["code"].(float64)
if int(code) != 200 {
msg, _ := parsed["msg"].(string)
if msg == "" {
msg = "存客宝返回异常"
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
return
}
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
}
plans := make([]map[string]interface{}, 0)
if arr, ok := listAny.([]interface{}); ok {
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
reqConf, _ := m["reqConf"].(map[string]interface{})
sceneConf, _ := m["sceneConf"].(map[string]interface{})
enabled := false
if sceneConf != nil {
if v, ok := sceneConf["enabled"].(bool); ok {
enabled = v
}
}
if m["status"] != nil {
if s, ok := m["status"].(float64); ok {
enabled = int(s) == 1
}
}
greeting, _ := mapStr(reqConf, "greeting")
tips, _ := mapStr(sceneConf, "tips")
remarkType, _ := mapStr(reqConf, "remarkType")
remarkFormat, _ := mapStr(reqConf, "remarkFormat")
startTime, _ := mapStr(reqConf, "startTime")
endTime, _ := mapStr(reqConf, "endTime")
addInterval := mapFloat(reqConf, "addFriendInterval")
if addInterval == 0 {
addInterval = mapFloat(sceneConf, "addInterval")
}
var deviceGroups interface{}
if reqConf != nil {
deviceGroups = reqConf["device"]
}
if deviceGroups == nil && sceneConf != nil {
deviceGroups = sceneConf["deviceGroups"]
}
plans = append(plans, map[string]interface{}{
"id": m["id"],
"name": m["name"],
"apiKey": m["apiKey"],
"sceneId": m["sceneId"],
"scenario": m["sceneId"],
"enabled": enabled,
"greeting": greeting,
"tips": tips,
"remarkType": remarkType,
"remarkFormat": remarkFormat,
"addInterval": addInterval,
"startTime": startTime,
"endTime": endTime,
"deviceGroups": deviceGroups,
})
}
}
total := 0
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
switch tv := dataVal["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, "plans": plans, "total": total})
}
func mapStr(m map[string]interface{}, key string) (string, bool) {
if m == nil {
return "", false
}
v, ok := m[key]
if !ok || v == nil {
return "", false
}
s, ok := v.(string)
return s, ok
}
func mapFloat(m map[string]interface{}, key string) float64 {
if m == nil {
return 0
}
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case int:
return float64(val)
case string:
if n, err := strconv.ParseFloat(val, 64); err == nil {
return n
}
}
return 0
}
// AdminCKBPlanDetail GET /api/admin/ckb/plan-detail?planId=xxx 管理端-存客宝获客计划详情
func AdminCKBPlanDetail(c *gin.Context) {
planIDStr := c.Query("planId")
if planIDStr == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 planId"})
return
}
planID, _ := strconv.ParseInt(planIDStr, 10, 64)
if planID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "planId 无效"})
return
}
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
apiKey, err := ckbOpenGetPlanDetail(token, planID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "apiKey": apiKey})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -8,19 +9,24 @@ import (
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 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
@@ -128,23 +134,7 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
// persons 列表仅暴露安全字段personId / name / aliases / label不暴露 ckbApiKey 等密钥)
var personRows []model.Person
if err := db.Order("name ASC").Find(&personRows).Error; err == nil && len(personRows) > 0 {
pList := make([]gin.H, 0, len(personRows))
for _, p := range personRows {
pList = append(pList, gin.H{
"personId": p.PersonID,
"name": p.Name,
"aliases": p.Aliases,
"label": p.Label,
})
}
out["persons"] = pList
}
if _, has := out["persons"]; !has {
out["persons"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
@@ -192,7 +182,7 @@ 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{},
@@ -228,8 +218,8 @@ func AdminSettingsGet(c *gin.Context) {
out["mpConfig"] = merged
}
case "oss_config":
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
out["ossConfig"] = sanitizeOSSConfig(m)
if m, ok := val.(map[string]interface{}); ok {
out["ossConfig"] = m
}
}
}
@@ -285,12 +275,12 @@ func AdminSettingsPost(c *gin.Context) {
}
}
if body.OssConfig != nil {
body.OssConfig = mergeOSSConfigSecret(db, body.OssConfig)
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": "设置已保存"})
}
@@ -374,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": "推广设置已保存"})
}
@@ -545,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": "配置保存成功"})
}
@@ -571,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
}
@@ -698,25 +688,17 @@ func DBUsersList(c *gin.Context) {
referralCountMap[r.ReferrerID] = int(r.Count)
}
// 4. 用户余额:从 user_balances 查询
balanceMap := make(map[string]float64)
var balRows []struct {
UserID string
Balance float64
}
db.Table("user_balances").Select("user_id, COALESCE(balance, 0) as balance").Find(&balRows)
for _, r := range balRows {
balanceMap[r.UserID] = r.Balance
}
// 填充每个用户的实时计算字段
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]
// 分销收益
@@ -729,9 +711,7 @@ func DBUsersList(c *gin.Context) {
}
users[i].Earnings = ptrFloat64(totalE)
users[i].PendingEarnings = ptrFloat64(available)
users[i].WithdrawnEarnings = ptrFloat64(withdrawn)
users[i].ReferralCount = ptrInt(referralCountMap[uid])
users[i].WalletBalance = ptrFloat64(balanceMap[uid])
}
c.JSON(http.StatusOK, gin.H{
@@ -782,14 +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"`
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
@@ -835,7 +815,7 @@ func DBUsersAction(c *gin.Context) {
updates["avatar"] = *body.Avatar
}
if body.Tags != nil {
updates["tags"] = *body.Tags
updates["ckb_tags"] = *body.Tags
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
@@ -895,9 +875,6 @@ func DBUsersAction(c *gin.Context) {
if body.VipBio != nil {
updates["vip_bio"] = *body.VipBio
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
@@ -1208,49 +1185,6 @@ func DBConfigDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
func sanitizeOSSConfig(cfg map[string]interface{}) gin.H {
out := gin.H{}
for k, v := range cfg {
out[k] = v
}
if secret, ok := out["accessKeySecret"].(string); ok && secret != "" {
out["accessKeySecret"] = "****"
}
return out
}
func mergeOSSConfigSecret(db *gorm.DB, incoming map[string]interface{}) map[string]interface{} {
if incoming == nil {
return incoming
}
secret, _ := incoming["accessKeySecret"].(string)
if secret != "" && secret != "****" {
return incoming
}
var row model.SystemConfig
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
return incoming
}
var existing map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &existing); err != nil {
return incoming
}
existingSecret, _ := existing["accessKeySecret"].(string)
if existingSecret == "" {
return incoming
}
merged := map[string]interface{}{}
for k, v := range incoming {
merged[k] = v
}
merged["accessKeySecret"] = existingSecret
return merged
}
// DBInitGet GET /api/db/init
func DBInitGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})

View File

@@ -3,11 +3,13 @@ package handler
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -15,6 +17,26 @@ import (
"gorm.io/gorm"
)
// 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)
}
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "mid", "section_title", "price", "is_free", "is_new",
@@ -38,7 +60,6 @@ type sectionListItem struct {
ClickCount int64 `json:"clickCount"` // 阅读次数reading_progress
PayCount int64 `json:"payCount"` // 付款笔数orders.product_type=section
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
HotRank int `json:"hotRank"` // 热度排名(按 hotScore 降序)
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
}
@@ -88,12 +109,28 @@ func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
return sections, nil
}
// computeSectionsWithHotScore 内部:计算 hotScore,可选设置 isPinned
// 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("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 {
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)
@@ -124,7 +161,7 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
payCountMap[r.ProductID] = r.Cnt
}
}
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
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 {
@@ -133,13 +170,13 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
PayWeight float64 `json:"payWeight"`
}
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
if v.ReadWeight > 0 {
if v.ReadWeight >= 0 {
readWeight = v.ReadWeight
}
if v.PayWeight > 0 {
if v.PayWeight >= 0 {
payWeight = v.PayWeight
}
if v.RecencyWeight > 0 {
if v.RecencyWeight >= 0 {
recencyWeight = v.RecencyWeight
}
}
@@ -157,132 +194,92 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
pinnedSet[id] = true
}
}
const rankTop = 20
// 构建基础 section 数据
type rawSection struct {
item sectionListItem
readCnt int64
payCnt int64
updatedAt time.Time
// 1. 阅读量排名:按 readCount 降序前20名得 20~1 分
type idCnt struct {
id string
cnt int64
}
raws := make([]rawSection, 0, len(rows))
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
}
raws = append(raws, rawSection{
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: readCountMap[r.ID],
PayCount: payCountMap[r.ID],
},
readCnt: readCountMap[r.ID],
payCnt: payCountMap[r.ID],
updatedAt: r.UpdatedAt,
})
}
// 排名积分:前 rankTop 名分别得 rankTop ~ 1 分,其余 0 分
readRankScore := make(map[string]float64, len(raws))
payRankScore := make(map[string]float64, len(raws))
recencyRankScore := make(map[string]float64, len(raws))
// 阅读量排名
sorted := make([]int, len(raws))
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].readCnt > raws[sorted[b]].readCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].readCnt == 0 {
break
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)
}
readRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 付款量排名
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].payCnt > raws[sorted[b]].payCnt
})
for rank, idx := range sorted {
if rank >= rankTop || raws[idx].payCnt == 0 {
break
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,
}
payRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 新度排名(按 updated_at 最近排序)
for i := range sorted {
sorted[i] = i
}
sort.Slice(sorted, func(a, b int) bool {
return raws[sorted[a]].updatedAt.After(raws[sorted[b]].updatedAt)
})
for rank, idx := range sorted {
if rank >= rankTop {
break
}
recencyRankScore[raws[idx].item.ID] = float64(rankTop - rank)
}
// 计算最终热度分
sections := make([]sectionListItem, 0, len(raws))
hotUpdates := make(map[string]float64, len(raws))
for i := range raws {
id := raws[i].item.ID
hot := readRankScore[id]*readWeight + recencyRankScore[id]*recencyWeight + payRankScore[id]*payWeight
hot = math.Round(hot*100) / 100
hotUpdates[id] = hot
raws[i].item.HotScore = hot
if setPinned {
raws[i].item.IsPinned = pinnedSet[id]
item.IsPinned = pinnedSet[r.ID]
}
sections = append(sections, raws[i].item)
sections = append(sections, item)
}
// 计算排名序号
ranked := make([]sectionListItem, len(sections))
copy(ranked, sections)
sort.Slice(ranked, func(i, j int) bool {
return ranked[i].HotScore > ranked[j].HotScore
})
rankMap := make(map[string]int, len(ranked))
for i, s := range ranked {
rankMap[s.ID] = i + 1
}
for i := range sections {
sections[i].HotRank = rankMap[sections[i].ID]
}
go persistHotScores(db, hotUpdates)
return sections, nil
}
// persistHotScores writes computed hot_score values back to the chapters table
func persistHotScores(db *gorm.DB, scores map[string]float64) {
for id, score := range scores {
_ = db.WithContext(context.Background()).
Model(&model.Chapter{}).
Where("id = ?", id).
UpdateColumn("hot_score", score).Error
}
}
// DBBookAction GET/POST/PUT /api/db/book
func DBBookAction(c *gin.Context) {
db := database.DB()
@@ -310,8 +307,47 @@ func DBBookAction(c *gin.Context) {
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
@@ -341,7 +377,6 @@ func DBBookAction(c *gin.Context) {
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
"previewPercent": ch.PreviewPercent,
},
})
return
@@ -361,10 +396,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
@@ -393,6 +441,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":
@@ -406,7 +456,8 @@ 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{
@@ -416,7 +467,7 @@ func DBBookAction(c *gin.Context) {
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
Content: processed,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
@@ -440,8 +491,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:
@@ -473,8 +527,6 @@ func DBBookAction(c *gin.Context) {
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
HotScore *float64 `json:"hotScore"`
PreviewPercent *int `json:"previewPercent"` // 章节级预览比例(%)1-100
ClearPreviewPercent *bool `json:"clearPreviewPercent"` // true 表示清除覆盖、使用全局
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -507,6 +559,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
}
@@ -521,6 +575,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
}
@@ -544,6 +600,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
}
@@ -559,10 +617,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,
@@ -583,14 +647,6 @@ func DBBookAction(c *gin.Context) {
if body.HotScore != nil {
updates["hot_score"] = *body.HotScore
}
if body.ClearPreviewPercent != nil && *body.ClearPreviewPercent {
updates["preview_percent"] = nil
} else if body.PreviewPercent != nil {
p := *body.PreviewPercent
if p >= 1 && p <= 100 {
updates["preview_percent"] = p
}
}
if body.PartID != "" {
updates["part_id"] = body.PartID
}
@@ -604,7 +660,7 @@ func DBBookAction(c *gin.Context) {
updates["chapter_title"] = body.ChapterTitle
}
var existing model.Chapter
err := db.Where("id = ?", body.ID).First(&existing).Error
err = db.Where("id = ?", body.ID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// 新建Create
partID := body.PartID
@@ -637,7 +693,7 @@ func DBBookAction(c *gin.Context) {
ChapterID: chapterID,
ChapterTitle: chapterTitle,
SectionTitle: body.Title,
Content: body.Content,
Content: processedContent,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
@@ -652,6 +708,9 @@ func DBBookAction(c *gin.Context) {
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
}
@@ -664,6 +723,9 @@ func DBBookAction(c *gin.Context) {
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
}
@@ -704,9 +766,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

@@ -2,7 +2,9 @@ package handler
import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -34,10 +36,17 @@ func DBLinkTagSave(c *gin.Context) {
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 必填"})
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"
}
@@ -47,6 +56,11 @@ func DBLinkTagSave(c *gin.Context) {
}
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
@@ -55,6 +69,7 @@ func DBLinkTagSave(c *gin.Context) {
existing.AppID = body.AppID
existing.PagePath = body.PagePath
db.Save(&existing)
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
@@ -64,6 +79,7 @@ func DBLinkTagSave(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, "linkTag": t})
}
@@ -78,5 +94,6 @@ func DBLinkTagDelete(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})
}

View File

@@ -12,6 +12,7 @@ import (
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
@@ -45,7 +46,6 @@ func DBPersonSave(c *gin.Context) {
var body struct {
PersonID string `json:"personId"`
Name string `json:"name"`
Aliases string `json:"aliases"`
Label string `json:"label"`
CkbApiKey string `json:"ckbApiKey"` // 存客宝真实密钥,留空则 fallback 全局 Key
Greeting string `json:"greeting"`
@@ -65,18 +65,26 @@ 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.Aliases = body.Aliases
existing.Label = body.Label
if strings.TrimSpace(body.CkbApiKey) != "" {
existing.CkbApiKey = body.CkbApiKey
}
existing.CkbApiKey = body.CkbApiKey
existing.Greeting = body.Greeting
existing.Tips = body.Tips
existing.RemarkType = body.RemarkType
@@ -101,20 +109,26 @@ func DBPersonSave(c *gin.Context) {
} else {
existing.DeviceGroups = ""
}
if err := db.Save(&existing).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
return
}
// 新增:创建本地人物记录,存客宝同步失败不阻断 @人物 与内容管理主链路
// 新增:创建本地 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
}
// 2. 构造创建计划请求体
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
addInterval := 1
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
@@ -134,41 +148,20 @@ func DBPersonSave(c *gin.Context) {
deviceIDs = append(deviceIDs, id)
}
}
newPerson := model.Person{
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Aliases: body.Aliases,
Label: body.Label,
CkbApiKey: strings.TrimSpace(body.CkbApiKey),
Greeting: body.Greeting,
Tips: body.Tips,
RemarkType: body.RemarkType,
RemarkFormat: body.RemarkFormat,
AddFriendInterval: addInterval,
StartTime: startTime,
EndTime: endTime,
}
if len(deviceIDs) > 0 {
idsStr := make([]string, 0, len(deviceIDs))
for _, id := range deviceIDs {
idsStr = append(idsStr, fmt.Sprintf("%d", id))
if len(deviceIDs) == 0 {
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取默认设备失败: " + err.Error()})
return
}
newPerson.DeviceGroups = strings.Join(idsStr, ",")
deviceIDs = []int64{defaultID}
}
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}
planPayload := map[string]interface{}{
"name": name,
"sceneId": 11,
"scenario": 11,
"planType": 1,
"sceneId": 9,
"scenario": 9,
"status": 1,
"remarkType": body.RemarkType,
"greeting": body.Greeting,
"addInterval": addInterval,
@@ -177,58 +170,123 @@ func DBPersonSave(c *gin.Context) {
"enabled": true,
"tips": body.Tips,
"distributionEnabled": false,
}
if len(deviceIDs) > 0 {
planPayload["deviceGroups"] = deviceIDs
"deviceGroups": deviceIDs,
}
openToken, tokenErr := ckbOpenGetToken()
if tokenErr != nil {
resp["ckbSyncError"] = tokenErr.Error()
resp["message"] = "人物已保存,存客宝同步失败,可稍后补同步"
c.JSON(http.StatusOK, resp)
return
}
planID, ckbCreateData, ckbResponse, planErr := ckbOpenCreatePlan(openToken, planPayload)
if planErr != nil {
resp["ckbSyncError"] = "创建存客宝计划失败: " + planErr.Error()
planID, ckbCreateData, ckbResponse, err := ckbOpenCreatePlan(openToken, planPayload)
if err != nil {
out := gin.H{"success": false, "error": "创建存客宝计划失败: " + err.Error()}
if ckbResponse != nil {
resp["ckbResponse"] = ckbResponse
out["ckbResponse"] = ckbResponse
}
resp["message"] = "人物已保存,存客宝同步失败,可稍后补同步"
c.JSON(http.StatusOK, resp)
c.JSON(http.StatusOK, out)
return
}
apiKey, detailErr := ckbOpenGetPlanDetail(openToken, planID)
if detailErr != nil {
db.Model(&model.Person{}).Where("person_id = ?", newPerson.PersonID).Update("ckb_plan_id", planID)
newPerson.CkbPlanID = planID
resp["person"] = newPerson
resp["ckbSyncError"] = "创建成功但获取计划密钥失败: " + detailErr.Error()
resp["message"] = "人物已保存,存客宝部分同步成功"
if len(ckbCreateData) > 0 {
resp["ckbCreateResult"] = ckbCreateData
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
}
c.JSON(http.StatusOK, resp)
return
}
newPerson.CkbPlanID = planID
newPerson.CkbApiKey = apiKey
if err := db.Model(&model.Person{}).Where("person_id = ?", newPerson.PersonID).Updates(map[string]interface{}{
"ckb_api_key": apiKey,
"ckb_plan_id": planID,
}).Error; err == nil {
resp["person"] = newPerson
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 {
@@ -244,66 +302,31 @@ func genPersonToken() (string, error) {
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
}
// DBPersonFixCKB POST /api/db/persons/fix-ckb 为缺少 ckb_api_key 的人物批量创建存客宝获客计划
func DBPersonFixCKB(c *gin.Context) {
db := database.DB()
var persons []model.Person
if err := db.Where("ckb_api_key = '' OR ckb_api_key IS NULL").Find(&persons).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if len(persons) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "所有人物已有 ckb_api_key", "fixed": 0})
return
}
openToken, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
results := make([]gin.H, 0, len(persons))
fixed := 0
for _, p := range persons {
planPayload := map[string]interface{}{
"name": fmt.Sprintf("SOUL链接人与事-%s", p.Name),
"sceneId": 11,
"scenario": 11,
"remarkType": p.RemarkType,
"greeting": p.Greeting,
"addInterval": 1,
"startTime": "09:00",
"endTime": "18:00",
"enabled": true,
"tips": p.Tips,
"distributionEnabled": false,
}
planID, _, _, planErr := ckbOpenCreatePlan(openToken, planPayload)
if planErr != nil {
results = append(results, gin.H{"personId": p.PersonID, "name": p.Name, "error": planErr.Error()})
continue
}
apiKey, keyErr := ckbOpenGetPlanDetail(openToken, planID)
if keyErr != nil {
results = append(results, gin.H{"personId": p.PersonID, "name": p.Name, "error": keyErr.Error()})
continue
}
db.Model(&model.Person{}).Where("person_id = ?", p.PersonID).Updates(map[string]interface{}{
"ckb_api_key": apiKey,
"ckb_plan_id": planID,
})
results = append(results, gin.H{"personId": p.PersonID, "name": p.Name, "apiKey": apiKey, "planId": planID})
fixed++
}
c.JSON(http.StatusOK, gin.H{"success": true, "fixed": fixed, "total": len(persons), "results": results})
}
// 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

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

@@ -317,66 +317,81 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
// -------- 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
}
// 查询用户的有效推荐人(先查 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
}
}
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
finalAmount := standardPrice
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 = standardPrice * (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)
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天"
@@ -393,7 +408,6 @@ func miniprogramPayPost(c *gin.Context) {
clientIP = "127.0.0.1"
}
// 插入订单到数据库
userID := req.UserID
if userID == "" {
userID = req.OpenID
@@ -411,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)
@@ -494,7 +511,7 @@ func miniprogramPayGet(c *gin.Context) {
orderPollLogf("主动同步订单已支付: %s", orderSn)
// 激活权益
if order.UserID != "" {
activateOrderBenefits(db, order.UserID, order.ProductType, now)
activateOrderBenefits(db, &order, now)
}
}
case "CLOSED", "REVOKED", "PAYERROR":
@@ -521,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)
@@ -579,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)
}
@@ -592,30 +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" {
// V4.2 修复:续费时累加剩余天数(从 max(now, vip_expire_date) 加 365 天)
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
expireDate := activateVIP(db, buyerUserID, 365, 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
@@ -624,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
})
@@ -848,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+/"
@@ -974,13 +1062,20 @@ func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) ti
return expireDate
}
// activateOrderBenefits 订单支付成功后激活对应权益VIP / 全书)
func activateOrderBenefits(db *gorm.DB, userID, productType string, payTime time.Time) {
// 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)
}
}

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

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -19,7 +20,6 @@ func escapeLike(s string) string {
}
// SearchGet GET /api/search?q= 从 chapters 表搜索GORM参数化
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content 到内存
func SearchGet(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -27,79 +27,52 @@ func SearchGet(c *gin.Context) {
return
}
pattern := "%" + escapeLike(q) + "%"
db := database.DB()
// 第一步:标题匹配(快速,不加载 content
type searchRow struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
Price *float64 `gorm:"column:price"`
IsFree *bool `gorm:"column:is_free"`
Snippet string `gorm:"column:snippet"`
}
var titleMatches []searchRow
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(3).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
for _, m := range titleMatches {
titleIDs[m.ID] = true
Limit(50).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
return
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 20 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, "+
"CONCAT(CASE WHEN LOCATE(?, content) > 60 THEN '...' ELSE '' END, "+
"SUBSTRING(content, GREATEST(1, LOCATE(?, content) - 50), 200), "+
"CASE WHEN LENGTH(content) > LOCATE(?, content) + 150 THEN '...' ELSE '' END) as snippet",
q, q, q).
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
sortChaptersByNaturalID(list)
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
score := 5
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
score = 10
}
snippet := ""
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
if pos >= 0 && len(ch.Content) > 0 {
start := pos - 50
if start < 0 {
start = 0
}
end := pos + utf8.RuneCountInString(q) + 50
if end > len(ch.Content) {
end = len(ch.Content)
}
snippet = ch.Content[start:end]
if start > 0 {
snippet = "..." + snippet
}
if end < len(ch.Content) {
snippet = snippet + "..."
}
contentQ = contentQ.Where("id NOT IN ?", ids)
}
contentQ.Order("sort_order ASC, id ASC").
Limit(remaining).
Find(&contentMatches)
}
results := make([]gin.H, 0, len(titleMatches)+len(contentMatches))
for _, ch := range titleMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "title", "score": 10, "snippet": "",
})
}
for _, ch := range contentMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
snippet := ch.Snippet
if len([]rune(snippet)) > 200 {
snippet = string([]rune(snippet)[:200]) + "..."
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "content", "score": 5, "snippet": snippet,
"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,
})
}
c.JSON(http.StatusOK, gin.H{

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
// UploadPost POST /api/upload 上传图片(表单 file
// 若管理端已配置 OSS优先上传到 OSSOSS 失败或未配置时回退本地磁盘(容灾)
func UploadPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -41,20 +43,27 @@ func UploadPost(c *gin.Context) {
folder = "avatars"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
// 尝试 OSS 上传
if ossCfg := getOssConfig(); ossCfg != nil {
src, err := file.Open()
// 优先尝试 OSS(已配置时)
if oss.IsEnabled() {
f, 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, "storage": "oss"}})
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)
dst := filepath.Join(dir, name)
@@ -62,8 +71,12 @@ func UploadPost(c *gin.Context) {
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, "storage": "local"}})
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 {
@@ -76,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

@@ -452,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
@@ -475,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"
@@ -502,7 +526,7 @@ 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,
})
}
@@ -608,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 {
@@ -687,6 +750,10 @@ func UserDashboardStats(c *gin.Context) {
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)

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

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

@@ -37,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"`

View File

@@ -0,0 +1,24 @@
package model
import "time"
// UserBalance 对应表 user_balances
type UserBalance struct {
UserID string `gorm:"column:user_id;primaryKey;size:50"`
Balance float64 `gorm:"column:balance;type:decimal(10,2);default:0"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (UserBalance) TableName() string { return "user_balances" }
// BalanceTransaction 对应表 balance_transactions
type BalanceTransaction struct {
ID string `gorm:"column:id;primaryKey;size:50"`
UserID string `gorm:"column:user_id;size:50"`
Type string `gorm:"column:type;size:20"` // recharge, consume, refund, gift
Amount float64 `gorm:"column:amount;type:decimal(10,2)"`
OrderID *string `gorm:"column:order_id;size:50"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (BalanceTransaction) TableName() string { return "balance_transactions" }

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")
{
@@ -49,10 +57,10 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.PUT("/content", handler.AdminContent)
admin.DELETE("/content", handler.AdminContent)
admin.GET("/dashboard/stats", handler.AdminDashboardStats)
admin.GET("/track/stats", handler.AdminTrackStats)
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
admin.GET("/dashboard/merchant-balance", handler.AdminDashboardMerchantBalance)
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
admin.GET("/payment", handler.AdminPayment)
admin.POST("/payment", handler.AdminPayment)
@@ -77,23 +85,21 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
admin.GET("/ckb/devices", handler.AdminCKBDevices)
admin.GET("/ckb/plans", handler.AdminCKBPlans)
admin.GET("/ckb/plan-detail", handler.AdminCKBPlanDetail)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.POST("/content/upload", handler.AdminContentUpload)
admin.GET("/users", handler.AdminAppUsersList)
admin.GET("/admin-users", handler.AdminUsersList)
admin.POST("/admin-users", handler.AdminUsersAction)
admin.PUT("/admin-users", handler.AdminUsersAction)
admin.DELETE("/admin-users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
admin.GET("/balance/summary", handler.BalanceSummary)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
admin.POST("/shensheshou/batch", handler.AdminShensheShouBatchQuery)
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("/orders", handler.OrdersList)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/user/track", handler.UserTrackGet)
}
// ----- 鉴权 -----
@@ -102,6 +108,8 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 书籍/章节(只读,写操作由 /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)
@@ -157,14 +165,12 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/migrate", handler.DBMigrateGet)
db.POST("/migrate", handler.DBMigratePost)
db.GET("/users", handler.DBUsersList)
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
db.GET("/users/rfm", handler.DBUsersRFM)
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
db.POST("/users", handler.DBUsersAction)
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)
@@ -181,13 +187,11 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/person", handler.DBPersonDetail)
db.POST("/persons", handler.DBPersonSave)
db.DELETE("/persons", handler.DBPersonDelete)
db.POST("/persons/fix-ckb", handler.DBPersonFixCKB)
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-person-leads", handler.CKBPersonLeadStats)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
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)
@@ -244,7 +248,6 @@ func Setup(cfg *config.Config) *gin.Engine {
// ----- 上传 -----
api.POST("/upload", handler.UploadPost)
api.POST("/upload/video", handler.UploadVideoPost)
api.DELETE("/upload", handler.UploadDelete)
// ----- 用户 -----
@@ -281,6 +284,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)
@@ -299,8 +304,6 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/ckb/lead", handler.CKBLead)
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.POST("/upload/image", handler.UploadImagePost)
miniprogram.POST("/upload/video", handler.UploadVideoPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
@@ -314,7 +317,6 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
miniprogram.POST("/track", handler.UserTrackPost)
miniprogram.POST("/user/update", handler.UserUpdate)
miniprogram.POST("/withdraw", handler.WithdrawPost)
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
@@ -334,17 +336,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.GET("/user-rules", handler.MiniprogramUserRules)
// 余额与代付
// 埋点
miniprogram.POST("/track", handler.MiniprogramTrackPost)
// 规则引擎(用户旅程引导)
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
// 余额
miniprogram.GET("/balance", handler.BalanceGet)
miniprogram.POST("/balance/recharge", handler.BalanceRecharge)
miniprogram.POST("/balance/recharge/confirm", handler.BalanceRechargeConfirm)
miniprogram.POST("/balance/gift", handler.BalanceGift)
miniprogram.POST("/balance/gift/redeem", handler.BalanceGiftRedeem)
miniprogram.GET("/balance/gift/info", handler.BalanceGiftInfo)
miniprogram.GET("/balance/gifts", handler.BalanceGiftList)
miniprogram.POST("/balance/refund", handler.BalanceRefund)
miniprogram.GET("/balance/transactions", handler.BalanceTransactions)
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)
}
// ----- 提现 -----
@@ -366,11 +376,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,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,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,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。

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

File diff suppressed because it is too large Load Diff