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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 按条件查章节并返回统一格式
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 置为 0(vip_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})
|
||||
}
|
||||
|
||||
91
soul-api/internal/handler/db_ckb_person_leads.go
Normal file
91
soul-api/internal/handler/db_ckb_person_leads.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "退款成功"})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"` // 存客宝真实密钥,不对外暴露
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
soul-api/scripts/add-persons-user-id.sql
Normal file
10
soul-api/scripts/add-persons-user-id.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- persons 表新增 user_id:用于“超级个体开通后自动创建@人”等幂等绑定
|
||||
-- 说明:
|
||||
-- - 允许为空(历史数据/手工创建不绑定 user)
|
||||
-- - 允许多条 NULL(MySQL 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);
|
||||
|
||||
Reference in New Issue
Block a user