在线书签管理
yaoye Lv5

在线书签管理

了解了您决定使用原始 HTML 进行前端开发后,以下是基于 Go 语言和 MySQL 数据库构建在线书签整理平台的详细实施指南。这将涵盖系统架构设计、数据库设计、后端开发、前端集成(使用原生 HTML、CSS 和 JavaScript)、用户认证、安全性考虑以及部署与维护等方面。

一、系统架构设计

1. 总体架构

采用前后端集成的架构,通过 Go 语言的模板引擎(如 html/template)实现服务器端渲染。前端使用原生 HTML、CSS 和 JavaScript,与后端通过表单提交和 AJAX 请求进行交互。

1
2
3
4
5
6
+----------------+       +-----------------+       +----------------+
| | | | | |
| 原生 HTML | <---->| 后端服务器 | <---->| MySQL数据库 |
| (HTML/CSS/JS) | | (Go语言) | | |
| | | | | |
+----------------+ +-----------------+ +----------------+

2. 技术栈

  • 后端:Go语言,使用 Gin 框架
  • 数据库:MySQL
  • 前端:原生 HTML、CSS、JavaScript
  • 模板引擎:Go 的 html/template
  • 身份验证:基于 Cookie 的会话管理或 JWT(JSON Web Tokens)
  • 版本控制:Git(GitHub/GitLab)

二、数据库设计

1. 用户表(users)

字段名 数据类型 约束 描述
id INT AUTO_INCREMENT PRIMARY KEY 用户唯一标识
username VARCHAR(50) UNIQUE, NOT NULL 用户名
email VARCHAR(100) UNIQUE, NOT NULL 用户邮箱
password VARCHAR(255) NOT NULL 加密后的密码
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 注册时间
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 更新时间

2. 书签表(bookmarks)

字段名 数据类型 约束 描述
id INT AUTO_INCREMENT PRIMARY KEY 书签唯一标识
user_id INT FOREIGN KEY REFERENCES users(id) 所属用户
title VARCHAR(255) NOT NULL 书签标题
url VARCHAR(2083) NOT NULL 书签URL
description TEXT NULL 书签描述
folder VARCHAR(100) NULL 文件夹名称
tags JSON NULL 标签列表(JSON格式)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 创建时间
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 更新时间

3. 标签表(tags)[可选]

如果需要更复杂的标签管理,可以创建单独的标签表并建立多对多关系。

字段名 数据类型 约束 描述
id INT AUTO_INCREMENT PRIMARY KEY 标签唯一标识
user_id INT FOREIGN KEY REFERENCES users(id) 所属用户
name VARCHAR(50) NOT NULL 标签名称
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 创建时间
关联表 字段名 数据类型 约束 描述
bookmark_tags bookmark_id INT FOREIGN KEY REFERENCES bookmarks(id) 所属书签
tag_id INT FOREIGN KEY REFERENCES tags(id) 所属标签

三、后端开发

1. 选择框架

Gin 是一个高性能的 Go 语言 Web 框架,适合构建服务器端渲染的应用。

2. 项目结构

一个典型的 Go 项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bookmark-platform/
├── cmd/
│ └── main.go
├── internal/
│ ├── handlers/
│ ├── models/
│ ├── repositories/
│ ├── services/
│ ├── middleware/
│ ├── templates/
│ └── utils/
├── static/
│ ├── css/
│ ├── js/
│ └── images/
├── templates/
│ ├── index.html
│ ├── login.html
│ ├── register.html
│ └── bookmarks.html
├── configs/
│ └── config.yaml
├── migrations/
│ └── ... SQL or migration files ...
├── scripts/
│ └── ... deployment scripts ...
├── go.mod
└── go.sum

3. 主要模块

a. 模型(Models)

定义用户和书签的结构体,使用 GORM 等 ORM 工具可以简化数据库操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// internal/models/user.go
package models

import (
"time"
)

