Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	soul-admin/src/api/ckb.ts
#	soul-admin/src/pages/content/PersonAddEditModal.tsx
#	soul-api/internal/model/person.go
#	开发文档/1、需求/以界面定需求.md
#	开发文档/1、需求/需求汇总.md
This commit is contained in:
Alex-larget
2026-03-18 21:10:02 +08:00
460 changed files with 92262 additions and 3962 deletions

View File

@@ -44,7 +44,7 @@ func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
if db.Where("name = ?", name).First(&p).Error == nil {
return p.Token, nil
}
created, err := createPersonMinimal(db, clean)
created, err := createPersonMinimal(db, clean, "")
if err != nil {
return "", err
}

View File

@@ -577,7 +577,7 @@ func previewContent(content string, percent int) string {
limit = total
}
runes := []rune(content)
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
return string(runes[:limit]) + "\n\n……"
}
// findChapterAndRespond 按条件查章节并返回统一格式

View File

@@ -72,7 +72,7 @@ func ckbOpenGetToken() (string, error) {
if msg == "" {
msg = "存客宝鉴权失败"
}
return "", fmt.Errorf(msg)
return "", fmt.Errorf("%s", msg)
}
return authResult.Data.Token, nil
}
@@ -114,7 +114,7 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
if result.Message == "" {
result.Message = "创建计划失败"
}
return 0, nil, ckbResponse, fmt.Errorf(result.Message)
return 0, nil, ckbResponse, fmt.Errorf("%s", result.Message)
}
// 原始 data 转为 map 供响应展示
createData = make(map[string]interface{})
@@ -127,6 +127,49 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
}
// ckbOpenUpdatePlan 调用 PUT /v1/plan/update 更新获客计划(用于停用/启用)
// payload 至少包含 planId
func ckbOpenUpdatePlan(token string, payload map[string]interface{}) (ckbResponse map[string]interface{}, err error) {
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPut, ckbOpenBaseURL+"/v1/plan/update", bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("构造更新计划请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求存客宝更新计划失败: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(b, &result)
ckbResponse = map[string]interface{}{
"code": result.Code,
"message": result.Message,
"data": nil,
}
if len(result.Data) > 0 {
var dataObj interface{}
_ = json.Unmarshal(result.Data, &dataObj)
ckbResponse["data"] = dataObj
}
if result.Code != 200 {
msg := result.Message
if msg == "" {
msg = "更新计划失败"
}
return ckbResponse, fmt.Errorf("%s", msg)
}
return ckbResponse, nil
}
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey若存客宝直接返回则复用避免二次请求
func parseApiKeyFromCreateData(data map[string]interface{}) string {
for _, key := range []string{"apiKey", "api_key"} {
@@ -200,7 +243,7 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
if result.Message == "" {
result.Message = "获取计划详情失败"
}
return "", fmt.Errorf(result.Message)
return "", fmt.Errorf("%s", result.Message)
}
if result.Data.APIKey == "" {
return "", fmt.Errorf("计划详情中缺少 apiKey")
@@ -341,7 +384,7 @@ func ckbOpenDeletePlan(token string, planID int64) error {
if result.Message == "" {
result.Message = "删除计划失败"
}
return fmt.Errorf(result.Message)
return fmt.Errorf("%s", result.Message)
}
return nil
}
@@ -458,3 +501,113 @@ func AdminCKBDevices(c *gin.Context) {
"total": total,
})
}
// AdminCKBPlans GET /api/admin/ckb/plans 管理端-存客宝获客计划列表(供链接人与事选择计划一键覆盖参数)
// 通过开放 API 获取 JWT再调用 /v1/plan/list返回精简后的计划列表。
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.Query("page")
if pageStr == "" {
pageStr = "1"
}
limitStr := c.Query("limit")
if limitStr == "" {
limitStr = "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"
if len(values) > 0 {
planURL += "?" + 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
}
// 计划返回结构:参考设备列表解析方式,尽可能兼容 {data:{list,total}} 或 {list,total}
var listAny interface{}
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
listAny = dataVal["list"]
if _, ok := parsed["total"]; !ok {
if tv, ok := dataVal["total"]; ok {
parsed["total"] = tv
}
}
} else if la, ok := parsed["list"]; ok {
listAny = la
}
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
}
plans = append(plans, map[string]interface{}{
"id": m["planId"],
"name": m["name"],
"apiKey": m["apiKey"],
"sceneId": m["sceneId"],
"scenario": m["scenario"],
"enabled": m["enabled"],
"greeting": m["greeting"],
"tips": m["tips"],
"remarkType": m["remarkType"],
"remarkFormat": m["remarkFormat"],
"addInterval": m["addInterval"],
"startTime": m["startTime"],
"endTime": m["endTime"],
"deviceGroups": m["deviceGroups"],
})
}
}
total := 0
switch tv := parsed["total"].(type) {
case float64:
total = int(tv)
case int:
total = tv
case string:
if n, err := strconv.Atoi(tv); err == nil {
total = n
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"plans": plans,
"total": total,
})
}

