714 lines
17 KiB
Go
714 lines
17 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math/rand"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"git.echol.cn/loser/st/server/global"
|
||
"git.echol.cn/loser/st/server/model/app"
|
||
"git.echol.cn/loser/st/server/model/app/request"
|
||
"git.echol.cn/loser/st/server/model/app/response"
|
||
"github.com/google/uuid"
|
||
"github.com/lib/pq"
|
||
"go.uber.org/zap"
|
||
"gorm.io/datatypes"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type WorldInfoService struct{}
|
||
|
||
// CreateWorldBook 创建世界书
|
||
func (s *WorldInfoService) CreateWorldBook(userID uint, req *request.CreateWorldBookRequest) (*app.AIWorldInfo, error) {
|
||
// 验证条目 UID 唯一性
|
||
if err := s.validateEntries(req.Entries); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
entriesJSON, err := json.Marshal(req.Entries)
|
||
if err != nil {
|
||
return nil, errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
worldBook := &app.AIWorldInfo{
|
||
UserID: userID,
|
||
BookName: req.BookName,
|
||
IsGlobal: req.IsGlobal,
|
||
Entries: datatypes.JSON(entriesJSON),
|
||
LinkedChars: req.LinkedChars,
|
||
}
|
||
|
||
if err := global.GVA_DB.Create(worldBook).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return worldBook, nil
|
||
}
|
||
|
||
// UpdateWorldBook 更新世界书
|
||
func (s *WorldInfoService) UpdateWorldBook(userID, bookID uint, req *request.UpdateWorldBookRequest) error {
|
||
// 验证条目 UID 唯一性
|
||
if err := s.validateEntries(req.Entries); err != nil {
|
||
return err
|
||
}
|
||
|
||
entriesJSON, err := json.Marshal(req.Entries)
|
||
if err != nil {
|
||
return errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
result := global.GVA_DB.Model(&app.AIWorldInfo{}).
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Updates(map[string]interface{}{
|
||
"book_name": req.BookName,
|
||
"is_global": req.IsGlobal,
|
||
"entries": datatypes.JSON(entriesJSON),
|
||
"linked_chars": pq.StringArray(req.LinkedChars),
|
||
})
|
||
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("世界书不存在或无权限")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// DeleteWorldBook 删除世界书
|
||
func (s *WorldInfoService) DeleteWorldBook(userID, bookID uint) error {
|
||
result := global.GVA_DB.
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Delete(&app.AIWorldInfo{})
|
||
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("世界书不存在或无权限")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetWorldBook 获取世界书详情
|
||
func (s *WorldInfoService) GetWorldBook(userID, bookID uint) (*app.AIWorldInfo, error) {
|
||
var book app.AIWorldInfo
|
||
err := global.GVA_DB.
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
First(&book).Error
|
||
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, errors.New("世界书不存在")
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
return &book, nil
|
||
}
|
||
|
||
// GetWorldBookList 获取世界书列表
|
||
func (s *WorldInfoService) GetWorldBookList(userID uint, req *request.WorldBookListRequest) (*response.WorldBookListResponse, error) {
|
||
var books []app.AIWorldInfo
|
||
var total int64
|
||
|
||
db := global.GVA_DB.Model(&app.AIWorldInfo{}).Where("user_id = ?", userID)
|
||
|
||
// 条件过滤
|
||
if req.BookName != "" {
|
||
db = db.Where("book_name ILIKE ?", "%"+req.BookName+"%")
|
||
}
|
||
if req.IsGlobal != nil {
|
||
db = db.Where("is_global = ?", *req.IsGlobal)
|
||
}
|
||
if req.CharacterID != nil {
|
||
db = db.Where("? = ANY(linked_chars)", fmt.Sprintf("%d", *req.CharacterID))
|
||
}
|
||
|
||
// 总数
|
||
if err := db.Count(&total).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 分页查询
|
||
offset := (req.Page - 1) * req.PageSize
|
||
if err := db.Offset(offset).Limit(req.PageSize).Order("updated_at DESC").Find(&books).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 转换响应
|
||
list := make([]response.WorldBookResponse, 0, len(books))
|
||
for i := range books {
|
||
list = append(list, response.ToWorldBookResponse(&books[i]))
|
||
}
|
||
|
||
return &response.WorldBookListResponse{
|
||
List: list,
|
||
Total: total,
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
}, nil
|
||
}
|
||
|
||
// CreateWorldEntry 创建世界书条目
|
||
func (s *WorldInfoService) CreateWorldEntry(userID, bookID uint, entry *app.AIWorldInfoEntry) error {
|
||
book, err := s.GetWorldBook(userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var entries []app.AIWorldInfoEntry
|
||
if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil {
|
||
return errors.New("条目数据解析失败")
|
||
}
|
||
|
||
// 生成唯一 UID
|
||
if entry.UID == "" {
|
||
entry.UID = uuid.New().String()
|
||
}
|
||
|
||
// 检查 UID 是否重复
|
||
for _, e := range entries {
|
||
if e.UID == entry.UID {
|
||
return errors.New("条目 UID 已存在")
|
||
}
|
||
}
|
||
|
||
entries = append(entries, *entry)
|
||
|
||
entriesJSON, err := json.Marshal(entries)
|
||
if err != nil {
|
||
return errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
return global.GVA_DB.Model(&app.AIWorldInfo{}).
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Update("entries", datatypes.JSON(entriesJSON)).Error
|
||
}
|
||
|
||
// UpdateWorldEntry 更新世界书条目
|
||
func (s *WorldInfoService) UpdateWorldEntry(userID, bookID uint, entry *app.AIWorldInfoEntry) error {
|
||
book, err := s.GetWorldBook(userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var entries []app.AIWorldInfoEntry
|
||
if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil {
|
||
return errors.New("条目数据解析失败")
|
||
}
|
||
|
||
found := false
|
||
for i := range entries {
|
||
if entries[i].UID == entry.UID {
|
||
entries[i] = *entry
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return errors.New("条目不存在")
|
||
}
|
||
|
||
entriesJSON, err := json.Marshal(entries)
|
||
if err != nil {
|
||
return errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
return global.GVA_DB.Model(&app.AIWorldInfo{}).
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Update("entries", datatypes.JSON(entriesJSON)).Error
|
||
}
|
||
|
||
// DeleteWorldEntry 删除世界书条目
|
||
func (s *WorldInfoService) DeleteWorldEntry(userID, bookID uint, entryID string) error {
|
||
book, err := s.GetWorldBook(userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var entries []app.AIWorldInfoEntry
|
||
if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil {
|
||
return errors.New("条目数据解析失败")
|
||
}
|
||
|
||
newEntries := make([]app.AIWorldInfoEntry, 0)
|
||
found := false
|
||
for _, e := range entries {
|
||
if e.UID != entryID {
|
||
newEntries = append(newEntries, e)
|
||
} else {
|
||
found = true
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return errors.New("条目不存在")
|
||
}
|
||
|
||
entriesJSON, err := json.Marshal(newEntries)
|
||
if err != nil {
|
||
return errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
return global.GVA_DB.Model(&app.AIWorldInfo{}).
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Update("entries", datatypes.JSON(entriesJSON)).Error
|
||
}
|
||
|
||
// LinkCharactersToWorldBook 关联角色到世界书
|
||
func (s *WorldInfoService) LinkCharactersToWorldBook(userID, bookID uint, characterIDs []uint) error {
|
||
// 验证世界书是否存在
|
||
_, err := s.GetWorldBook(userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 验证角色是否属于该用户
|
||
var count int64
|
||
err = global.GVA_DB.Model(&app.AICharacter{}).
|
||
Where("user_id = ? AND id IN ?", userID, characterIDs).
|
||
Count(&count).Error
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if int(count) != len(characterIDs) {
|
||
return errors.New("部分角色不存在或无权限")
|
||
}
|
||
|
||
// 转换为字符串数组
|
||
linkedChars := make([]string, len(characterIDs))
|
||
for i, id := range characterIDs {
|
||
linkedChars[i] = fmt.Sprintf("%d", id)
|
||
}
|
||
|
||
return global.GVA_DB.Model(&app.AIWorldInfo{}).
|
||
Where("id = ? AND user_id = ?", bookID, userID).
|
||
Update("linked_chars", pq.StringArray(linkedChars)).Error
|
||
}
|
||
|
||
// ImportWorldBook 导入世界书
|
||
func (s *WorldInfoService) ImportWorldBook(userID uint, bookName string, data []byte, format string) (*app.AIWorldInfo, error) {
|
||
var entries []app.AIWorldInfoEntry
|
||
|
||
switch format {
|
||
case "json", "lorebook":
|
||
var importData struct {
|
||
Name string `json:"name"`
|
||
Entries []app.AIWorldInfoEntry `json:"entries"`
|
||
}
|
||
if err := json.Unmarshal(data, &importData); err != nil {
|
||
return nil, errors.New("JSON 格式解析失败")
|
||
}
|
||
entries = importData.Entries
|
||
if bookName == "" && importData.Name != "" {
|
||
bookName = importData.Name
|
||
}
|
||
default:
|
||
return nil, errors.New("不支持的导入格式")
|
||
}
|
||
|
||
if bookName == "" {
|
||
bookName = "导入的世界书"
|
||
}
|
||
|
||
// 为没有 UID 的条目生成 UID
|
||
for i := range entries {
|
||
if entries[i].UID == "" {
|
||
entries[i].UID = uuid.New().String()
|
||
}
|
||
}
|
||
|
||
// 验证条目 UID 唯一性
|
||
if err := s.validateEntries(entries); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
entriesJSON, err := json.Marshal(entries)
|
||
if err != nil {
|
||
return nil, errors.New("条目数据序列化失败")
|
||
}
|
||
|
||
worldBook := &app.AIWorldInfo{
|
||
UserID: userID,
|
||
BookName: bookName,
|
||
IsGlobal: false,
|
||
Entries: datatypes.JSON(entriesJSON),
|
||
LinkedChars: []string{},
|
||
}
|
||
|
||
if err := global.GVA_DB.Create(worldBook).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return worldBook, nil
|
||
}
|
||
|
||
// ExportWorldBook 导出世界书
|
||
func (s *WorldInfoService) ExportWorldBook(userID, bookID uint) (*response.WorldBookExportData, error) {
|
||
book, err := s.GetWorldBook(userID, bookID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var entries []app.AIWorldInfoEntry
|
||
if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil {
|
||
return nil, errors.New("条目数据解析失败")
|
||
}
|
||
|
||
return &response.WorldBookExportData{
|
||
Name: book.BookName,
|
||
Entries: entries,
|
||
}, nil
|
||
}
|
||
|
||
// MatchWorldInfo 匹配世界书条目(用于聊天时激活)
|
||
func (s *WorldInfoService) MatchWorldInfo(userID uint, req *request.MatchWorldInfoRequest) (*response.MatchWorldInfoResponse, error) {
|
||
// 获取角色关联的世界书
|
||
var books []app.AIWorldInfo
|
||
err := global.GVA_DB.
|
||
Where("user_id = ? AND (is_global = true OR ? = ANY(linked_chars))", userID, fmt.Sprintf("%d", req.CharacterID)).
|
||
Find(&books).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 合并所有世界书的条目
|
||
var allEntries []struct {
|
||
Entry app.AIWorldInfoEntry
|
||
Source string
|
||
}
|
||
|
||
for _, book := range books {
|
||
var entries []app.AIWorldInfoEntry
|
||
if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil {
|
||
global.GVA_LOG.Warn("世界书条目解析失败",
|
||
zap.Uint("bookID", book.ID),
|
||
zap.String("bookName", book.BookName),
|
||
zap.Error(err))
|
||
continue
|
||
}
|
||
|
||
for _, entry := range entries {
|
||
if entry.Enabled {
|
||
allEntries = append(allEntries, struct {
|
||
Entry app.AIWorldInfoEntry
|
||
Source string
|
||
}{Entry: entry, Source: book.BookName})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 匹配条目
|
||
matched := s.matchEntries(allEntries, req.Messages, req.ScanDepth, req.MaxTokens)
|
||
|
||
// 计算总 Token 数(简单估算:每 4 个字符 = 1 token)
|
||
totalTokens := 0
|
||
for _, m := range matched {
|
||
totalTokens += len(m.Content) / 4
|
||
}
|
||
|
||
return &response.MatchWorldInfoResponse{
|
||
Entries: matched,
|
||
TotalTokens: totalTokens,
|
||
}, nil
|
||
}
|
||
|
||
// matchEntries 核心匹配引擎
|
||
func (s *WorldInfoService) matchEntries(
|
||
allEntries []struct {
|
||
Entry app.AIWorldInfoEntry
|
||
Source string
|
||
},
|
||
messages []string,
|
||
scanDepth int,
|
||
maxTokens int,
|
||
) []response.MatchedWorldInfoEntry {
|
||
// 获取扫描范围内的消息
|
||
scanMessages := messages
|
||
if scanDepth > 0 && scanDepth < len(messages) {
|
||
scanMessages = messages[len(messages)-scanDepth:]
|
||
}
|
||
|
||
// 合并扫描文本
|
||
scanText := strings.Join(scanMessages, "\n")
|
||
|
||
// 第一轮:匹配常驻条目(Constant)
|
||
var matched []matchedEntry
|
||
for _, item := range allEntries {
|
||
if item.Entry.Constant {
|
||
matched = append(matched, matchedEntry{
|
||
entry: item.Entry,
|
||
source: item.Source,
|
||
depth: 0,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 第二轮:正常匹配
|
||
for _, item := range allEntries {
|
||
if item.Entry.Constant {
|
||
continue // 常驻条目已处理
|
||
}
|
||
|
||
// 检查概率
|
||
if item.Entry.UseProbability && item.Entry.Probability < 100 {
|
||
if rand.Intn(100) >= item.Entry.Probability {
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 匹配关键词
|
||
isMatch := s.matchKeys(scanText, item.Entry.Keys, item.Entry)
|
||
|
||
// 如果是选择性激活,还需要匹配次要关键词
|
||
if isMatch && item.Entry.Selective && len(item.Entry.SecondaryKeys) > 0 {
|
||
secondaryMatch := s.matchKeys(scanText, item.Entry.SecondaryKeys, item.Entry)
|
||
if !secondaryMatch {
|
||
isMatch = false
|
||
}
|
||
}
|
||
|
||
if isMatch {
|
||
matched = append(matched, matchedEntry{
|
||
entry: item.Entry,
|
||
source: item.Source,
|
||
depth: 0,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 第三轮:递归激活
|
||
recursiveDepth := 1
|
||
for recursiveDepth <= 3 { // 最多递归 3 层
|
||
newMatches := []matchedEntry{}
|
||
|
||
for _, item := range allEntries {
|
||
// 跳过已匹配的条目
|
||
alreadyMatched := false
|
||
for _, m := range matched {
|
||
if m.entry.UID == item.Entry.UID {
|
||
alreadyMatched = true
|
||
break
|
||
}
|
||
}
|
||
if alreadyMatched {
|
||
continue
|
||
}
|
||
|
||
// 检查是否设置了递归延迟
|
||
if item.Entry.DelayUntilRecursion && recursiveDepth == 1 {
|
||
continue
|
||
}
|
||
|
||
// 防止递归
|
||
if item.Entry.PreventRecursion {
|
||
continue
|
||
}
|
||
|
||
// 在已匹配的内容中查找
|
||
for _, m := range matched {
|
||
if m.depth == recursiveDepth-1 {
|
||
isMatch := s.matchKeys(m.entry.Content, item.Entry.Keys, item.Entry)
|
||
|
||
if isMatch && item.Entry.Selective && len(item.Entry.SecondaryKeys) > 0 {
|
||
secondaryMatch := s.matchKeys(m.entry.Content, item.Entry.SecondaryKeys, item.Entry)
|
||
if !secondaryMatch {
|
||
isMatch = false
|
||
}
|
||
}
|
||
|
||
if isMatch {
|
||
newMatches = append(newMatches, matchedEntry{
|
||
entry: item.Entry,
|
||
source: item.Source,
|
||
depth: recursiveDepth,
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(newMatches) == 0 {
|
||
break // 没有新匹配,停止递归
|
||
}
|
||
|
||
matched = append(matched, newMatches...)
|
||
recursiveDepth++
|
||
}
|
||
|
||
// 分组处理(同组只保留一个)
|
||
matched = s.applyGroupFilters(matched)
|
||
|
||
// 排序(按 Order 排序)
|
||
sort.Slice(matched, func(i, j int) bool {
|
||
return matched[i].entry.Order < matched[j].entry.Order
|
||
})
|
||
|
||
// Token 限制
|
||
result := []response.MatchedWorldInfoEntry{}
|
||
currentTokens := 0
|
||
for _, m := range matched {
|
||
entryTokens := len(m.entry.Content) / 4
|
||
if currentTokens+entryTokens > maxTokens {
|
||
break
|
||
}
|
||
|
||
result = append(result, response.MatchedWorldInfoEntry{
|
||
Content: m.entry.Content,
|
||
Position: m.entry.Position,
|
||
Order: m.entry.Order,
|
||
Source: m.source,
|
||
})
|
||
|
||
currentTokens += entryTokens
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// matchedEntry 内部匹配结果
|
||
type matchedEntry struct {
|
||
entry app.AIWorldInfoEntry
|
||
source string
|
||
depth int // 递归深度
|
||
}
|
||
|
||
// matchKeys 关键词匹配
|
||
func (s *WorldInfoService) matchKeys(text string, keys []string, entry app.AIWorldInfoEntry) bool {
|
||
// 确定大小写敏感性
|
||
caseSensitive := false
|
||
if entry.CaseSensitive != nil {
|
||
caseSensitive = *entry.CaseSensitive
|
||
}
|
||
|
||
// 确定是否使用正则
|
||
useRegex := false
|
||
if entry.UseRegex != nil {
|
||
useRegex = *entry.UseRegex
|
||
}
|
||
|
||
// 确定是否匹配整词
|
||
matchWholeWords := false
|
||
if entry.MatchWholeWords != nil {
|
||
matchWholeWords = *entry.MatchWholeWords
|
||
}
|
||
|
||
searchText := text
|
||
if !caseSensitive {
|
||
searchText = strings.ToLower(text)
|
||
}
|
||
|
||
for _, key := range keys {
|
||
if key == "" {
|
||
continue
|
||
}
|
||
|
||
searchKey := key
|
||
if !caseSensitive {
|
||
searchKey = strings.ToLower(key)
|
||
}
|
||
|
||
if useRegex {
|
||
// 正则匹配
|
||
pattern := searchKey
|
||
if !caseSensitive {
|
||
pattern = "(?i)" + pattern
|
||
}
|
||
matched, err := regexp.MatchString(pattern, text)
|
||
if err != nil {
|
||
global.GVA_LOG.Warn("正则表达式匹配失败",
|
||
zap.String("pattern", pattern),
|
||
zap.Error(err))
|
||
continue
|
||
}
|
||
if matched {
|
||
return true
|
||
}
|
||
} else if matchWholeWords {
|
||
// 整词匹配
|
||
pattern := `\b` + regexp.QuoteMeta(searchKey) + `\b`
|
||
if !caseSensitive {
|
||
pattern = "(?i)" + pattern
|
||
}
|
||
matched, err := regexp.MatchString(pattern, searchText)
|
||
if err != nil {
|
||
global.GVA_LOG.Warn("整词匹配失败",
|
||
zap.String("pattern", pattern),
|
||
zap.Error(err))
|
||
continue
|
||
}
|
||
if matched {
|
||
return true
|
||
}
|
||
} else {
|
||
// 普通子串匹配
|
||
if strings.Contains(searchText, searchKey) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// applyGroupFilters 应用分组过滤
|
||
func (s *WorldInfoService) applyGroupFilters(matched []matchedEntry) []matchedEntry {
|
||
// 按分组分类
|
||
groups := make(map[string][]matchedEntry)
|
||
noGroup := []matchedEntry{}
|
||
|
||
for _, m := range matched {
|
||
if m.entry.Group == "" {
|
||
noGroup = append(noGroup, m)
|
||
} else {
|
||
groups[m.entry.Group] = append(groups[m.entry.Group], m)
|
||
}
|
||
}
|
||
|
||
// 每个分组只保留一个(权重最高的)
|
||
result := noGroup
|
||
for _, group := range groups {
|
||
if len(group) == 0 {
|
||
continue
|
||
}
|
||
|
||
// 按权重排序
|
||
sort.Slice(group, func(i, j int) bool {
|
||
if group[i].entry.GroupWeight != group[j].entry.GroupWeight {
|
||
return group[i].entry.GroupWeight > group[j].entry.GroupWeight
|
||
}
|
||
return group[i].entry.Order < group[j].entry.Order
|
||
})
|
||
|
||
result = append(result, group[0])
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// validateEntries 验证条目 UID 唯一性
|
||
func (s *WorldInfoService) validateEntries(entries []app.AIWorldInfoEntry) error {
|
||
uidMap := make(map[string]bool)
|
||
for _, entry := range entries {
|
||
if entry.UID == "" {
|
||
return errors.New("条目 UID 不能为空")
|
||
}
|
||
if uidMap[entry.UID] {
|
||
return errors.New("条目 UID 重复: " + entry.UID)
|
||
}
|
||
uidMap[entry.UID] = true
|
||
}
|
||
return nil
|
||
}
|