type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique;not null" json:"username"`
Email string `gorm:"unique;not null" json:"email"`
Password string `gorm:"not null" json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Bookmarks []Bookmark `gorm:"foreignKey:UserID" json:"bookmarks,omitempty"`
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// internal/models/bookmark.go
package models

import (
"time"
)

type Bookmark struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
Title string `gorm:"not null" json:"title"`
URL string `gorm:"not null" json:"url"`
Description string `json:"description,omitempty"`
Folder string `json:"folder,omitempty"`
Tags string `json:"tags,omitempty"` // JSON 字符串或分隔符
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}

b. 数据库连接

使用 GORM 连接 MySQL 数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// internal/utils/database.go
package utils

import (
"fmt"
"log"
"os"

"your_project/internal/models"

"gorm.io/driver/mysql"
"gorm.io/gorm"
)

var DB *gorm.DB

func InitDB() {
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_NAME"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database: ", err)
}

// 自动迁移
err = DB.AutoMigrate(&models.User{}, &models.Bookmark{})
if err != nil {
log.Fatal("Failed to migrate database: ", err)
}
}

c. 处理器(Handlers)

实现用户注册、登录及书签管理的处理函数,渲染相应的 HTML 模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// internal/handlers/auth.go
package handlers

import (
"net/http"
"time"

"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/dgrijalva/jwt-go"

"your_project/internal/models"
"your_project/internal/utils"
)

type RegisterInput struct {
Username string `form:"username" binding:"required"`
Email string `form:"email" binding:"required,email"`
Password string `form:"password" binding:"required,min=6"`
}

func Register(c *gin.Context) {
var input RegisterInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "register.html", gin.H{
"error": err.Error(),
})
return
}

// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "register.html", gin.H{
"error": "服务器错误",
})
return
}

user := models.User{
Username: input.Username,
Email: input.Email,
Password: string(hashedPassword),
}

if err := utils.DB.Create(&user).Error; err != nil {
c.HTML(http.StatusBadRequest, "register.html", gin.H{
"error": "邮箱或用户名已存在",
})
return
}

c.Redirect(http.StatusSeeOther, "/login")
}

type LoginInput struct {
Email string `form:"email" binding:"required,email"`
Password string `form:"password" binding:"required"`
}

func Login(c *gin.Context) {
var input LoginInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "login.html", gin.H{
"error": err.Error(),
})
return
}

var user models.User
if err := utils.DB.Where("email = ?", input.Email).First(&user).Error; err != nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"error": "无效的凭证",
})
return
}

// 比较密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"error": "无效的凭证",
})
return
}

// 生成JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"exp": time.Now().Add(time.Hour * 72).Unix(),
})

tokenString, err := token.SignedString([]byte("your_secret_key"))
if err != nil {
c.HTML(http.StatusInternalServerError, "login.html", gin.H{
"error": "无法生成令牌",
})
return
}

// 设置Cookie
c.SetCookie("token", tokenString, 3600*72, "/", "", false, true)

c.Redirect(http.StatusSeeOther, "/bookmarks")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// internal/middleware/auth.go
package middleware

import (
"fmt"
"net/http"
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := c.Cookie("token")
if err != nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}

token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 验证签名方法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte("your_secret_key"), nil
})

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := uint(claims["user_id"].(float64)) // 转换为 uint
c.Set("user_id", userID)
c.Next()
} else {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
}
}

d. 书签处理器

实现添加、编辑、删除、获取书签的处理函数,渲染相应的 HTML 模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// internal/handlers/bookmark.go
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"your_project/internal/models"
"your_project/internal/utils"
)

type CreateBookmarkInput struct {
Title string `form:"title" binding:"required"`
URL string `form:"url" binding:"required,url"`
Description string `form:"description"`
Folder string `form:"folder"`
Tags string `form:"tags"` // 可以使用逗号分隔的字符串
}

func CreateBookmark(c *gin.Context) {
var input CreateBookmarkInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "bookmarks.html", gin.H{
"error": err.Error(),
})
return
}

userID := c.MustGet("user_id").(uint)

bookmark := models.Bookmark{
UserID: userID,
Title: input.Title,
URL: input.URL,
Description: input.Description,
Folder: input.Folder,
Tags: input.Tags,
}

if err := utils.DB.Create(&bookmark).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "创建书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

func GetBookmarks(c *gin.Context) {
userID := c.MustGet("user_id").(uint)
var bookmarks []models.Bookmark
if err := utils.DB.Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "获取书签失败",
})
return
}

c.HTML(http.StatusOK, "bookmarks.html", gin.H{
"bookmarks": bookmarks,
})
}

func UpdateBookmark(c *gin.Context) {
var input CreateBookmarkInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "bookmarks.html", gin.H{
"error": err.Error(),
})
return
}

userID := c.MustGet("user_id").(uint)
bookmarkID := c.Param("id")

var bookmark models.Bookmark
if err := utils.DB.Where("id = ? AND user_id = ?", bookmarkID, userID).First(&bookmark).Error; err != nil {
c.HTML(http.StatusNotFound, "bookmarks.html", gin.H{
"error": "未找到书签",
})
return
}

bookmark.Title = input.Title
bookmark.URL = input.URL
bookmark.Description = input.Description
bookmark.Folder = input.Folder
bookmark.Tags = input.Tags

if err := utils.DB.Save(&bookmark).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "更新书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

func DeleteBookmark(c *gin.Context) {
userID := c.MustGet("user_id").(uint)
bookmarkID := c.Param("id")

if err := utils.DB.Where("id = ? AND user_id = ?", bookmarkID, userID).Delete(&models.Bookmark{}).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "删除书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

e. 路由设置

配置路由,并应用认证中间件。使用 Go 的模板引擎渲染页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// cmd/main.go
package main

import (
"your_project/internal/handlers"
"your_project/internal/middleware"
"your_project/internal/utils"

"github.com/gin-gonic/gin"
)

func main() {
utils.LoadConfig()
utils.InitDB()

r := gin.Default()

// 设置模板目录
r.LoadHTMLGlob("templates/*")

// 设置静态文件目录
r.Static("/static", "./static")

// 公开路由
r.GET("/", handlers.ShowHomePage)
r.GET("/register", handlers.ShowRegisterPage)
r.POST("/register", handlers.Register)
r.GET("/login", handlers.ShowLoginPage)
r.POST("/login", handlers.Login)

// 需要认证的路由
authorized := r.Group("/")
authorized.Use(middleware.AuthMiddleware())
{
authorized.GET("/bookmarks", handlers.GetBookmarks)
authorized.POST("/bookmarks", handlers.CreateBookmark)
authorized.POST("/bookmarks/update/:id", handlers.UpdateBookmark)
authorized.POST("/bookmarks/delete/:id", handlers.DeleteBookmark)
authorized.GET("/logout", handlers.Logout)
}

r.Run(":8080") // 默认监听在0.0.0.0:8080
}

f. 处理首页、注册和登录页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// internal/handlers/home.go
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
)

func ShowHomePage(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
}

func ShowRegisterPage(c *gin.Context) {
c.HTML(http.StatusOK, "register.html", nil)
}

func ShowLoginPage(c *gin.Context) {
c.HTML(http.StatusOK, "login.html", nil)
}

func Logout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.Redirect(http.StatusSeeOther, "/login")
}

4. 配置管理

使用环境变量管理敏感信息,如数据库连接字符串和 JWT 密钥。可以使用 .env 文件和 godotenv 库加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// internal/utils/config.go
package utils

import (
"log"
"os"

"github.com/joho/godotenv"
)

func LoadConfig() {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
}

main.go 中调用 LoadConfig

1
2
3
4
5
func main() {
utils.LoadConfig()
utils.InitDB()
// 其余代码...
}

5. 错误处理与日志记录

使用 Gin 自带的日志功能,或者集成更高级的日志库如 logruszap 以增强日志记录能力。

四、前端集成(使用原生 HTML、CSS 和 JavaScript)

1. 模板设计

使用 Go 的 html/template 包来渲染动态内容。创建 HTML 模板文件,如 index.html, login.html, register.html, bookmarks.html 等。

示例:templates/login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<h1>登录</h1>
{{if .error}}
<p style="color:red;">{{.error}}</p>
{{end}}
<form action="/login" method="POST">
<label for="email">邮箱:</label>
<input type="email" id="email" name="email" required>
<br>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
<br>
<button type="submit">登录</button>
</form>
<p>没有账号? <a href="/register">注册</a></p>
</body>
</html>

示例:templates/bookmarks.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的书签</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script src="/static/js/scripts.js" defer></script>
</head>
<body>
<h1>我的书签</h1>
<a href="/logout">退出</a>

{{if .error}}
<p style="color:red;">{{.error}}</p>
{{end}}

<h2>添加书签</h2>
<form action="/bookmarks" method="POST">
<label for="title">标题:</label>
<input type="text" id="title" name="title" required>
<br>
<label for="url">URL:</label>
<input type="url" id="url" name="url" required>
<br>
<label for="description">描述:</label>
<textarea id="description" name="description"></textarea>
<br>
<label for="folder">文件夹:</label>
<input type="text" id="folder" name="folder">
<br>
<label for="tags">标签 (逗号分隔):</label>
<input type="text" id="tags" name="tags">
<br>
<button type="submit">添加书签</button>
</form>

<h2>书签列表</h2>
<table>
<thead>
<tr>
<th>标题</th>
<th>URL</th>
<th>描述</th>
<th>文件夹</th>
<th>标签</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .bookmarks}}
<tr>
<td>{{.Title}}</td>
<td><a href="{{.URL}}" target="_blank">{{.URL}}</a></td>
<td>{{.Description}}</td>
<td>{{.Folder}}</td>
<td>{{.Tags}}</td>
<td>
<form action="/bookmarks/delete/{{.ID}}" method="POST" style="display:inline;">
<button type="submit">删除</button>
</form>
<!-- 可以添加编辑功能 -->
</td>
</tr>
{{end}}
</tbody>
</table>
</body>
</html>

2. 静态资源管理

将 CSS 和 JavaScript 文件放置在 static/cssstatic/js 目录下,并在模板中引用。

示例:static/css/styles.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
body {
font-family: Arial, sans-serif;
margin: 20px;
}

h1, h2 {
color: #333;
}

form {
margin-bottom: 20px;
}

label {
display: inline-block;
width: 100px;
}

input, textarea {
width: 300px;
padding: 5px;
margin-bottom: 10px;
}

button {
padding: 5px 10px;
}

table {
width: 100%;
border-collapse: collapse;
}

th, td {
border: 1px solid #ccc;
padding: 10px;
text-align: left;
}

th {
background-color: #f4f4f4;
}

示例:static/js/scripts.js

1
2
3
4
5
6
7
8
9
10
11
12
// 目前暂无特定功能,可以添加书签编辑、动态搜索等功能
document.addEventListener('DOMContentLoaded', function() {
// 示例:确认删除
const deleteForms = document.querySelectorAll('form[action^="/bookmarks/delete"]');
deleteForms.forEach(form => {
form.addEventListener('submit', function(e) {
if (!confirm('确定要删除这个书签吗?')) {
e.preventDefault();
}
});
});
});

3. 表单处理与交互

使用表单提交进行数据的创建和删除。对于更复杂的交互(如编辑书签、动态搜索),可以使用 JavaScript 和 AJAX 请求实现。

示例:添加书签

用户在 bookmarks.html 页面填写表单后,表单数据将通过 POST 请求提交到 /bookmarks 路由,后端处理后重定向回书签列表页面。

示例:删除书签

每个书签行中的删除按钮实际上是一个表单,提交到 /bookmarks/delete/:id 路由,后端处理后重定向回书签列表页面。

4. 动态内容渲染

使用 Go 的模板语法在服务器端渲染动态内容,如用户的书签列表。

五、身份验证与安全性

1. 身份验证

使用基于 Cookie 的会话管理或 JWT 进行身份验证。在前面的示例中,我们使用了 JWT 并将其存储在 Cookie 中。

2. 安全性措施

  • 密码加密:使用 bcrypt 哈希用户密码。
  • 输入验证:后端严格验证所有输入,防止 SQL 注入和 XSS 攻击。
  • HTTPS:确保所有通信通过 HTTPS 加密。
  • CORS:如果需要跨域访问,配置跨域资源共享(CORS)策略,限制允许的来源。
  • CSRF 保护:防止跨站请求伪造攻击,可以使用 CSRF 令牌。
  • 速率限制:防止暴力破解攻击,限制 API 请求频率。

六、部署与运维

1. 部署环境

  • 云服务提供商:AWS, Google Cloud, Azure, DigitalOcean 等。
  • 容器化:使用 Docker 封装应用,简化部署和扩展。

2. 部署步骤

  1. 构建 Docker 镜像

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # Dockerfile
    FROM golang:1.20-alpine

    WORKDIR /app

    COPY go.mod .
    COPY go.sum .
    RUN go mod download

    COPY . .

    RUN go build -o main ./cmd/main.go

    EXPOSE 8080

    CMD ["./main"]
  2. 构建并推送镜像

    1
    2
    docker build -t your_dockerhub_username/bookmark-platform:latest .
    docker push your_dockerhub_username/bookmark-platform:latest
  3. 部署到服务器

    • 使用 Docker Compose 或 Kubernetes 进行编排。
    • 配置环境变量和秘密管理。

3. 持续集成与部署(CI/CD)

  • 使用 GitHub Actions, GitLab CI, Jenkins 等工具实现自动化构建、测试和部署。

  • 示例:GitHub Actions 的简单配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    # .github/workflows/go.yml
    name: Go

    on:
    push:
    branches: [ main ]
    pull_request:
    branches: [ main ]

    jobs:
    build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Set up Go
    uses: actions/setup-go@v2
    with:
    go-version: 1.20
    - name: Install dependencies
    run: go mod download
    - name: Run tests
    run: go test ./...
    - name: Build
    run: go build -v ./...

4. 监控与日志

  • 监控:使用 Prometheus 和 Grafana 监控应用性能和资源使用。
  • 日志管理:集中日志管理,如 ELK Stack(Elasticsearch, Logstash, Kibana)或使用云服务提供商的日志解决方案。

七、测试

1. 单元测试

使用 Go 内置的 testing 包编写单元测试。测试各个功能模块,如用户注册、登录、书签管理等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// internal/handlers/auth_test.go
package handlers

import (
"testing"
"net/http/httptest"
"github.com/gin-gonic/gin"
"net/http"
"bytes"
"encoding/json"
)

func TestRegister(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.Default()
router.POST("/register", Register)

payload := RegisterInput{
Username: "testuser",
Email: "[email protected]",
Password: "password123",
}

body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

if resp.Code != http.StatusOK && resp.Code != http.StatusSeeOther {
t.Errorf("Expected status 200 or 303, got %d", resp.Code)
}
}

2. 集成测试

测试 API 端点与数据库的集成。使用工具如 PostmanInsomnia 进行手动测试,或编写自动化测试脚本。

3. 前端测试

虽然使用原生 HTML,仍可使用工具如 Selenium 进行端到端测试,确保用户界面的功能正确性。

八、优化与扩展

1. 性能优化

  • 数据库优化:添加索引,优化查询语句。
  • 缓存:使用 Redis 等缓存常用数据,减少数据库负载。
  • CDN:为静态资源使用 CDN 加速。

2. 功能扩展

  • 批量操作:支持批量添加、编辑、删除书签。
  • 标签管理:提供更丰富的标签功能,如标签建议、自动补全。
  • 导入与导出:允许用户导入浏览器书签,导出为 HTML 或 JSON 格式。
  • 书签分享:支持生成公开或私密的书签链接,方便分享。
  • 多语言支持:国际化(i18n),支持多语言界面。

3. 用户体验提升

  • 书签预览:在书签列表中显示网站预览或截图。
  • 拖拽排序:用户可以通过拖拽调整书签的顺序。
  • 快捷键:提供快捷键,提高用户操作效率。

九、示例代码

以下是一个简单的用户注册和登录的完整示例,基于 Gin 和 GORM,并使用 Go 的模板引擎进行前端渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// cmd/main.go
package main

import (
"your_project/internal/handlers"
"your_project/internal/middleware"
"your_project/internal/utils"

"github.com/gin-gonic/gin"
)

func main() {
utils.LoadConfig()
utils.InitDB()

r := gin.Default()

// 设置模板目录
r.LoadHTMLGlob("templates/*")

// 设置静态文件目录
r.Static("/static", "./static")

// 公开路由
r.GET("/", handlers.ShowHomePage)
r.GET("/register", handlers.ShowRegisterPage)
r.POST("/register", handlers.Register)
r.GET("/login", handlers.ShowLoginPage)
r.POST("/login", handlers.Login)

// 需要认证的路由
authorized := r.Group("/")
authorized.Use(middleware.AuthMiddleware())
{
authorized.GET("/bookmarks", handlers.GetBookmarks)
authorized.POST("/bookmarks", handlers.CreateBookmark)
authorized.POST("/bookmarks/update/:id", handlers.UpdateBookmark)
authorized.POST("/bookmarks/delete/:id", handlers.DeleteBookmark)
authorized.GET("/logout", handlers.Logout)
}

r.Run(":8080")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// internal/models/user.go
package models

import (
"time"
)

type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique;not null" json:"username"`
Email string `gorm:"unique;not null" json:"email"`
Password string `gorm:"not null" json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// internal/models/bookmark.go
package models

import (
"time"
)

type Bookmark struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
Title string `gorm:"not null" json:"title"`
URL string `gorm:"not null" json:"url"`
Description string `json:"description,omitempty"`
Folder string `json:"folder,omitempty"`
Tags string `json:"tags,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// internal/utils/database.go
package utils

