AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现
本文记录了Sun-Panel书签同步工具的开发过程。文章分析了Sun-Panel的API接口,包括登录、获取分组/书签、创建分组/书签等核心功能。设计实现了三种同步模式(Pull/Push/Sync),通过本地与远程数据模型映射实现分组和书签的双向同步。文章详细展示了同步流程和验证token流程的Mermaid图,并提供了Go语言实现的Sun-Panel客户端代码片段,封装了API调用和数据结构。
·
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.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的书签同步功能,主要完成了:
- 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接口分析
更多推荐


所有评论(0)