同步
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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": "配置保存成功"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user