import (
"fmt"
"log"
"os"

"your_project/internal/models"

"gorm.io/driver/mysql"
"gorm.io/gorm"
)

var DB *gorm.DB

func InitDB() {
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_NAME"),
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database: ", err)
}

// 自动迁移
err = DB.AutoMigrate(&models.User{}, &models.Bookmark{})
if err != nil {
log.Fatal("Failed to migrate database: ", err)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// internal/handlers/auth.go
package handlers

import (
"net/http"
"time"

"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/dgrijalva/jwt-go"

"your_project/internal/models"
"your_project/internal/utils"
)

type RegisterInput struct {
Username string `form:"username" binding:"required"`
Email string `form:"email" binding:"required,email"`
Password string `form:"password" binding:"required,min=6"`
}

func Register(c *gin.Context) {
var input RegisterInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "register.html", gin.H{
"error": err.Error(),
})
return
}

// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "register.html", gin.H{
"error": "服务器错误",
})
return
}

user := models.User{
Username: input.Username,
Email: input.Email,
Password: string(hashedPassword),
}

if err := utils.DB.Create(&user).Error; err != nil {
c.HTML(http.StatusBadRequest, "register.html", gin.H{
"error": "邮箱或用户名已存在",
})
return
}

c.Redirect(http.StatusSeeOther, "/login")
}

