当前位置: 首页 > news >正文

AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现

AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现

1. 前言 📝

在上一篇文章中,Sun-Panel的接口分析,本篇文章将聚焦于sun-panel数据同步服务的实现。

2. Sun-Panel API分析 📊

Sun-Panel提供了完善的API接口,我们需要实现以下核心功能:

功能端点方法描述
登录/api/loginPOST获取认证token
获取分组/api/panel/itemIconGroup/getListPOST获取所有书签分组
获取书签/api/panel/itemIcon/getListByGroupIdPOST获取指定分组的书签
创建分组/api/panel/itemIconGroup/editPOST创建新分组
创建书签/api/panel/itemIcon/editPOST创建新书签

3. 同步功能设计 🔄

3.1 同步策略

我们提供三种同步模式:

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

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

3.2 数据模型映射

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

本地模型Sun-Panel模型说明
CategoryGroup书签分类/分组
BookmarkItemIcon书签项

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.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的书签同步功能,主要完成了:

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

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


往期系列

  • 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接口分析
http://www.xdnf.cn/news/931051.html

相关文章:

  • 【ArcGIS Pro微课1000例】0072:如何自动保存编辑内容及保存工程?
  • 68常用控件_QGroupBox的使用
  • C语言中的文件操作
  • Android写一个捕获全局异常的工具类
  • 【hive】函数集锦:窗口函数、列转行、日期函数
  • stm32-c8t6实现语音识别(LD3320)
  • 【Mac 从 0 到 1 保姆级配置教程 16】- Docker 快速安装配置、常用命令以及实际项目演示
  • 【SpringBoot+SpringCloud】Linux配置nacos踩坑大全
  • AI时代:学习永不嫌晚,语言多元共存
  • 思澈sdk-新建lcd
  • LeetCode--25.k个一组翻转链表
  • 从0开始学习R语言--Day20-ARIMA与格兰杰因果检验
  • 【第八篇】 SpringBoot高级配置(配置篇)
  • CZGL.SystemInfo:跨平台的系统信息获取库
  • AUTOSAR实战教程--DoIP_01_配置项解释
  • 0x-3-Oracle 23 ai-sqlcl 25.1 集成安装-配置和优化
  • Linux下GCC和C++实现统计Clickhouse数据仓库指定表中各字段的空值、空字符串或零值比例
  • Go基本语法——go语言中的四种变量定义方法
  • 【Java学习笔记】BigInteger 和 BigDecimal 类
  • 【Go语言基础【19】】接口:灵活实现多态的核心机制
  • 基于RT-DETR算法的夜间交通车辆与行人目标检测
  • FPGA静态功耗
  • 2025年与2030年AI及AI智能体 (Agent) 市场份额分析报告
  • 网络之交换机
  • 【题解-洛谷】B3626 跳跃机器人
  • JavaWeb——登录(14/16):登录校验-Interceptor-详解(使用细节、拦截路径的配置、匹配规则、执行流程、拦截器与过滤器的区别)
  • 【华为云Astro 轻应用】组装“待处理工单”页面示例
  • C语言基础面试问答
  • 【人工智能 | 项目开发】Python Flask实现本地AI大模型可视化界面
  • 苍穹外卖-day01