This commit is contained in:
Alex-larget
2026-03-24 18:45:32 +08:00
parent dcb7961945
commit f3d74ce94a
68 changed files with 2461 additions and 2535 deletions

View File

@@ -43,6 +43,9 @@ func Init(dsn string) error {
skipMigrate := strings.ToLower(strings.TrimSpace(os.Getenv("SKIP_AUTO_MIGRATE")))
if skipMigrate == "1" || skipMigrate == "true" || skipMigrate == "yes" {
log.Println("database: SKIP_AUTO_MIGRATE enabled, skipping schema migration")
// 即使跳过 AutoMigrate也补齐关键运行时字段避免新功能因历史库缺列直接报错。
ensurePersonSchema(db)
ensureCkbLeadSchema(db)
log.Println("database: connected")
return nil
}
@@ -89,6 +92,7 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
log.Printf("database: ckb_lead_records migrate warning: %v", err)
}
ensureCkbLeadSchema(db)
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
@@ -146,3 +150,32 @@ func ensurePersonSchema(db *gorm.DB) {
}
}
}
func ensureCkbLeadSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.CkbLeadRecord{}, "push_status") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN push_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '推送状态: pending/success/failed'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add push_status", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "retry_count") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add retry_count", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "last_push_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN last_push_at DATETIME NULL COMMENT '最后推送时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add last_push_at", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "next_retry_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次重试时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add next_retry_at", err)
}
}
if !m.HasIndex(&model.CkbLeadRecord{}, "idx_ckb_lead_push_status") {
if err := db.Exec("CREATE INDEX idx_ckb_lead_push_status ON ckb_lead_records(push_status)").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_push_status", err)
}
}
}

View File

@@ -83,7 +83,11 @@ func AdminWithdrawalsList(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Withdrawal{})
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
if statusFilter == "pending" {
q = q.Where("status IN ?", []string{"pending", "processing", "pending_confirm"})
} else {
q = q.Where("status = ?", statusFilter)
}
}
var total int64
q.Count(&total)
@@ -91,7 +95,11 @@ func AdminWithdrawalsList(c *gin.Context) {
var list []model.Withdrawal
query := db.Order("created_at DESC")
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
if statusFilter == "pending" {
query = query.Where("status IN ?", []string{"pending", "processing", "pending_confirm"})
} else {
query = query.Where("status = ?", statusFilter)
}
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})

View File