type LoginInput struct {
Email string `form:"email" binding:"required,email"`
Password string `form:"password" binding:"required"`
}

func Login(c *gin.Context) {
var input LoginInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "login.html", gin.H{
"error": err.Error(),
})
return
}

var user models.User
if err := utils.DB.Where("email = ?", input.Email).First(&user).Error; err != nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"error": "无效的凭证",
})
return
}

// 比较密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
c.HTML(http.StatusUnauthorized, "login.html", gin.H{
"error": "无效的凭证",
})
return
}

// 生成JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"exp": time.Now().Add(time.Hour * 72).Unix(),
})

tokenString, err := token.SignedString([]byte("your_secret_key"))
if err != nil {
c.HTML(http.StatusInternalServerError, "login.html", gin.H{
"error": "无法生成令牌",
})
return
}

// 设置Cookie
c.SetCookie("token", tokenString, 3600*72, "/", "", false, true)

c.Redirect(http.StatusSeeOther, "/bookmarks")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// internal/middleware/auth.go
package middleware

import (
"fmt"
"net/http"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := c.Cookie("token")
if err != nil {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}

token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 验证签名方法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte("your_secret_key"), nil
})

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := uint(claims["user_id"].(float64)) // 转换为 uint
c.Set("user_id", userID)
c.Next()
} else {
c.Redirect(http.StatusSeeOther, "/login")
c.Abort()
return
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// internal/handlers/bookmark.go
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"your_project/internal/models"
"your_project/internal/utils"
)

