Files
Mycontent/soul-api/internal/handler/autolink.go
乘风 0ce86645b1 feat: enhance deployment scripts and experience index
- Added a new webhook integration for Feishu/Lark groups, allowing for custom bot notifications.
- Refactored deployment scripts to unify the deployment process, improving maintainability and reducing redundancy.
- Updated the experience index to include recent entries and improved documentation for various roles.

Made-with: Cursor
2026-04-15 15:23:50 +08:00

353 lines
10 KiB
Go
Raw Permalink 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"
"unicode"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
"gorm.io/gorm"
)
// linkTagPlainTextRe 正文里 #标签 的捕获:仅标签名,遇句读/引号/空格/#/@/</> 等即停止(勿用 [^\s]+ 以免吞入」。)
var linkTagPlainTextRe = regexp.MustCompile(`#([\p{L}\p{N}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\p{M}_-]+)`)
// sanitizeNameOrLabel 去除括号及内容,如 南风(管理) -> 南风
func sanitizeNameOrLabel(s string) string {
re := regexp.MustCompile(`\s*[(][^)]*(\)|)?`)
return strings.TrimSpace(re.ReplaceAllString(s, ""))
}
// isLinkTagBodyRune 标签名允许的字符(用于首尾剥掉句读、引号等标点,避免 #碎片时间」。 被整段吞入)
func isLinkTagBodyRune(r rune) bool {
if r == '_' || r == '-' {
return true
}
if unicode.IsLetter(r) || unicode.IsNumber(r) {
return true
}
if unicode.Is(unicode.Han, r) {
return true
}
if unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Hangul, r) {
return true
}
if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Me, r) {
return true
}
return false
}
func trimLinkTagEdgePunct(s string) string {
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
if isLinkTagBodyRune(r) {
break
}
s = s[size:]
}
for len(s) > 0 {
r, size := utf8.DecodeLastRuneInString(s)
if isLinkTagBodyRune(r) {
break
}
s = s[:len(s)-size]
}
return s
}
// sanitizeLinkTagLabel 链接标签展示名:去括号说明、去首尾标点与杂符号(与 # 解析规则一致)
func sanitizeLinkTagLabel(s string) string {
s = strings.TrimSpace(s)
s = sanitizeNameOrLabel(s)
s = trimLinkTagEdgePunct(s)
return strings.TrimSpace(s)
}
// isValidNameOrLabel 仅允许包含至少一个汉字/字母/数字,纯符号(如 "、,、!)则跳过,不创建
func isValidNameOrLabel(s string) bool {
if s == "" {
return false
}
// 至少包含一个 Unicode 字母或数字
re := regexp.MustCompile(`[\p{L}\p{N}]`)
return re.MatchString(s)
}
// splitPersonAliases 解析 persons.aliases逗号 / 中文逗号 / 顿号分隔)
func splitPersonAliases(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
s = strings.ReplaceAll(s, "", ",")
s = strings.ReplaceAll(s, "、", ",")
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
out = append(out, t)
}
}
return out
}
// buildPersonAliasTokenMap 别名 -> person.token同一别名多条人物时按 id 升序先命中者优先)
func buildPersonAliasTokenMap(db *gorm.DB) map[string]string {
var rows []model.Person
if err := db.Select("id", "token", "aliases").
Where("aliases IS NOT NULL AND aliases != ?", "").
Order("id ASC").
Find(&rows).Error; err != nil || len(rows) == 0 {
return map[string]string{}
}
out := make(map[string]string)
for _, p := range rows {
tok := strings.TrimSpace(p.Token)
if tok == "" {
continue
}
for _, a := range splitPersonAliases(p.Aliases) {
key := sanitizeNameOrLabel(a)
if key == "" || !isValidNameOrLabel(key) {
continue
}
if _, exists := out[key]; !exists {
out[key] = tok
}
}
}
return out
}
// ensurePersonByName 按名称或 aliases 确保 Person 存在,不存在则创建(含存客宝计划),返回 token
func ensurePersonByName(db *gorm.DB, name string, aliasToken map[string]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
}
if aliasToken != nil {
if tok := aliasToken[clean]; tok != "" {
return tok, nil
}
if tok := aliasToken[name]; tok != "" {
return tok, nil
}
}
// 无匹配人物:先建占位 users无 open_id再建 Person 并绑定 user_id便于超级个体/会员体系与 @ 一致
u, err := createUserPlaceholderForArticleMention(db, clean)
if err != nil {
return "", err
}
created, err := createPersonMinimal(db, clean, u.ID)
if err != nil {
_ = db.Unscoped().Where("id = ?", u.ID).Delete(&model.User{})
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 := sanitizeLinkTagLabel(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()
aliasToken := buildPersonAliasTokenMap(db)
// 1. 提取所有 @name 和 #label排除 <> 避免匹配到 HTML 标签内)
mentionRe := regexp.MustCompile(`@([^\s@#<>]+)`)
tagRe := linkTagPlainTextRe
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, aliasToken)
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 := sanitizeLinkTagLabel(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 := sanitizeLinkTagLabel(raw)
if clean == "" || !isValidNameOrLabel(clean) {
return m
}
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">#` + clean + `</span>`
})
// 4. 恢复占位符
for _, p := range placeholders {
content = strings.Replace(content, "\x00PLACEHOLDER\x00", p, 1)
}
return content, nil
}