@@ -86,7 +86,8 @@ type cachedPartRow struct {
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
// Icon 可选system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
Icon string `json:"icon,omitempty"`
Icon string `json:"icon,omitempty"`
BadgeText string `json:"badgeText,omitempty"`
}
type cachedFixedItem struct {
ID string `json:"id"`
@@ -137,19 +138,46 @@ func loadBookPartIconURLs() map[string]string {
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts每次接口响应前调用避免 Redis 旧缓存缺 icon
// loadBookPartBadgeTexts 读取 system_config.book_part_badges{"part-1":"新"}key 与 chapters.part_id 一致
func loadBookPartBadgeTexts() map[string]string {
out := map[string]string{}
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "book_part_badges").First(&row).Error; err != nil {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out[k] = s
}
}
}
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL/角标写入 parts每次接口响应前调用避免 Redis 旧缓存缺字段)
func mergeBookPartIcons(parts []cachedPartRow) {
if len(parts) == 0 {
return
}
m := loadBookPartIconURLs()
if len(m) == 0 {
return
}
bm := loadBookPartBadgeTexts()
for i := range parts {
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
parts[i].Icon = u
}
if b := strings.TrimSpace(bm[parts[i].PartID]); b != "" {
parts[i].BadgeText = b
}
}
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
@@ -113,6 +114,216 @@ func getCkbLeadApiKey() string {
return ckbAPIKey
}
func markLeadPushSuccess(db *gorm.DB, recordID int64) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
"push_status": "success",
"ckb_error": "",
"last_push_at": now,
"next_retry_at": nil,
}).Error
}
func markLeadPushFailed(db *gorm.DB, recordID int64, errMsg string, incRetry bool) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
updates := map[string]interface{}{
"push_status": "failed",
"ckb_error": strings.TrimSpace(errMsg),
"last_push_at": now,
"next_retry_at": now.Add(5 * time.Minute),
}
if incRetry {
updates["retry_count"] = gorm.Expr("retry_count + 1")
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(updates).Error
}
type ckbLeadPushResult struct {
Code int
Message string
Raw string
}
func pushLeadToCKB(name, phone, wechatId, leadKey string) (ckbLeadPushResult, error) {
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
if strings.TrimSpace(phone) != "" {
params["phone"] = strings.TrimSpace(phone)
}
if strings.TrimSpace(wechatId) != "" {
params["wechatId"] = strings.TrimSpace(wechatId)
}
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
if v, ok := params["phone"].(string); ok && v != "" {
q.Set("phone", v)
}
if v, ok := params["wechatId"].(string); ok && v != "" {
q.Set("wechatId", v)
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
return ckbLeadPushResult{}, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(b, &result)
msg := strings.TrimSpace(result.Message)
if msg == "" {
msg = strings.TrimSpace(result.Msg)
}
return ckbLeadPushResult{
Code: result.Code,
Message: msg,
Raw: string(b),
}, nil
}
func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord) bool {
select {
case <-ctx.Done():
return false
default:
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", r.ID).Update("push_status", "pending").Error
var p map[string]interface{}
_ = json.Unmarshal([]byte(r.Params), &p)
source := strings.TrimSpace(r.Source)
name := strings.TrimSpace(r.Nickname)
if name == "" {
if v, ok := p["name"].(string); ok {
name = strings.TrimSpace(v)
}
}
if name == "" {
name = "小程序用户"
}
phone := strings.TrimSpace(r.Phone)
wechatId := strings.TrimSpace(r.WechatID)
if phone == "" {
if v, ok := p["phone"].(string); ok {
phone = strings.TrimSpace(v)
}
}
if wechatId == "" {
if v, ok := p["wechatId"].(string); ok {
wechatId = strings.TrimSpace(v)
}
}
leadKey := getCkbLeadApiKey()
targetName := ""
targetMemberID := ""
targetMemberName := ""
leadUserID := strings.TrimSpace(r.UserID)
if v, ok := p["userId"].(string); ok && leadUserID == "" {
leadUserID = strings.TrimSpace(v)
}
if source != "index_link_button" {
if v, ok := p["targetUserId"].(string); ok && strings.TrimSpace(v) != "" {
var person model.Person
if db.Where("token = ?", strings.TrimSpace(v)).First(&person).Error == nil && strings.TrimSpace(person.CkbApiKey) != "" {
leadKey = strings.TrimSpace(person.CkbApiKey)
targetName = strings.TrimSpace(person.Name)
if person.UserID != nil {
targetMemberID = strings.TrimSpace(*person.UserID)
}
}
}
if v, ok := p["targetNickname"].(string); ok && strings.TrimSpace(v) != "" {
targetName = strings.TrimSpace(v)
}
if v, ok := p["targetMemberId"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberID = strings.TrimSpace(v)
}
if v, ok := p["targetMemberName"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberName = strings.TrimSpace(v)
}
}
res, perr := pushLeadToCKB(name, phone, wechatId, leadKey)
if perr != nil {
markLeadPushFailed(db, r.ID, perr.Error(), true)
return false
}
if res.Code == 200 {
markLeadPushSuccess(db, r.ID)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: targetName,
MemberName: targetMemberName,
TargetMemberID: targetMemberID,
Source: source,
Repeated: true,
LeadUserID: leadUserID,
})
return true
}
msg := res.Message
if msg == "" {
msg = "重推失败"
}
markLeadPushFailed(db, r.ID, msg, true)
return false
}
func RetryCkbLeadByID(ctx context.Context, recordID int64) (bool, error) {
if recordID <= 0 {
return false, fmt.Errorf("recordID 无效")
}
db := database.DB()
var r model.CkbLeadRecord
if err := db.Where("id = ?", recordID).First(&r).Error; err != nil {
return false, err
}
return retryOneLeadRecord(ctx, db, r), nil
}
// RetryFailedCkbLeads 重推存客宝失败留资记录(供定时任务调用)
func RetryFailedCkbLeads(ctx context.Context, limit int) (retried, success int, err error) {
if limit < 1 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
db := database.DB()
now := time.Now()
var rows []model.CkbLeadRecord
if err := db.Where("push_status = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", "failed", now).
Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, 0, err
}
retried = len(rows)
for i := range rows {
if retryOneLeadRecord(ctx, db, rows[i]) {
success++
}
}
return retried, success, nil
}
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
var body struct {
@@ -388,15 +599,17 @@ func CKBIndexLead(c *gin.Context) {
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
}).Error
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -417,7 +630,8 @@ func CKBIndexLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -429,6 +643,7 @@ func CKBIndexLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
var msg string
var defaultPerson model.Person
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&defaultPerson).Error == nil && strings.TrimSpace(defaultPerson.Tips) != "" {
@@ -451,14 +666,14 @@ func CKBIndexLead(c *gin.Context) {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -478,12 +693,8 @@ func CKBIndexLead(c *gin.Context) {
}
}
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s leadKey=%s\n", result.Code, result.Message, string(b), leadKey)
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": result.Message,
})
markLeadPushFailed(db, rec.ID, errMsg, true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)、文章@某人、超级个体详情点头像
@@ -496,9 +707,9 @@ func CKBLead(c *gin.Context) {
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention / 超级个体人物 token
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -529,21 +740,19 @@ func CKBLead(c *gin.Context) {
if body.TargetUserID != "" {
var p model.Person
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
return
}
if strings.TrimSpace(p.CkbApiKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
return
}
leadKey = p.CkbApiKey
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
fmt.Printf("[CKBLead] 未找到人物 token=%s回退全局获客池\n", body.TargetUserID)
} else {
if strings.TrimSpace(p.CkbApiKey) != "" {
leadKey = p.CkbApiKey
}
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
}
@@ -565,7 +774,7 @@ func CKBLead(c *gin.Context) {
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
@@ -574,7 +783,9 @@ func CKBLead(c *gin.Context) {
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -605,7 +816,8 @@ func CKBLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -618,6 +830,7 @@ func CKBLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
who := targetName
if who == "" {
who = "对方"
@@ -639,15 +852,15 @@ func CKBLead(c *gin.Context) {
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -713,16 +926,17 @@ func CKBLead(c *gin.Context) {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
markLeadPushSuccess(db, rec.ID)
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
@@ -737,9 +951,10 @@ func CKBLead(c *gin.Context) {
}
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s leadKey=%s\n", result.Code, ckbMsg, string(b), leadKey)
markLeadPushFailed(db, rec.ID, errMsg, true)
respObj := gin.H{
"success": false,
"message": errMsg,
"success": true,
"message": "添加成功,我们正在为您安排对接",
"ckbCode": result.Code,
"ckbMessage": ckbMsg,
}
@@ -750,15 +965,15 @@ func CKBLead(c *gin.Context) {
}
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
}
func leadSourceLabel(source string) string {
@@ -783,7 +998,7 @@ func leadSourceLabel(source string) string {
var _webhookDedupCache = struct {
sync.Mutex
m map[string]string
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string, targetMemberID string) bool {

View File

@@ -233,6 +233,28 @@ func CronRetryOrderWebhooks(c *gin.Context) {
})
}
// CronRetryCkbLeads GET/POST /api/cron/retry-ckb-leads
// 重推存客宝失败留资记录,并更新 ckb_lead_records.push_status。
func CronRetryCkbLeads(c *gin.Context) {
limit := 100
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
retried, success, err := RetryFailedCkbLeads(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"retried": retried,
"pushed": success,
"limit": limit,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})

View File

@@ -142,6 +142,7 @@ func defaultMpUi() gin.H {
"chaptersPage": gin.H{
"bookTitle": "一场SOUL的创业实验场",
"bookSubtitle": "来自Soul派对房的真实商业故事",
"newBadgeText": "NEW",
},
"homePage": gin.H{
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
@@ -549,12 +550,13 @@ func DBConfigGet(c *gin.Context) {
q := db.Table("system_config")
if key != "" {
q = q.Where("config_key = ?", key)
q = q.Order("updated_at DESC")
}
if err := q.Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if key != "" && len(list) == 1 {
if key != "" && len(list) > 0 {
var val interface{}
_ = json.Unmarshal(list[0].ConfigValue, &val)
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
@@ -956,6 +958,11 @@ func DBConfigPost(c *gin.Context) {
if body.Key == "article_ranking_weights" || body.Key == "pinned_section_ids" {
cache.InvalidateBookCache()
}
// 目录篇图标/角标变更后,立即使目录缓存失效,避免前台看到旧值
if body.Key == "book_part_icons" || body.Key == "book_part_badges" {
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}

View File

@@ -523,6 +523,7 @@ func DBBookAction(c *gin.Context) {
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
NewID string `json:"newId"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
@@ -762,12 +763,46 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
newID := strings.TrimSpace(body.NewID)
idChanged := newID != "" && newID != body.ID
if idChanged {
var existed int64
if err := db.Model(&model.Chapter{}).Where("id = ? AND id <> ?", newID, body.ID).Count(&existed).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if existed > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节ID已存在请换一个"})
return
}
updates["id"] = newID
}
if idChanged {
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
return err
}
// 同步历史关联数据,避免改 ID 后订单/阅读记录断链
if err := tx.Model(&model.Order{}).
Where("product_type = ? AND product_id = ?", "section", body.ID).
Update("product_id", newID).Error; err != nil {
return err
}
if err := tx.Table("reading_progress").
Where("section_id = ?", body.ID).
Update("section_id", newID).Error; err != nil {
return err
}
return nil
})
} else {
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContentByID(body.ID)
cache.InvalidateChapterContent(existing.MID)
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -29,6 +30,7 @@ func DBCKBLeadList(c *gin.Context) {
if mode == "contact" {
search := c.Query("search")
source := c.Query("source")
pushStatus := strings.TrimSpace(c.Query("pushStatus"))
q := db.Model(&model.CkbLeadRecord{})
if search != "" {
q = q.Where("nickname LIKE ? OR phone LIKE ? OR wechat_id LIKE ? OR name LIKE ?",
@@ -37,6 +39,9 @@ func DBCKBLeadList(c *gin.Context) {
if source != "" {
q = q.Where("source = ?", source)
}
if pushStatus != "" {
q = q.Where("push_status = ?", pushStatus)
}
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
@@ -80,6 +85,11 @@ func DBCKBLeadList(c *gin.Context) {
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"pushStatus": r.PushStatus,
"retryCount": r.RetryCount,
"ckbError": r.CkbError,
"lastPushAt": r.LastPushAt,
"nextRetryAt": r.NextRetryAt,
"createdAt": r.CreatedAt,
})
}
@@ -96,8 +106,8 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize,
"stats": gin.H{
"uniqueUsers": uniqueUsers,
"sourceStats": sourceStats,
"uniqueUsers": uniqueUsers,
"sourceStats": sourceStats,
},
})
return
@@ -158,6 +168,27 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// DBCKBLeadRetry POST /api/db/ckb-leads/retry 管理端-手动重推单条失败线索
func DBCKBLeadRetry(c *gin.Context) {
var body struct {
ID int64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
return
}
ok, err := RetryCkbLeadByID(c.Request.Context(), body.ID)
if err != nil {
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "重推失败"
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "pushed": ok})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -621,7 +621,7 @@ func CKBPinnedPerson(c *gin.Context) {
return
}
nickname := strings.TrimSpace(p.Name)
avatar := strings.TrimSpace(p.Avatar)
avatar := ""
if p.UserID != nil && *p.UserID != "" {
var u model.User
if db.Select("nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {

View File

@@ -4,17 +4,21 @@ import "time"
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
type CkbLeadRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
Source string `gorm:"column:source;size:50" json:"source"` // 来源index_lead / article_mention
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
CkbError string `gorm:"column:ckb_error;size:500" json:"ckbError"` // 存客宝请求失败时写入错误信息,便于运营排查
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
Source string `gorm:"column:source;size:50" json:"source"` // 来源index_lead / article_mention
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
PushStatus string `gorm:"column:push_status;size:20;default:'pending';index" json:"pushStatus"` // pending/success/failed
RetryCount int `gorm:"column:retry_count;default:0" json:"retryCount"`
LastPushAt *time.Time `gorm:"column:last_push_at" json:"lastPushAt,omitempty"`
NextRetryAt *time.Time `gorm:"column:next_retry_at" json:"nextRetryAt,omitempty"`
CkbError string `gorm:"column:ckb_error;size:500" json:"ckbError"` // 存客宝请求失败时写入错误信息,便于运营排查
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (CkbLeadRecord) TableName() string { return "ckb_lead_records" }

View File

@@ -166,6 +166,8 @@ func Setup(cfg *config.Config) *gin.Engine {
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.POST("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.GET("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.POST("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
@@ -223,6 +225,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/persons/pinned", handler.DBPersonPinnedList)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.POST("/ckb-leads/retry", handler.DBCKBLeadRetry)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)