type CreateBookmarkInput struct {
Title string `form:"title" binding:"required"`
URL string `form:"url" binding:"required,url"`
Description string `form:"description"`
Folder string `form:"folder"`
Tags string `form:"tags"` // 可以使用逗号分隔的字符串
}

func CreateBookmark(c *gin.Context) {
var input CreateBookmarkInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "bookmarks.html", gin.H{
"error": err.Error(),
})
return
}

userID := c.MustGet("user_id").(uint)

bookmark := models.Bookmark{
UserID: userID,
Title: input.Title,
URL: input.URL,
Description: input.Description,
Folder: input.Folder,
Tags: input.Tags,
}

if err := utils.DB.Create(&bookmark).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "创建书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

func GetBookmarks(c *gin.Context) {
userID := c.MustGet("user_id").(uint)
var bookmarks []models.Bookmark
if err := utils.DB.Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "获取书签失败",
})
return
}

c.HTML(http.StatusOK, "bookmarks.html", gin.H{
"bookmarks": bookmarks,
})
}

func UpdateBookmark(c *gin.Context) {
var input CreateBookmarkInput
if err := c.ShouldBind(&input); err != nil {
c.HTML(http.StatusBadRequest, "bookmarks.html", gin.H{
"error": err.Error(),
})
return
}

userID := c.MustGet("user_id").(uint)
bookmarkID := c.Param("id")

var bookmark models.Bookmark
if err := utils.DB.Where("id = ? AND user_id = ?", bookmarkID, userID).First(&bookmark).Error; err != nil {
c.HTML(http.StatusNotFound, "bookmarks.html", gin.H{
"error": "未找到书签",
})
return
}

bookmark.Title = input.Title
bookmark.URL = input.URL
bookmark.Description = input.Description
bookmark.Folder = input.Folder
bookmark.Tags = input.Tags

if err := utils.DB.Save(&bookmark).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "更新书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

func DeleteBookmark(c *gin.Context) {
userID := c.MustGet("user_id").(uint)
bookmarkID := c.Param("id")

if err := utils.DB.Where("id = ? AND user_id = ?", bookmarkID, userID).Delete(&models.Bookmark{}).Error; err != nil {
c.HTML(http.StatusInternalServerError, "bookmarks.html", gin.H{
"error": "删除书签失败",
})
return
}

c.Redirect(http.StatusSeeOther, "/bookmarks")
}

十、下一步建议

  1. 最小可行产品(MVP):先实现核心功能,如用户注册、登录和基本的书签管理,快速上线并获取用户反馈。
  2. 用户反馈:通过调查问卷、用户测试等方式收集用户反馈,了解他们的需求和痛点。
  3. 迭代开发:根据反馈持续优化和添加新功能,如高级搜索、书签分享、自动分类等。
  4. 市场推广:通过社交媒体、内容营销、SEO 等方式推广平台,吸引更多用户。
  5. 安全审计:定期进行安全审计,确保用户数据的安全和隐私。

结语

使用原生 HTML 进行前端开发可以简化项目结构,减少依赖,适合初期开发和小型项目。通过合理的服务器端渲染和模板设计,您可以构建一个功能丰富且用户友好的在线书签整理平台。保持代码的整洁和模块化,确保后续的维护和扩展更加便捷。如果在开发过程中遇到具体问题或需要进一步的指导,请随时向我咨询!