Enhance profile editing and sharing functionality

- Added a new feature for sharing profile cards, including special handling for forwarding to friends and displaying a canvas cover with user information.
- Updated the mini program's profile-edit page to generate a shareable card with a structured layout, including user avatar, nickname, and additional information.
- Improved the documentation to reflect the new sharing capabilities and updated the last modified date for relevant entries.
This commit is contained in:
Alex-larget
2026-03-16 17:18:49 +08:00
parent 219ae3b843
commit 9210b931c4
19 changed files with 948 additions and 338 deletions

View File

@@ -0,0 +1,205 @@
package handler
import (
"regexp"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"gorm.io/gorm"
)
// sanitizeNameOrLabel 去除括号及内容,如 南风(管理) -> 南风
func sanitizeNameOrLabel(s string) string {
re := regexp.MustCompile(`\s*[(][^)]*(\)|)?`)
return strings.TrimSpace(re.ReplaceAllString(s, ""))
}
// isValidNameOrLabel 仅允许包含至少一个汉字/字母/数字,纯符号(如 "、,、!)则跳过,不创建
func isValidNameOrLabel(s string) bool {
if s == "" {
return false
}
// 至少包含一个 Unicode 字母或数字
re := regexp.MustCompile(`[\p{L}\p{N}]`)
return re.MatchString(s)
}
// ensurePersonByName 按名称确保 Person 存在,不存在则创建(含存客宝计划),返回 token
func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
name = strings.TrimSpace(name)
if name == "" {
return "", nil
}
clean := sanitizeNameOrLabel(name)
if clean == "" || !isValidNameOrLabel(clean) {
return "", nil
}
var p model.Person
if db.Where("name = ?", clean).First(&p).Error == nil {
return p.Token, nil
}
// 按净化后的名字再查一次
if db.Where("name = ?", name).First(&p).Error == nil {
return p.Token, nil
}
created, err := createPersonMinimal(db, clean)
if err != nil {
return "", err
}
return created.Token, nil
}
// ensureLinkTagByLabel 按 label 确保 LinkTag 存在,不存在则创建
func ensureLinkTagByLabel(db *gorm.DB, label string) (tagID string, err error) {
label = strings.TrimSpace(label)
if label == "" {
return "", nil
}
clean := sanitizeNameOrLabel(label)
if clean == "" || !isValidNameOrLabel(clean) {
return "", nil
}
var t model.LinkTag
if db.Where("label = ?", clean).First(&t).Error == nil {
return t.TagID, nil
}
if db.Where("label = ?", label).First(&t).Error == nil {
return t.TagID, nil
}
t = model.LinkTag{TagID: clean, Label: clean, Type: "url"}
if err := db.Create(&t).Error; err != nil {
return "", err
}
return t.TagID, nil
}
// ParseAutoLinkContent 解析 content 中的 @人物 和 #标签,确保存在并转为带 data-id 的 span后端统一处理
func ParseAutoLinkContent(content string) (string, error) {
if content == "" || (!strings.Contains(content, "@") && !strings.Contains(content, "#")) {
return content, nil
}
db := database.DB()
// 1. 提取所有 @name 和 #label排除 <> 避免匹配到 HTML 标签内)
mentionRe := regexp.MustCompile(`@([^\s@#<>]+)`)
tagRe := regexp.MustCompile(`#([^\s@#<>]+)`)
names := make(map[string]string) // cleanName -> token
labels := make(map[string]string) // cleanLabel -> tagId
for _, m := range mentionRe.FindAllStringSubmatch(content, -1) {
if len(m) < 2 {
continue
}
raw := m[1]
clean := sanitizeNameOrLabel(raw)
if clean == "" || !isValidNameOrLabel(clean) || names[clean] != "" {
continue
}
token, err := ensurePersonByName(db, clean)
if err == nil && token != "" {
names[clean] = token
names[raw] = token // 原始名也映射
}
}
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
if len(m) < 2 {
continue
}
raw := m[1]
clean := sanitizeNameOrLabel(raw)
if clean == "" || !isValidNameOrLabel(clean) || labels[clean] != "" {
continue
}
tagID, err := ensureLinkTagByLabel(db, clean)
if err == nil && tagID != "" {
labels[clean] = tagID
labels[raw] = tagID
}
}
// 2. 占位符替换:先把已有 mention/linkTag span 保护起来
mentionSpanRe := regexp.MustCompile(`<span[^>]*data-type="mention"[^>]*>.*?</span>`)
linkTagSpanRe := regexp.MustCompile(`<span[^>]*data-type="linkTag"[^>]*>.*?</span>`)
placeholders := []string{}
content = mentionSpanRe.ReplaceAllStringFunc(content, func(m string) string {
// 回填 data-id若为空则按昵称匹配
idRe := regexp.MustCompile(`data-id="([^"]*)"`)
labelRe := regexp.MustCompile(`data-label="([^"]*)"`)
innerRe := regexp.MustCompile(`>([^<]+)<`)
nickname := ""
if idRe.MatchString(m) {
sub := idRe.FindStringSubmatch(m)
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
placeholders = append(placeholders, m)
return "\x00PLACEHOLDER\x00"
}
}
if labelRe.MatchString(m) {
sub := labelRe.FindStringSubmatch(m)
if len(sub) >= 2 {
nickname = sanitizeNameOrLabel(sub[1])
}
}
if nickname == "" && innerRe.MatchString(m) {
sub := innerRe.FindStringSubmatch(m)
if len(sub) >= 2 {
nickname = sanitizeNameOrLabel(strings.TrimPrefix(sub[1], "@"))
}
}
if nickname != "" {
if token, ok := names[nickname]; ok && token != "" {
// 插入或替换 data-id
if idRe.MatchString(m) {
m = idRe.ReplaceAllString(m, `data-id="`+token+`"`)
} else {
// 在 data-type 后插入 data-id
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-id="`+token+`"`, 1)
}
}
}
placeholders = append(placeholders, m)
return "\x00PLACEHOLDER\x00"
})
content = linkTagSpanRe.ReplaceAllStringFunc(content, func(m string) string {
placeholders = append(placeholders, m)
return "\x00PLACEHOLDER\x00"
})
// 3. 替换纯文本 @name 和 #label
content = mentionRe.ReplaceAllStringFunc(content, func(m string) string {
raw := strings.TrimPrefix(m, "@")
clean := sanitizeNameOrLabel(raw)
token := names[clean]
if token == "" {
token = names[raw]
}
if token == "" {
return m
}
return `<span data-type="mention" data-id="` + token + `" class="mention-tag">@` + raw + `</span>`
})
content = tagRe.ReplaceAllStringFunc(content, func(m string) string {
raw := strings.TrimPrefix(m, "#")
clean := sanitizeNameOrLabel(raw)
tagID := labels[clean]
if tagID == "" {
tagID = labels[raw]
}
if tagID == "" {
return m
}
return `<span data-type="linkTag" data-url="" data-tag-type="url" data-tag-id="` + tagID + `" data-page-path="" data-app-id="" class="link-tag-node">#` + raw + `</span>`
})
// 4. 恢复占位符
for _, p := range placeholders {
content = strings.Replace(content, "\x00PLACEHOLDER\x00", p, 1)
}
return content, nil
}

View File

@@ -389,6 +389,7 @@ func CKBIndexLead(c *gin.Context) {
q.Set("phone", phone)
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
fmt.Printf("[CKBIndexLead] 请求存客宝完整链接: %s\n", reqURL)
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
@@ -463,18 +464,24 @@ func CKBLead(c *gin.Context) {
name = "小程序用户"
}
// 确定使用哪个存客宝密钥:被@人物的 ckb_api_key > 全局system_config > .env > 代码内置)
// 存客宝 scenarios 内部 API 需要计划级 apiKeypersons.ckb_api_key不是 token
// 文章 @ 场景targetUserId=token → 查 Person 取 CkbApiKey 作为 leadKey
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname) // 被@人的显示名,用于成功文案
targetName := strings.TrimSpace(body.TargetNickname)
if body.TargetUserID != "" {
var p model.Person
if db.Where("token = ?", body.TargetUserID).First(&p).Error == nil {
if p.CkbApiKey != "" {
leadKey = p.CkbApiKey
}
if targetName == "" {
targetName = p.Name
}
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
return
}
if strings.TrimSpace(p.CkbApiKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
return
}
leadKey = p.CkbApiKey
if targetName == "" {
targetName = p.Name
}
}
@@ -551,6 +558,7 @@ func CKBLead(c *gin.Context) {
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
fmt.Printf("[CKBLead] 请求存客宝完整链接: %s\n", reqURL)
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
@@ -562,6 +570,7 @@ func CKBLead(c *gin.Context) {
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
@@ -585,10 +594,23 @@ func CKBLead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
errMsg := result.Message
ckbMsg := strings.TrimSpace(result.Message)
if ckbMsg == "" {
ckbMsg = strings.TrimSpace(result.Msg)
}
errMsg := ckbMsg
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s\n", result.Code, ckbMsg, string(b))
respObj := gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": ckbMsg,
}
if ckbMsg == "" && len(b) > 0 {
respObj["ckbRaw"] = string(b) // 存客保未返回 message/msg 时透传原始响应,供调试
}
c.JSON(http.StatusOK, respObj)
}

View File

@@ -127,6 +127,20 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
}
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey若存客宝直接返回则复用避免二次请求
func parseApiKeyFromCreateData(data map[string]interface{}) string {
for _, key := range []string{"apiKey", "api_key"} {
v, ok := data[key]
if !ok || v == nil {
continue
}
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
// parsePlanIDFromData 从 data 中解析 planId支持 number 或 string若无则尝试 id
func parsePlanIDFromData(data map[string]interface{}) int64 {
for _, key := range []string{"planId", "id"} {
@@ -412,14 +426,14 @@ func AdminCKBDevices(c *gin.Context) {
status = "online"
}
devices = append(devices, map[string]interface{}{
"id": id,
"memo": memo,
"imei": m["imei"],
"wechatId": wechatID,
"status": status,
"avatar": m["avatar"],
"nickname": m["nickname"],
"usedInPlan": m["usedInPlans"],
"id": id,
"memo": memo,
"imei": m["imei"],
"wechatId": wechatID,
"status": status,
"avatar": m["avatar"],
"nickname": m["nickname"],
"usedInPlan": m["usedInPlans"],
"totalFriend": m["totalFriend"],
})
}
@@ -444,4 +458,3 @@ func AdminCKBDevices(c *gin.Context) {
"total": total,
})
}

View File

@@ -453,7 +453,8 @@ func DBBookAction(c *gin.Context) {
if item.IsFree != nil {
isFree = *item.IsFree
}
wordCount := len(item.Content)
processed, _ := ParseAutoLinkContent(item.Content)
wordCount := len(processed)
status := "published"
editionStandard, editionPremium := true, false
ch := model.Chapter{
@@ -463,7 +464,7 @@ func DBBookAction(c *gin.Context) {
ChapterID: strPtr(item.ChapterID, "chapter-1"),
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
SectionTitle: item.Title,
Content: item.Content,
Content: processed,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,
@@ -604,10 +605,16 @@ func DBBookAction(c *gin.Context) {
if body.IsFree != nil {
isFree = *body.IsFree
}
wordCount := len(body.Content)
// 后端统一解析 @/# 并转为带 data-id 的 span
processedContent, err := ParseAutoLinkContent(body.Content)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析 @/# 失败: " + err.Error()})
return
}
wordCount := len(processedContent)
updates := map[string]interface{}{
"section_title": body.Title,
"content": body.Content,
"content": processedContent,
"word_count": wordCount,
"price": price,
"is_free": isFree,
@@ -641,7 +648,7 @@ func DBBookAction(c *gin.Context) {
updates["chapter_title"] = body.ChapterTitle
}
var existing model.Chapter
err := db.Where("id = ?", body.ID).First(&existing).Error
err = db.Where("id = ?", body.ID).First(&existing).Error
if err == gorm.ErrRecordNotFound {
// 新建Create
partID := body.PartID
@@ -674,7 +681,7 @@ func DBBookAction(c *gin.Context) {
ChapterID: chapterID,
ChapterTitle: chapterTitle,
SectionTitle: body.Title,
Content: body.Content,
Content: processedContent,
WordCount: &wordCount,
IsFree: &isFree,
Price: &price,

View File

@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -37,6 +38,10 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"})
return
}
if !isValidNameOrLabel(strings.TrimSpace(body.Label)) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 只能包含汉字/字母/数字,不能为纯符号"})
return
}
if body.TagID == "" {
body.TagID = body.Label
}

View File

@@ -12,6 +12,7 @@ import (
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
@@ -64,6 +65,10 @@ func DBPersonSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
return
}
if !isValidNameOrLabel(strings.TrimSpace(body.Name)) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 只能包含汉字/字母/数字,不能为纯符号"})
return
}
db := database.DB()
var existing model.Person
// 按 name 查找文章编辑自动创建场景PersonID 为空时先查是否已存在
@@ -124,7 +129,6 @@ func DBPersonSave(c *gin.Context) {
}
// 2. 构造创建计划请求体
// 参考 Cunkebao createPlanname, sceneId, scenario, remarkType, greeting, addInterval, startTime, endTime, enabled, tips, deviceGroups
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
addInterval := 1
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
@@ -144,7 +148,6 @@ func DBPersonSave(c *gin.Context) {
deviceIDs = append(deviceIDs, id)
}
}
// deviceGroups 必填:未传时默认选择名为 soul 的设备
if len(deviceIDs) == 0 {
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
if err != nil {
@@ -155,8 +158,10 @@ func DBPersonSave(c *gin.Context) {
}
planPayload := map[string]interface{}{
"name": name,
"sceneId": 11,
"scenario": 11,
"planType": 1,
"sceneId": 9,
"scenario": 9,
"status": 1,
"remarkType": body.RemarkType,
"greeting": body.Greeting,
"addInterval": addInterval,
@@ -178,11 +183,14 @@ func DBPersonSave(c *gin.Context) {
return
}
// 3. 用 planId 拉计划详情,获取 apiKey
apiKey, err := ckbOpenGetPlanDetail(openToken, planID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + err.Error()})
return
apiKey := parseApiKeyFromCreateData(ckbCreateData)
if apiKey == "" {
var getErr error
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
if getErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + getErr.Error()})
return
}
}
newPerson := model.Person{
@@ -217,6 +225,68 @@ func DBPersonSave(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// createPersonMinimal 仅按 name 创建 Person含存客宝计划供 autolink 复用
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("name 必填")
}
personID := fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(name, " ", "_")), time.Now().UnixMilli())
tok, err := genPersonToken()
if err != nil {
return nil, fmt.Errorf("生成 token 失败")
}
openToken, err := ckbOpenGetToken()
if err != nil {
return nil, err
}
planName := fmt.Sprintf("SOUL链接人与事-%s", name)
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
if err != nil {
return nil, fmt.Errorf("获取默认设备失败: %w", err)
}
planPayload := map[string]interface{}{
"name": planName,
"planType": 1,
"sceneId": 9,
"scenario": 9,
"status": 1,
"addInterval": 1,
"startTime": "09:00",
"endTime": "18:00",
"enabled": true,
"distributionEnabled": false,
"deviceGroups": []int64{defaultID},
}
planID, createData, _, err := ckbOpenCreatePlan(openToken, planPayload)
if err != nil {
return nil, fmt.Errorf("创建存客宝计划失败: %w", err)
}
apiKey := parseApiKeyFromCreateData(createData)
if apiKey == "" {
var getErr error
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
if getErr != nil {
return nil, fmt.Errorf("获取计划密钥失败: %w", getErr)
}
}
newPerson := model.Person{
PersonID: personID,
Token: tok,
Name: name,
CkbApiKey: apiKey,
CkbPlanID: planID,
AddFriendInterval: 1,
StartTime: "09:00",
EndTime: "18:00",
DeviceGroups: fmt.Sprintf("%d", defaultID),
}
if err := db.Create(&newPerson).Error; err != nil {
return nil, err
}
return &newPerson, nil
}
func genPersonToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {