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 同步策略

我们提供三种同步模式:

  1. Pull模式:从Sun-Panel拉取数据到本地
  2. Push模式:将本地数据推送到Sun-Panel
  3. Sync模式:双向同步,确保两端数据一致

这里一致只保证分组和title一致,只关注最核心的数据同步

3.2 数据模型映射

本地数据模型与Sun-Panel模型的对应关系:

本地模型 Sun-Panel模型 说明
Category Group 书签分类/分组
Bookmark ItemIcon 书签项

3.3 同步流程

3.3.1 同步流程
pull
push
sync
开始
选择模式
从Sun-Panel获取数据
推送本地数据
双向同步
处理分组
处理书签
保存到本地
遍历本地分类
创建远程分组
推送书签
拉取远程数据
推送本地数据
3.3.2 验证token流程
连接失败
连接成功
HTTP错误
API错误
登录成功
无效
有效
开始配置验证
用户输入URL
用户输入用户名
用户输入密码
尝试连接
显示错误信息
允许重试?
退出配置
发送登录请求
验证响应
显示网络错误
解析错误信息
显示API错误
获取Token
保存Token到配置
测试Token有效性
Token有效?
清除Token
显示Token无效
获取用户信息
显示验证成功
保存配置
结束流程

4. 代码实现 👨‍💻

4.1 Sun-Panel客户端实现

首先创建Sun-Panel客户端,封装所有API调用:

// internal/sunpanel/client.go

package sunpanel

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

type Client struct {
	baseURL string
	token   string
	client  *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 LoginResponse
	if 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.Token
	return 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 GroupListResponse
	if 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 BookmarkListResponse
	if 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 AuthInfoResponse
	if 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.go

var 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.Category
			db.Model(&models.Category{}).Where("name = ?", group.Title).First(&category)
			targetGroupId := category.ID
			if 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.Bookmark
				db.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.go

var 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.Category
		db.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.Bookmark
			db.Model(&models.Bookmark{}).Where("category_id = ?", category.ID).Find(&bookmarks)
			// 查询group中是否存在该分组
			var existGroup sunpanel.Group
			for _, 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.Bookmark
					for _, 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.go

var 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.Category
		db.Find(&localCategories)
		log.Info("Local categories", zap.Any("categories", localCategories))

		// 3. 同步分组/分类
		for _, group := range groups {
			// 检查本地是否存在该分组
			var category models.Category
			db.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.Bookmark
			db.Model(&models.Bookmark{}).Where("category_id = ?", category.ID).Find(&localBookmarks)

			// 同步书签
			for _, remoteBookmark := range remoteBookmarks {
				var existBookmark models.Bookmark
				db.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.Group
			for _, group := range groups {
				if group.Title == category.Name {
					existGroup = group
					break
				}
			}

			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.Bookmark
			db.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.Bookmark
				for _, remoteBookmark := range remoteBookmarks {
					if remoteBookmark.Title == localBookmark.Title {
						existRemoteBookmark = remoteBookmark
						break
					}
				}

				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.AppConfigModel
	if 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))
	}
	// userName
	userNamePromot := promptui.Prompt{
		Label:  "请输入sun-panel用户名",
		Stdout: os.Stderr,
	}
	userName, err := userNamePromot.Run()
	if err != nil {
		log.Fatal("Failed to get userName", zap.Error(err))
	}
	// password
	passwordPromot := 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 = url
	config.SunPanel.Token = token
	common.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的书签同步功能,主要完成了:

  1. Sun-Panel客户端封装,支持所有必要的API操作
  2. 三种同步模式:Pull、Push和Sync
  3. 用户友好的配置流程,简化首次使用设置
  4. 健壮的错误处理,确保同步过程可靠

书签管理不仅仅是收藏,更是知识的流动与共享。 通过实现强大的同步功能,我们让书签真正活起来,在不同设备、不同平台间自由流动,为用户创造无缝的知识管理体验。


往期系列

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