2026-03-16 17:18:49 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-18 20:33:50 +08:00
|
|
|
|
created, err := createPersonMinimal(db, clean, "")
|
2026-03-16 17:18:49 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return created.Token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:54:14 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 17:18:49 +08:00
|
|
|
|
// 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 {
|
2026-03-16 17:54:14 +08:00
|
|
|
|
// 回填 data-id、data-label:TipTap 用 data-label 显示名字,缺则显示 token
|
2026-03-16 17:18:49 +08:00
|
|
|
|
idRe := regexp.MustCompile(`data-id="([^"]*)"`)
|
|
|
|
|
|
labelRe := regexp.MustCompile(`data-label="([^"]*)"`)
|
|
|
|
|
|
innerRe := regexp.MustCompile(`>([^<]+)<`)
|
|
|
|
|
|
nickname := ""
|
|
|
|
|
|
if labelRe.MatchString(m) {
|
|
|
|
|
|
sub := labelRe.FindStringSubmatch(m)
|
2026-03-16 17:54:14 +08:00
|
|
|
|
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
|
2026-03-16 17:18:49 +08:00
|
|
|
|
nickname = sanitizeNameOrLabel(sub[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if nickname == "" && innerRe.MatchString(m) {
|
|
|
|
|
|
sub := innerRe.FindStringSubmatch(m)
|
|
|
|
|
|
if len(sub) >= 2 {
|
|
|
|
|
|
nickname = sanitizeNameOrLabel(strings.TrimPrefix(sub[1], "@"))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 17:54:14 +08:00
|
|
|
|
// 若 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 17:18:49 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 17:54:14 +08:00
|
|
|
|
// 确保有 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)
|
|
|
|
|
|
}
|
2026-03-16 17:18:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-16 17:54:14 +08:00
|
|
|
|
return `<span data-type="mention" data-id="` + token + `" data-label="` + raw + `" class="mention-tag">@` + raw + `</span>`
|
2026-03-16 17:18:49 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|