View File

@@ -203,3 +203,86 @@ func CronSyncOrders(c *gin.Context) {
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// RunSyncVipCkbPlans 扫描已到期 VIP 用户,自动停用其绑定 Person 的存客宝计划
// - 最佳努力:停用失败只记日志,不中断整体任务
// - 幂等:重复执行不会产生额外副作用(计划已停用则仍然 update
func RunSyncVipCkbPlans(ctx context.Context, limit int) (scanned, disabled int, err error) {
if limit < 1 {
limit = 200
}
if limit > 2000 {
limit = 2000
}
db := database.DB()
// 只处理“有过期日且已过期,并且绑定了 Person(user_id) 且有 planId”的用户
// 说明persons.user_id 为新增字段;历史未绑定的不在本任务处理范围内
type row struct {
UserID string `gorm:"column:user_id"`
PlanID int64 `gorm:"column:ckb_plan_id"`
Nickname string `gorm:"column:nickname"`
}
rows := make([]row, 0)
q := `
SELECT u.id as user_id, p.ckb_plan_id, COALESCE(u.nickname,'') as nickname
FROM users u
INNER JOIN persons p ON p.user_id = u.id
WHERE u.is_vip = 1
AND u.vip_expire_date IS NOT NULL
AND u.vip_expire_date <= NOW()
AND p.ckb_plan_id > 0
ORDER BY u.vip_expire_date ASC
LIMIT ?`
if err := db.Raw(q, limit).Scan(&rows).Error; err != nil {
return 0, 0, err
}
scanned = len(rows)
if scanned == 0 {
return scanned, 0, nil
}
openToken, tokErr := ckbOpenGetToken()
if tokErr != nil {
// 没 token 直接失败,让 cron 重试(避免把用户标记成非 VIP 但计划未停用)
return scanned, 0, tokErr
}
for _, r := range rows {
select {
case <-ctx.Done():
return scanned, disabled, ctx.Err()
default:
}
if r.PlanID <= 0 || r.UserID == "" {
continue
}
if err := setCkbPlanEnabled(openToken, r.PlanID, false); err != nil {
syncOrdersLogf("停用存客宝计划失败: userId=%s, planId=%d, nickname=%s, err=%v", r.UserID, r.PlanID, r.Nickname, err)
continue
}
disabled++
syncOrdersLogf("已停用存客宝计划: userId=%s, planId=%d, nickname=%s", r.UserID, r.PlanID, r.Nickname)
// 兜底清理脏标记:到期用户将 is_vip 置为 0vip_expire_date 保留)
_ = db.Model(&model.User{}).Where("id = ?", r.UserID).Update("is_vip", false).Error
}
return scanned, disabled, nil
}
// CronSyncVipCkbPlans GET/POST /api/cron/sync-vip-ckb-plans
// ?limit=200 每次最多处理 N 个到期用户
func CronSyncVipCkbPlans(c *gin.Context) {
limit := 200
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
limit = n
}
}
scanned, disabled, err := RunSyncVipCkbPlans(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "scanned": scanned, "disabled": disabled})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "scanned": scanned, "disabled": disabled, "limit": limit})
}

View File

@@ -0,0 +1,91 @@
package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBCKBPersonLeads GET /api/db/ckb-person-leads
// - 不带 token返回每个 Person 的获客数(基于 ckb_lead_records.target_person_id
// - 带 token返回该 Person 的获客明细(分页)
func DBCKBPersonLeads(c *gin.Context) {
db := database.DB()
token := strings.TrimSpace(c.Query("token"))
// 1) 汇总:每个人物的获客数
if token == "" {
type Row struct {
Token string `json:"token"`
Total int64 `json:"total"`
}
var rows []Row
// persons.token 唯一;左连接保证没获客也能返回 total=0
if err := db.Raw(`
SELECT p.token AS token, COUNT(l.id) AS total
FROM persons p
LEFT JOIN ckb_lead_records l ON l.target_person_id = p.person_id
GROUP BY p.token
`).Scan(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "byPerson": rows})
return
}
// 2) 明细:某个人物的获客列表
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
}
var person model.Person
// token 是管理端/小程序统一引用的主键;兜底允许传 personId便于排查/手工调用)
if err := db.Where("token = ? OR person_id = ?", token, token).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到人物"})
return
}
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id = ?", person.PersonID)
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"nickname": r.Nickname,
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"records": out,
"total": total,
"page": page,
"pageSize": pageSize,
"personName": person.Name,
})
}

