AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现
AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现
1. 前言 📝
在上一篇文章中,Sun-Panel的接口分析,本篇文章将聚焦于sun-panel数据同步服务的实现。
2. Sun-Panel API分析 📊
Sun-Panel提供了完善的API接口,我们需要实现以下核心功能:
功能 | 端点 | 方法 | 描述 |
---|---|---|---|
登录 | /api/login | POST | 获取认证token |
获取分组 | /api/panel/itemIconGroup/getList | POST | 获取所有书签分组 |
获取书签 | /api/panel/itemIcon/getListByGroupId | POST | 获取指定分组的书签 |
创建分组 | /api/panel/itemIconGroup/edit | POST | 创建新分组 |
创建书签 | /api/panel/itemIcon/edit | POST | 创建新书签 |
3. 同步功能设计 🔄
3.1 同步策略
我们提供三种同步模式:
- Pull模式:从Sun-Panel拉取数据到本地
- Push模式:将本地数据推送到Sun-Panel
- Sync模式:双向同步,确保两端数据一致
这里一致只保证分组和title一致,只关注最核心的数据同步
3.2 数据模型映射
本地数据模型与Sun-Panel模型的对应关系:
本地模型 | Sun-Panel模型 | 说明 |
---|---|---|
Category | Group | 书签分类/分组 |
Bookmark | ItemIcon | 书签项 |
3.3 同步流程
3.3.1 同步流程
3.3.2 验证token流程
4. 代码实现 👨💻
4.1 Sun-Panel客户端实现
首先创建Sun-Panel客户端,封装所有API调用:
// internal/sunpanel/client.gopackage sunpanelimport ("bytes""encoding/json""fmt""net/http"
)type Client struct {baseURL stringtoken stringclient *http.Client
}type LoginResponse struct {Code int `json:"code"`Data struct {Token string `json:"token"`} `json:"data"`Msg string `json:"msg"`
}type Group struct {ID int `json:"id"`Title string `json:"title"`Description string `json:"description"`
}type Bookmark struct {ID int `json:"id"`Title string `json:"title"`URL string `json:"url"`Description string `json:"description"`ItemIconGroupId int `json:"itemIconGroupId"`
}type GroupListResponse struct {Code int `json:"code"`Data struct {List []Group `json:"list"`} `json:"data"`Msg string `json:"msg"`
}type BookmarkListResponse struct {Code int `json:"code"`Data struct {List []Bookmark `json:"list"`} `json:"data"`Msg string `json:"msg"`
}type UserInfo struct {ID int `json:"id"`Username string `json:"username"`Name string `json:"name"`HeadImage string `json:"headImage"`Status int `json:"status"`Role int `json:"role"`Mail string `json:"mail"`ReferralCode string `json:"referralCode"`
}type AuthInfoResponse struct {Code int `json:"code"`Data struct {User UserInfo `json:"user"`VisitMode int `json:"visitMode"`} `json:"data"`Msg string `json:"msg"`
}func NewClient(baseURL string) *Client {return &Client{baseURL: baseURL,client: &http.Client{},}
}func (c *Client) Login(username, password string) (string, error) {url := fmt.Sprintf("%s/api/login", c.baseURL)data := map[string]string{"username": username,"password": password,}jsonData, err := json.Marshal(data)if err != nil {return "", err}resp, err := c.client.Post(url, "application/json", bytes.NewBuffer(jsonData))if err != nil {return "", err}defer resp.Body.Close()var loginResp LoginResponseif err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {return "", err}if loginResp.Code != 0 {return "", fmt.Errorf("login failed: %s", loginResp.Msg)}c.token = loginResp.Data.Tokenreturn loginResp.Data.Token, nil
}func (c *Client) SetToken(token string) {c.token = token
}func (c *Client) GetGroups() ([]Group, error) {url := fmt.Sprintf("%s/api/panel/itemIconGroup/getList", c.baseURL)req, err := http.NewRequest("POST", url, nil)if err != nil {return nil, err}req.Header.Set("Authorization", "Bearer "+c.token)req.Header.Set("Token", c.token)req.Header.Set("Content-Type", "application/json")resp, err := c.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var groupResp GroupListResponseif err := json.NewDecoder(resp.Body).Decode(&groupResp); err != nil {return nil, err}if groupResp.Code != 0 {return nil, fmt.Errorf("get groups failed: %s", groupResp.Msg)}return groupResp.Data.List, nil
}func (c *Client) GetBookmarksByGroup(groupID int) ([]Bookmark, error) {url := fmt.Sprintf("%s/api/panel/itemIcon/getListByGroupId", c.baseURL)data := map[string]int{"itemIconGroupId": groupID,}jsonData, err := json.Marshal(data)if err != nil {return nil, err}req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))if err != nil {return nil, err}req.Header.Set("Authorization", "Bearer "+c.token)req.Header.Set("Token", c.token)req.Header.Set("Content-Type", "application/json")resp, err := c.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var bookmarkResp BookmarkListResponseif err := json.NewDecoder(resp.Body).Decode(&bookmarkResp); err != nil {return nil, err}if bookmarkResp.Code != 0 {return nil, fmt.Errorf("get bookmarks failed: %s", bookmarkResp.Msg)}return bookmarkResp.Data.List, nil
}func (c *Client) CreateGroup(title string) (*Group, error) {url := fmt.Sprintf("%s/api/panel/itemIconGroup/edit", c.baseURL)fmt.Println(url)data := map[string]interface{}{"title": title,"cardStyle": map[string]interface{}{"style": 0,"textColor": "#ffffff","textInfoHideDescription": false,"textIconHideTitle": false,},}jsonData, err := json.Marshal(data)if err != nil {return nil, err}req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))if err != nil {return nil, err}req.Header.Set("Authorization", "Bearer "+c.token)req.Header.Set("Token", c.token)req.Header.Set("Content-Type", "application/json")fmt.Println(req)resp, err := c.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var groupResp struct {Code int `json:"code"`Data Group `json:"data"`Msg string `json:"msg"`}if err := json.NewDecoder(resp.Body).Decode(&groupResp); err != nil {return nil, err}if groupResp.Code != 0 {return nil, fmt.Errorf("create group failed: %s", groupResp.Msg)}return &groupResp.Data, nil
}func (c *Client) CreateBookmark(groupID int, title, url, description string) (*Bookmark, error) {apiURL := fmt.Sprintf("%s/api/panel/itemIcon/edit", c.baseURL)data := map[string]interface{}{"icon": map[string]interface{}{"itemType": 1,"backgroundColor": "#2a2a2a6b",},"title": title,"url": url,"lanUrl": url,"description": description,"openMethod": 2,"cardType": 1,"itemIconGroupId": groupID,"backgroundColor": "#2a2a2a6b","expandParam": map[string]interface{}{},}jsonData, err := json.Marshal(data)if err != nil {return nil, err}req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))if err != nil {return nil, err}req.Header.Set("Authorization", "Bearer "+c.token)req.Header.Set("Token", c.token)req.Header.Set("Content-Type", "application/json")resp, err := c.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var bookmarkResp struct {Code int `json:"code"`Data Bookmark `json:"data"`Msg string `json:"msg"`}if err := json.NewDecoder(resp.Body).Decode(&bookmarkResp); err != nil {return nil, err}if bookmarkResp.Code != 0 {return nil, fmt.Errorf("create bookmark failed: %s", bookmarkResp.Msg)}return &bookmarkResp.Data, nil
}func (c *Client) GetAuthInfo() (*UserInfo, error) {url := fmt.Sprintf("%s/api/user/getAuthInfo", c.baseURL)req, err := http.NewRequest("GET", url, nil)if err != nil {return nil, err}req.Header.Set("Authorization", "Bearer "+c.token)req.Header.Set("Token", c.token)req.Header.Set("Content-Type", "application/json")resp, err := c.client.Do(req)if err != nil {return nil, err}defer resp.Body.Close()var authResp AuthInfoResponseif err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {return nil, err}if authResp.Code != 0 {return nil, fmt.Errorf("get auth info failed: %s", authResp.Msg)}return &authResp.Data.User, nil
}
4.2 同步命令实现
4.2.1 Pull命令实现
// cmd/sp.govar pullCmd = &cobra.Command{Use: "pull",Short: "pull data from sun-panel",Long: `pull data from sun-panel`,Run: func(cmd *cobra.Command, args []string) {db, err := utils.GetGormDB()if err != nil {log.Fatal("Failed to get db", zap.Error(err))}client := GetClient()groups, err := client.GetGroups()if err != nil {log.Fatal("Failed to get groups", zap.Error(err))}log.Info("groups", zap.Any("groups", groups))for _, group := range groups {// 查询数据库中是否存在该分组var category models.Categorydb.Model(&models.Category{}).Where("name = ?", group.Title).First(&category)targetGroupId := category.IDif category.ID == 0 {// 保存到数据库category = models.Category{Name: group.Title,Description: "由同步服务创建",}db.Create(&category)targetGroupId = category.ID}bookmarks, err := client.GetBookmarksByGroup(group.ID)if err != nil {log.Fatal("Failed to get bookmarks", zap.Error(err))}log.Info("bookmarks", zap.Any("bookmarks", bookmarks))for _, bookmark := range bookmarks {// 根据分组和标题查询数据库中是否存在该书签var existBookmark models.Bookmarkdb.Model(&models.Bookmark{}).Where("category_id = ? AND title = ?", targetGroupId, bookmark.Title).First(&existBookmark)if existBookmark.ID == 0 {// 保存到数据库saveBookmark := models.Bookmark{Title: bookmark.Title,URL: bookmark.URL,Description: bookmark.Description,CategoryID: targetGroupId,}db.Create(&saveBookmark)}}}log.Info("pull data from sun-panel success")fmt.Println("pull data from sun-panel success")},
}
4.2.2 Push命令实现
// cmd/sp.govar pushCmd = &cobra.Command{Use: "push",Short: "push data to sun-panel",Long: `push data to sun-panel`,Run: func(cmd *cobra.Command, args []string) {db, err := utils.GetGormDB()if err != nil {log.Fatal("Failed to get db", zap.Error(err))}var categories []models.Categorydb.Find(&categories)client := GetClient()groups, err := client.GetGroups()if err != nil {log.Fatal("Failed to get groups", zap.Error(err))}log.Info("groups", zap.Any("groups", groups))for _, category := range categories {// 查询数据库中所有书签var bookmarks []models.Bookmarkdb.Model(&models.Bookmark{}).Where("category_id = ?", category.ID).Find(&bookmarks)// 查询group中是否存在该分组var existGroup sunpanel.Groupfor _, group := range groups {if group.Title == category.Name {existGroup = group}}if existGroup.ID == 0 {// 创建分组existGroup, err := client.CreateGroup(category.Name)if err != nil {log.Fatal("Failed to create group", zap.Error(err))}for _, bookmark := range bookmarks {client.CreateBookmark(existGroup.ID, bookmark.Title, bookmark.URL, bookmark.Description)}} else {// 查询该分组下所有书签spbookmarks, err := client.GetBookmarksByGroup(existGroup.ID)if err != nil {log.Fatal("Failed to get bookmarks", zap.Error(err))}for _, bookmark := range bookmarks {// 查询spbookmarks中是否存在该书签var existBookmark sunpanel.Bookmarkfor _, spbookmark := range spbookmarks {if spbookmark.Title == bookmark.Title {existBookmark = spbookmark}}if existBookmark.ID == 0 {client.CreateBookmark(existGroup.ID, bookmark.Title, bookmark.URL, bookmark.Description)}}}}log.Info("push data to sun-panel success")fmt.Println("push data to sun-panel success")},
}
4.2.3 Sync命令实现(双向同步)
// cmd/sp.govar syncCmd = &cobra.Command{Use: "sync",Short: "sync data between sun-panel and local",Long: `sync data between sun-panel and local`,Run: func(cmd *cobra.Command, args []string) {// 获取数据库连接db, err := utils.GetGormDB()if err != nil {log.Fatal("Failed to get db", zap.Error(err))}// 获取客户端client := GetClient()// 1. 从远程获取所有分组groups, err := client.GetGroups()if err != nil {log.Fatal("Failed to get groups", zap.Error(err))}log.Info("Remote groups", zap.Any("groups", groups))// 2. 获取本地所有分类var localCategories []models.Categorydb.Find(&localCategories)log.Info("Local categories", zap.Any("categories", localCategories))// 3. 同步分组/分类for _, group := range groups {// 检查本地是否存在该分组var category models.Categorydb.Model(&models.Category{}).Where("name = ?", group.Title).First(&category)if category.ID == 0 {// 本地不存在,创建新分类category = models.Category{Name: group.Title,Description: "由同步服务创建",}db.Create(&category)}// 获取远程书签remoteBookmarks, err := client.GetBookmarksByGroup(group.ID)if err != nil {log.Fatal("Failed to get remote bookmarks", zap.Error(err))}// 获取本地书签var localBookmarks []models.Bookmarkdb.Model(&models.Bookmark{}).Where("category_id = ?", category.ID).Find(&localBookmarks)// 同步书签for _, remoteBookmark := range remoteBookmarks {var existBookmark models.Bookmarkdb.Model(&models.Bookmark{}).Where("category_id = ? AND title = ?", category.ID, remoteBookmark.Title).First(&existBookmark)if existBookmark.ID == 0 {// 本地不存在,创建新书签saveBookmark := models.Bookmark{Title: remoteBookmark.Title,URL: remoteBookmark.URL,Description: remoteBookmark.Description,CategoryID: category.ID,}db.Create(&saveBookmark)}}}// 4. 处理本地特有的分类for _, category := range localCategories {// 检查远程是否存在该分组var existGroup sunpanel.Groupfor _, group := range groups {if group.Title == category.Name {existGroup = groupbreak}}if existGroup.ID == 0 {// 远程不存在,创建新分组newGroup, err := client.CreateGroup(category.Name)if err != nil {log.Fatal("Failed to create remote group", zap.Error(err))}existGroup = *newGroup}// 获取本地书签var localBookmarks []models.Bookmarkdb.Model(&models.Bookmark{}).Where("category_id = ?", category.ID).Find(&localBookmarks)// 获取远程书签remoteBookmarks, err := client.GetBookmarksByGroup(existGroup.ID)if err != nil {log.Fatal("Failed to get remote bookmarks", zap.Error(err))}// 同步书签到远程for _, localBookmark := range localBookmarks {var existRemoteBookmark sunpanel.Bookmarkfor _, remoteBookmark := range remoteBookmarks {if remoteBookmark.Title == localBookmark.Title {existRemoteBookmark = remoteBookmarkbreak}}if existRemoteBookmark.ID == 0 {// 远程不存在,创建新书签_, err := client.CreateBookmark(existGroup.ID, localBookmark.Title, localBookmark.URL, localBookmark.Description)if err != nil {log.Fatal("Failed to create remote bookmark", zap.Error(err))}}}}log.Info("Sync completed successfully")fmt.Println("Sync completed successfully")},
}
4.3 用户配置管理
首次使用或者配置有误需要更新sun-panel配置信息:
func HandleSpConfigInput() {config := common.AppConfigModelif config == nil {log.Fatal("Failed to get config")}log.Info("sp config called", zap.Any("config", config))urlPromot := promptui.Prompt{Label: "请输入sun-panel url",Stdout: os.Stderr,}url, err := urlPromot.Run()if err != nil {log.Fatal("Failed to get url", zap.Error(err))}// userNameuserNamePromot := promptui.Prompt{Label: "请输入sun-panel用户名",Stdout: os.Stderr,}userName, err := userNamePromot.Run()if err != nil {log.Fatal("Failed to get userName", zap.Error(err))}// passwordpasswordPromot := promptui.Prompt{Label: "请输入sun-panel密码",Mask: '*',Stdout: os.Stderr,}password, err := passwordPromot.Run()if err != nil {log.Fatal("Failed to get password", zap.Error(err))}//调用client := sunpanel.NewClient(url)token, err := client.Login(userName, password)if err != nil {log.Fatal("Failed to login", zap.Error(err))}config.SunPanel.URL = urlconfig.SunPanel.Token = tokencommon.AppConfigModel = config// 写入配置utils.ConfigInstance.SaveConfig(config)
}
5. 使用演示
5.1 配置Sun-Panel连接 🎥
配置sun-panel的url和token,不会保存用户名和密码,仅获取token时需要。token失效时间在sun-panel侧配置。
$ ./aibookmark config sp
请输入sun-panel url: http://localhost:9000
请输入sun-panel用户名: admin
请输入sun-panel密码: ******
5.2 从Sun-Panel拉取数据
$ ./aibookmark sp pull
pull data from sun-panel success
5.3 推送数据到Sun-Panel
$ ./aibookmark sp push
push data to sun-panel success
5.4 双向同步
$ ./aibookmark sp sync
Sync completed successfully
6. 总结 📝
本文实现了与Sun-Panel的书签同步功能,主要完成了:
- Sun-Panel客户端封装,支持所有必要的API操作
- 三种同步模式:Pull、Push和Sync
- 用户友好的配置流程,简化首次使用设置
- 健壮的错误处理,确保同步过程可靠
书签管理不仅仅是收藏,更是知识的流动与共享。 通过实现强大的同步功能,我们让书签真正活起来,在不同设备、不同平台间自由流动,为用户创造无缝的知识管理体验。
往期系列
- Ai书签管理工具开发全记录(一):项目总览与技术蓝图
- Ai书签管理工具开发全记录(二):项目基础框架搭建
- AI书签管理工具开发全记录(三):配置及数据系统设计
- AI书签管理工具开发全记录(四):日志系统设计与实现
- AI书签管理工具开发全记录(五):后端服务搭建与API实现
- AI书签管理工具开发全记录(六):前端管理基础框框搭建 Vue3+Element Plus
- AI书签管理工具开发全记录(七):页面编写与接口对接
- AI书签管理工具开发全记录(八):Ai创建书签功能实现
- AI书签管理工具开发全记录(九):用户端页面集成与展示
- AI书签管理工具开发全记录(十):命令行中结合ai高效添加书签
- AI书签管理工具开发全记录(十一):MCP集成
- AI书签管理工具开发全记录(十二):MCP集成查询
- AI书签管理工具开发全记录(十三):TUI基本框架搭建
- AI书签管理工具开发全记录(十四):TUI基本界面完善
- AI书签管理工具开发全记录(十五):TUI基本逻辑实现与数据展示
- # AI书签管理工具开发全记录(十六):Sun-Panel接口分析