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(`]*data-type="mention"[^>]*>.*?`) linkTagSpanRe := regexp.MustCompile(`]*data-type="linkTag"[^>]*>.*?`) placeholders := []string{} content = mentionSpanRe.ReplaceAllStringFunc(content, func(m string) string { // 回填 data-id、data-label:TipTap 用 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 `@` + raw + `` }) 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 `#` + raw + `` }) // 4. 恢复占位符 for _, p := range placeholders { content = strings.Replace(content, "\x00PLACEHOLDER\x00", p, 1) } return content, nil }