Files
soul-yongping/soul-api/internal/handler/autolink.go
卡若 76965adb23 chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
2026-03-17 17:50:12 +08:00

237 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// getPersonNameByToken 按 token 查 Person 返回 name用于修复已损坏的 mention显示 token 而非名字)
func getPersonNameByToken(db *gorm.DB, token string) string {
if token == "" {
return ""
}
var p model.Person
if db.Select("name").Where("token = ?", token).First(&p).Error != nil {
return ""
}
return p.Name
}
// 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、data-labelTipTap 用 data-label 显示名字,缺则显示 token
idRe := regexp.MustCompile(`data-id="([^"]*)"`)
labelRe := regexp.MustCompile(`data-label="([^"]*)"`)
innerRe := regexp.MustCompile(`>([^<]+)<`)
nickname := ""
if labelRe.MatchString(m) {
sub := labelRe.FindStringSubmatch(m)
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
nickname = sanitizeNameOrLabel(sub[1])
}
}
if nickname == "" && innerRe.MatchString(m) {
sub := innerRe.FindStringSubmatch(m)
if len(sub) >= 2 {
nickname = sanitizeNameOrLabel(strings.TrimPrefix(sub[1], "@"))
}
}
// 若 inner 是 token如已损坏显示 @pZefXfWlon...),用 token 查 Person 取真实名字
if idRe.MatchString(m) {
sub := idRe.FindStringSubmatch(m)
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
tokenVal := sub[1]
innerSub := innerRe.FindStringSubmatch(m)
innerText := ""
if len(innerSub) >= 2 {
innerText = strings.TrimPrefix(innerSub[1], "@")
}
// 若 inner 看起来像 token长串字母数字且与 data-id 一致,用 DB 查真实名字
if innerText == tokenVal && len(tokenVal) >= 20 {
if realName := getPersonNameByToken(db, tokenVal); realName != "" {
nickname = realName
}
}
}
}
if nickname != "" {
if token, ok := names[nickname]; ok && token != "" {
if idRe.MatchString(m) {
m = idRe.ReplaceAllString(m, `data-id="`+token+`"`)
} else {
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-id="`+token+`"`, 1)
}
}
// 确保有 data-label否则 TipTap 会显示 token 而非名字
needLabel := !labelRe.MatchString(m)
if !needLabel {
if sub := labelRe.FindStringSubmatch(m); len(sub) >= 2 && strings.TrimSpace(sub[1]) == "" {
needLabel = true
}
}
if needLabel {
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-label="`+nickname+`"`, 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 + `" data-label="` + raw + `" 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
}