View File

@@ -2,7 +2,10 @@ package handler
import (
"net/http"
"math/rand"
"strconv"
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
@@ -11,14 +14,97 @@ import (
"github.com/gin-gonic/gin"
)
func isDigits12(s string) bool {
if len(s) != 12 {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func isZId12(s string) bool {
if len(s) != 12 || s[0] != 'z' {
return false
}
for i := 1; i < 12; i++ {
ch := s[i]
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') {
continue
}
return false
}
return true
}
func genZId12() string {
const letters = "0123456789abcdefghijklmnopqrstuvwxyz"
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 12)
b[0] = 'z'
for i := 1; i < 12; i++ {
b[i] = letters[rnd.Intn(len(letters))]
}
return string(b)
}
// DBLinkTagList GET /api/db/link-tags 管理端-链接标签列表
func DBLinkTagList(c *gin.Context) {
var rows []model.LinkTag
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
// 兼容旧前端:无分页/搜索参数时返回全量
pageStr := strings.TrimSpace(c.Query("page"))
pageSizeStr := strings.TrimSpace(c.Query("pageSize"))
search := strings.TrimSpace(c.Query("search"))
if pageStr == "" && pageSizeStr == "" && search == "" {
var rows []model.LinkTag
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
return
}
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
db := database.DB()
q := db.Model(&model.LinkTag{})
if search != "" {
like := "%" + search + "%"
q = q.Where("label LIKE ? OR tag_id LIKE ?", like, like)
}
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
var rows []model.LinkTag
offset := (page - 1) * pageSize
if err := q.Order("label ASC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
totalPages := (int(total) + pageSize - 1) / pageSize
c.JSON(http.StatusOK, gin.H{
"success": true,
"linkTags": rows,
"total": total,
"page": page,
"pageSize": pageSize,
"totalPages": totalPages,
})
}
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
@@ -36,6 +122,14 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
autoCreate := strings.TrimSpace(body.TagID) == ""
body.TagID = strings.TrimSpace(body.TagID)
body.Label = strings.TrimSpace(body.Label)
body.URL = strings.TrimSpace(body.URL)
body.Type = strings.TrimSpace(body.Type)
body.AppID = strings.TrimSpace(body.AppID)
body.PagePath = strings.TrimSpace(body.PagePath)
if body.Label == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"})
return
@@ -44,22 +138,37 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 只能包含汉字/字母/数字,不能为纯符号"})
return
}
if body.TagID == "" {
body.TagID = body.Label
}
if body.Type == "" {
body.Type = "url"
}
// tagId 规则12位数字或 12位且以 z 开头z + 11位[a-z0-9]
// 管理端新增:可不传 tagId由后端生成编辑通常会携带现有 tagId
if body.TagID == "" {
body.TagID = genZId12()
autoCreate = true
}
db := database.DB()
var existing model.LinkTag
// 若 tagId 不符合新规则:仅允许更新已有记录(兼容历史中文 tagId禁止新建
if !(isDigits12(body.TagID) || isZId12(body.TagID)) {
if err := db.Where("tag_id = ?", body.TagID).First(&existing).Error; err == nil {
// allow update existing legacy tagId
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 必须为12位数字或12位且以 z 开头z+11位小写字母数字"})
return
}
}
// 小程序类型:只存 appId + pagePath不存 weixin:// 到 url
if body.Type == "miniprogram" {
body.URL = ""
}
db := database.DB()
var existing model.LinkTag
// 按 label 查找:文章编辑自动创建场景,若已存在则直接返回
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
// 按 label 查找仅用于「自动创建」场景tagId 为空时回落 label若已存在则直接返回
if autoCreate {
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
@@ -76,6 +185,15 @@ func DBLinkTagSave(c *gin.Context) {
// body.URL 已在 miniprogram 类型时置空
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
// 极低概率:生成的 tagId 冲突,重试一次
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
t.TagID = genZId12()
if e2 := db.Create(&t).Error; e2 == nil {
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}

View File

@@ -46,6 +46,7 @@ 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"`
@@ -83,6 +84,7 @@ func DBPersonSave(c *gin.Context) {
}
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Aliases = strings.TrimSpace(body.Aliases)
existing.Label = body.Label
existing.CkbApiKey = body.CkbApiKey
existing.Greeting = body.Greeting
@@ -197,6 +199,7 @@ func DBPersonSave(c *gin.Context) {
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Aliases: strings.TrimSpace(body.Aliases),
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,
@@ -226,7 +229,8 @@ func DBPersonSave(c *gin.Context) {
}
// createPersonMinimal 仅按 name 创建 Person含存客宝计划供 autolink 复用
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("name 必填")
@@ -271,6 +275,7 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
}
}
newPerson := model.Person{
UserID: strPtrIfNotEmpty(userID),
PersonID: personID,
Token: tok,
Name: name,
@@ -287,6 +292,103 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
return &newPerson, nil
}
func strPtrIfNotEmpty(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return &s
}
// setCkbPlanEnabled 将存客宝计划置为启用/停用(最佳努力)
func setCkbPlanEnabled(openToken string, planID int64, enabled bool) error {
if planID <= 0 {
return fmt.Errorf("planID 无效")
}
status := 0
if enabled {
status = 1
}
payload := map[string]interface{}{
"planId": planID,
"status": status,
"enabled": enabled,
"scenario": 9, // 兜底:部分接口可能要求带 scenario与 create 保持一致
}
_, err := ckbOpenUpdatePlan(openToken, payload)
return err
}
// ensurePersonForUser 确保用户对应的 Person 存在(用于超级个体开通成功后的自动创建)
// 幂等规则:
// 1) 优先按 persons.user_id 查;存在则必要时同步 name=nickname
// 2) 若无 user_id 记录,则按 name=nickname 兜底复用;若复用成功且 user_id 为空则补绑
// 3) 都不存在则创建(含 CKB 计划)
//
// 该逻辑为“最佳努力”,调用方不应因失败而阻断支付/权益激活。
func ensurePersonForUser(db *gorm.DB, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("userID 不能为空")
}
var user model.User
if err := db.Select("id", "nickname").Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Errorf("用户不存在: %w", err)
}
nickname := ""
if user.Nickname != nil {
nickname = strings.TrimSpace(*user.Nickname)
}
if nickname == "" {
return fmt.Errorf("用户昵称为空,跳过创建 Person")
}
if !isValidNameOrLabel(nickname) {
return fmt.Errorf("用户昵称不符合 Person.name 规则,跳过创建 Person")
}
// 获取 CKB open token仅在需要启用计划时使用失败不阻断
openToken, _ := ckbOpenGetToken()
// 1) 按 user_id 查
var p model.Person
if err := db.Where("user_id = ?", userID).First(&p).Error; err == nil {
// 同步展示名(跟随昵称)
if strings.TrimSpace(p.Name) != nickname {
db.Model(&p).Updates(map[string]interface{}{"name": nickname, "updated_at": time.Now()})
}
// 续费/恢复:若已有计划则尝试重新启用(最佳努力)
if openToken != "" && p.CkbPlanID > 0 {
_ = setCkbPlanEnabled(openToken, p.CkbPlanID, true)
}
return nil
}
// 2) 按 name 兜底复用
var byName model.Person
if err := db.Where("name = ?", nickname).First(&byName).Error; err == nil {
// 若未绑定 user_id补绑并确保 name 为昵称
updates := map[string]interface{}{}
if byName.UserID == nil || strings.TrimSpace(*byName.UserID) == "" {
updates["user_id"] = userID
}
if strings.TrimSpace(byName.Name) != nickname {
updates["name"] = nickname
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
db.Model(&byName).Updates(updates)
}
if openToken != "" && byName.CkbPlanID > 0 {
_ = setCkbPlanEnabled(openToken, byName.CkbPlanID, true)
}
return nil
}
// 3) 创建
_, err := createPersonMinimal(db, nickname, userID)
return err
}
func genPersonToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {

View File

@@ -360,7 +360,7 @@ func GiftPayDetail(c *gin.Context) {
return
}
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" && gpr.Status != "refunded" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
return
}
@@ -431,6 +431,8 @@ func GiftPayDetail(c *gin.Context) {
action = "pay"
} else if gpr.Status == "paid" {
action = "share"
} else if gpr.Status == "refunded" {
action = "refunded"
} else if gpr.Status == "pending" {
action = "share" // 旧版:待好友付
}
@@ -449,6 +451,8 @@ func GiftPayDetail(c *gin.Context) {
} else {
action = "redeem"
}
} else if gpr.Status == "refunded" {
action = "refunded"
}
}

View File

@@ -690,6 +690,10 @@ func MiniprogramPayNotify(c *gin.Context) {
}
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"))
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力)
if err := ensurePersonForUser(db, beneficiaryUserID); err != nil {
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, orderSn=%s, err=%v\n", beneficiaryUserID, orderSn, err)
}
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", beneficiaryUserID, orderSn)
} else if attach.ProductType == "balance_recharge" {
@@ -1117,6 +1121,10 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
case "vip":
activateVIP(db, userID, 365, payTime)
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力,不阻断权益)
if err := ensurePersonForUser(db, userID); err != nil {
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, err=%v\n", userID, err)
}
case "balance_recharge":
ConfirmBalanceRechargeByOrder(db, order)
}

View File

@@ -274,5 +274,12 @@ func AdminOrderRefund(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()})
return
}
// 代付批量订单退款:同步更新 gift_pay_requests 状态,避免小程序仍可分享/领取
if order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
_ = db.Model(&model.GiftPayRequest{}).
Where("id = ?", *order.GiftPayRequestID).
Updates(map[string]interface{}{"status": "refunded"}).Error
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"})
}

View File

@@ -3,7 +3,7 @@ package model
import "time"
// GiftPayRequest 代付请求表(改造后:发起人创建并支付,好友领取)
// status: pending_pay待发起人支付| paid已支付待领取| cancelled | expired
// status: pending_pay待发起人支付| paid已支付待领取| refunded已退款不可再分享/领取)| cancelled | expired
type GiftPayRequest struct {
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
RequestSN string `gorm:"column:request_sn;uniqueIndex;size:32" json:"requestSn"`
@@ -12,7 +12,7 @@ type GiftPayRequest struct {
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_pay / paid / cancelled / expired
Status string `gorm:"column:status;size:20;index" json:"status"` // pending_pay / paid / refunded / cancelled / expired
Quantity int `gorm:"column:quantity;default:1" json:"quantity"`
RedeemedCount int `gorm:"column:redeemed_count;default:0" json:"redeemedCount"`
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`

View File

@@ -8,10 +8,14 @@ import "time"
type Person struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
// 绑定用户(用于“超级个体开通后自动创建@人”等幂等场景)
// 允许为空:历史数据/手工创建的 Person 不一定绑定 user
UserID *string `gorm:"column:user_id;size:50;uniqueIndex" json:"userId"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Token string `gorm:"column:token;size:36;index" json:"token"` // 32 位唯一 token文章/小程序传此值
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Name string `gorm:"column:name;size:100" json:"name"`
Aliases string `gorm:"column:aliases;size:500;default:''" json:"aliases"` // comma-separated alternative names (马甲)
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
Label string `gorm:"column:label;size:200" json:"label"`
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露

View File

@@ -87,6 +87,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
admin.GET("/ckb/devices", handler.AdminCKBDevices)
// 存客宝开放 API 辅助接口:获客计划列表(供链接人与事一键选择计划覆盖参数)
admin.GET("/ckb/plans", handler.AdminCKBPlans)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
@@ -147,6 +149,8 @@ func Setup(cfg *config.Config) *gin.Engine {
{
cron.GET("/sync-orders", handler.CronSyncOrders)
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
cron.POST("/unbind-expired", handler.CronUnbindExpired)
}
@@ -196,6 +200,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)
db.POST("/user-rules", handler.DBUserRulesAction)

View File

@@ -0,0 +1,10 @@
-- persons 表新增 user_id用于“超级个体开通后自动创建@人”等幂等绑定
-- 说明:
-- - 允许为空(历史数据/手工创建不绑定 user
-- - 允许多条 NULLMySQL UNIQUE 对 NULL 不冲突)
-- - 绑定后建议一人仅一条 Person满足“昵称同名@人”需求)
ALTER TABLE persons
ADD COLUMN user_id VARCHAR(50) DEFAULT NULL COMMENT '绑定用户ID幂等创建@人)',
ADD UNIQUE KEY uk_persons_user_id (user_id);