diff --git a/cmd/migrate_typecho/main.go b/cmd/migrate_typecho/main.go new file mode 100644 index 0000000..988c667 --- /dev/null +++ b/cmd/migrate_typecho/main.go @@ -0,0 +1,262 @@ +// 一次性迁移工具:将远程 Typecho 数据库 typecho_contents 表中的文章(post)和页面(page) +// 迁移到本地 goblog.db 的 posts / pages 表。slug 相同的记录执行覆盖更新。 +// 用法: go run ./cmd/migrate_typecho -host 47.93.160.42 -port 3630 -user blog -pass 'xxx' +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "strings" + "time" + + "goblog/models" + + "github.com/glebarez/sqlite" + "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// typechoContent 对应 typecho_contents 表的一行 +type typechoContent struct { + CID int64 + Title sql.NullString + Slug sql.NullString + Created int64 + Modified int64 + Text sql.NullString + Order int64 + Template sql.NullString + Type string + Status string + Password sql.NullString + AllowComment string + AllowPing string +} + +func main() { + host := flag.String("host", "47.93.160.42", "MySQL主机") + port := flag.Int("port", 3630, "MySQL端口") + user := flag.String("user", "blog", "MySQL用户名") + pass := flag.String("pass", "qI7=bL4@iJ", "MySQL密码") + dbName := flag.String("db", "typecho", "typecho数据库名(留空自动探测)") + sqlitePath := flag.String("sqlite", "goblog.db", "本地SQLite文件路径") + dryRun := flag.Bool("dry-run", false, "只对比统计,不写入") + flag.Parse() + + // ---------- 连接远程 MySQL ---------- + cfg := mysql.NewConfig() + cfg.User = *user + cfg.Passwd = *pass + cfg.Net = "tcp" + cfg.Addr = fmt.Sprintf("%s:%d", *host, *port) + cfg.DBName = *dbName + cfg.Timeout = 10 * time.Second + cfg.ReadTimeout = 30 * time.Second + cfg.Params = map[string]string{"charset": "utf8mb4"} + + mysqlDB, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + log.Fatalf("MySQL DSN错误: %v", err) + } + defer mysqlDB.Close() + if err = mysqlDB.Ping(); err != nil { + log.Fatalf("MySQL连接失败: %v", err) + } + log.Printf("MySQL连接成功: %s", cfg.Addr) + + // 自动探测包含 typecho_contents 的数据库 + schema := *dbName + if schema == "" { + err = mysqlDB.QueryRow( + "SELECT table_schema FROM information_schema.tables WHERE table_name = 'typecho_contents' LIMIT 1", + ).Scan(&schema) + if err != nil { + log.Fatalf("未找到 typecho_contents 表: %v", err) + } + log.Printf("探测到typecho数据库: %s", schema) + } + + // ---------- 连接本地 SQLite ---------- + localDB, err := gorm.Open(sqlite.Open(*sqlitePath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + log.Fatalf("SQLite连接失败: %v", err) + } + + // 迁移文章归属到本地第一个管理员账号 + var author models.User + if err = localDB.Where("role = ?", "admin").First(&author).Error; err != nil { + if err = localDB.First(&author).Error; err != nil { + log.Fatalf("本地无任何用户,无法确定文章作者: %v", err) + } + } + log.Printf("迁移内容将归属本地用户: %s (ID=%d)", author.Username, author.ID) + + // ---------- 读取远程内容 ---------- + rows, err := mysqlDB.Query(fmt.Sprintf( + "SELECT cid, title, slug, created, modified, text, `order`, template, type, status, password, allowComment, allowPing "+ + "FROM `%s`.typecho_contents WHERE type IN ('post', 'page') ORDER BY cid", schema)) + if err != nil { + log.Fatalf("查询 typecho_contents 失败: %v", err) + } + defer rows.Close() + + var contents []typechoContent + for rows.Next() { + var c typechoContent + if err = rows.Scan(&c.CID, &c.Title, &c.Slug, &c.Created, &c.Modified, &c.Text, + &c.Order, &c.Template, &c.Type, &c.Status, &c.Password, &c.AllowComment, &c.AllowPing); err != nil { + log.Fatalf("读取行失败: %v", err) + } + contents = append(contents, c) + } + if err = rows.Err(); err != nil { + log.Fatalf("遍历结果失败: %v", err) + } + log.Printf("远程共读取 %d 条内容(post+page)", len(contents)) + + // ---------- 写入本地 ---------- + var postNew, postUpd, pageNew, pageUpd, skipped int + for _, c := range contents { + title := strings.TrimSpace(c.Title.String) + if title == "" { + log.Printf("跳过 cid=%d: 标题为空", c.CID) + skipped++ + continue + } + slug := strings.TrimSpace(c.Slug.String) + if slug == "" { + slug = fmt.Sprintf("typecho-%d", c.CID) + } + content := cleanContent(c.Text.String) + createdAt := time.Unix(c.Created, 0) + updatedAt := time.Unix(c.Modified, 0) + if c.Modified == 0 { + updatedAt = createdAt + } + status := "draft" + if c.Status == "publish" { + status = "published" + } + + if *dryRun { + log.Printf("[dry-run] %s cid=%d slug=%s title=%s status=%s", c.Type, c.CID, slug, title, status) + continue + } + + switch c.Type { + case "post": + post := models.Post{ + Title: title, + Slug: slug, + Content: content, + Summary: extractSummary(content), + AuthorID: author.ID, + Status: status, + Password: c.Password.String, + AllowComment: c.AllowComment == "1", + AllowPing: c.AllowPing == "1", + Template: c.Template.String, + } + if status == "published" { + post.PublishedAt = &createdAt + } + + var existing models.Post + err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error + if err == nil { + // 覆盖更新已存在的文章 + post.ID = existing.ID + post.CreatedAt = createdAt + post.UpdatedAt = updatedAt + post.Views = existing.Views + post.CategoryID = existing.CategoryID + post.IsTop = existing.IsTop + if err = localDB.Unscoped().Model(&models.Post{}).Where("id = ?", existing.ID). + Select("Title", "Content", "Summary", "AuthorID", "Status", "Password", + "AllowComment", "AllowPing", "Template", "PublishedAt", "CreatedAt", "UpdatedAt", "DeletedAt"). + Updates(map[string]interface{}{ + "title": post.Title, "content": post.Content, "summary": post.Summary, + "author_id": post.AuthorID, "status": post.Status, "password": post.Password, + "allow_comment": post.AllowComment, "allow_ping": post.AllowPing, + "template": post.Template, "published_at": post.PublishedAt, + "created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil, + }).Error; err != nil { + log.Fatalf("更新文章失败 slug=%s: %v", slug, err) + } + postUpd++ + } else if err == gorm.ErrRecordNotFound { + post.CreatedAt = createdAt + post.UpdatedAt = updatedAt + if err = localDB.Create(&post).Error; err != nil { + log.Fatalf("插入文章失败 slug=%s: %v", slug, err) + } + postNew++ + } else { + log.Fatalf("查询本地文章失败 slug=%s: %v", slug, err) + } + + case "page": + var existing models.Page + err = localDB.Unscoped().Where("slug = ?", slug).First(&existing).Error + if err == nil { + if err = localDB.Unscoped().Model(&models.Page{}).Where("id = ?", existing.ID). + Updates(map[string]interface{}{ + "title": title, "content": content, "author_id": author.ID, + "status": status, "order": c.Order, "password": c.Password.String, + "allow_comment": c.AllowComment == "1", + "created_at": createdAt, "updated_at": updatedAt, "deleted_at": nil, + }).Error; err != nil { + log.Fatalf("更新页面失败 slug=%s: %v", slug, err) + } + pageUpd++ + } else if err == gorm.ErrRecordNotFound { + page := models.Page{ + Title: title, + Slug: slug, + Content: content, + AuthorID: author.ID, + Status: status, + Order: int(c.Order), + Password: c.Password.String, + AllowComment: c.AllowComment == "1", + } + page.CreatedAt = createdAt + page.UpdatedAt = updatedAt + if err = localDB.Create(&page).Error; err != nil { + log.Fatalf("插入页面失败 slug=%s: %v", slug, err) + } + pageNew++ + } else { + log.Fatalf("查询本地页面失败 slug=%s: %v", slug, err) + } + } + } + + log.Printf("迁移完成: 文章新增 %d 篇 / 覆盖 %d 篇, 页面新增 %d 个 / 覆盖 %d 个, 跳过 %d 条", + postNew, postUpd, pageNew, pageUpd, skipped) +} + +// cleanContent 清理typecho内容: 去掉 标记前缀 +func cleanContent(text string) string { + return strings.TrimPrefix(text, "") +} + +// extractSummary 提取摘要: 优先取 之前的部分, 否则取前200个字符 +func extractSummary(content string) string { + if idx := strings.Index(content, ""); idx >= 0 { + content = content[:idx] + } + // 去掉常见markdown标记后截断 + plain := strings.NewReplacer("#", "", "*", "", "`", "", ">", "", "\r", "").Replace(content) + plain = strings.TrimSpace(plain) + runes := []rune(plain) + if len(runes) > 200 { + return string(runes[:200]) + } + return plain +} diff --git a/go.mod b/go.mod index 2b57522..431a8fb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.11.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-sql-driver/mysql v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/spf13/viper v1.21.0 github.com/yuin/goldmark v1.8.5 @@ -14,6 +15,7 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect diff --git a/go.sum b/go.sum index 3034aa4..8993b60 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -31,6 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= diff --git a/goblog.db b/goblog.db index e68b331..d6277ef 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/goblog.db.bak-migrate b/goblog.db.bak-migrate new file mode 100644 index 0000000..c9bb116 Binary files /dev/null and b/goblog.db.bak-migrate differ diff --git a/handlers/user.go b/handlers/user.go new file mode 100644 index 0000000..2ed6b56 --- /dev/null +++ b/handlers/user.go @@ -0,0 +1,255 @@ +package handlers + +import ( + "net/http" + "strconv" + + "goblog/database" + "goblog/models" + + "github.com/gin-gonic/gin" +) + +// 创建用户请求 +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` + Email string `json:"email" binding:"required,email"` + Role string `json:"role"` + Status *int `json:"status"` +} + +// 更新用户请求 +type AdminUpdateUserRequest struct { + Nickname string `json:"nickname"` + Email string `json:"email"` + Role string `json:"role"` + Status *int `json:"status"` +} + +// 重置密码请求 +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// 获取用户列表(管理员) +func GetUsers(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + query := database.DB.Model(&models.User{}) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like) + } + + var total int64 + query.Count(&total) + + var users []models.User + if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": users, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// 创建用户(管理员) +func CreateUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查用户名是否已存在 + var count int64 + database.DB.Model(&models.User{}).Where("username = ?", req.Username).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"}) + return + } + + // 检查邮箱是否已存在 + database.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被注册"}) + return + } + + role := req.Role + if role != "admin" { + role = "user" + } + + status := 1 + if req.Status != nil { + status = *req.Status + } + + user := models.User{ + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + Role: role, + Status: status, + IsActive: true, + } + + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": user}) +} + +// 更新用户信息(管理员) +func AdminUpdateUser(c *gin.Context) { + id := c.Param("id") + operatorID, _ := c.Get("userID") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + var req AdminUpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + isSelf := operatorID == user.ID + + updates := map[string]interface{}{ + "nickname": req.Nickname, + } + + if req.Email != "" && req.Email != user.Email { + // 检查邮箱是否已被其他用户使用 + var count int64 + database.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, user.ID).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱已被其他用户使用"}) + return + } + updates["email"] = req.Email + } + + if req.Role != "" && req.Role != user.Role { + if isSelf { + c.JSON(http.StatusBadRequest, gin.H{"error": "不能修改自己的角色"}) + return + } + if req.Role != "admin" && req.Role != "user" { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的角色"}) + return + } + updates["role"] = req.Role + } + + if req.Status != nil && *req.Status != user.Status { + if isSelf { + c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"}) + return + } + updates["status"] = *req.Status + } + + if err := database.DB.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +// 重置用户密码(管理员) +func AdminResetPassword(c *gin.Context) { + id := c.Param("id") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + var req ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := user.SetPassword(req.NewPassword); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + + if err := database.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"}) +} + +// 删除用户(管理员) +func DeleteUser(c *gin.Context) { + id := c.Param("id") + operatorID, _ := c.Get("userID") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + if operatorID == user.ID { + c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己的账号"}) + return + } + + // 检查该用户是否有文章 + var count int64 + database.DB.Model(&models.Post{}).Where("author_id = ?", user.ID).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有文章,无法删除"}) + return + } + + // 检查该用户是否有页面 + database.DB.Model(&models.Page{}).Where("author_id = ?", user.ID).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "该用户名下还有页面,无法删除"}) + return + } + + if err := database.DB.Delete(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/routers/router.go b/routers/router.go index cc2fdb4..361a409 100644 --- a/routers/router.go +++ b/routers/router.go @@ -100,6 +100,13 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) { admin.PUT("/comments/:id/spam", handlers.MarkSpamComment) admin.DELETE("/comments/:id", handlers.DeleteComment) + // 用户管理 + admin.GET("/users", handlers.GetUsers) + admin.POST("/users", handlers.CreateUser) + admin.PUT("/users/:id", handlers.AdminUpdateUser) + admin.PUT("/users/:id/password", handlers.AdminResetPassword) + admin.DELETE("/users/:id", handlers.DeleteUser) + // 文件管理(图片上传) admin.POST("/upload", handlers.UploadImage) admin.GET("/uploads", handlers.ListUploads) diff --git a/static/admin/app.js b/static/admin/app.js index 335c82f..42fd03a 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -43,6 +43,9 @@ document.addEventListener('DOMContentLoaded', () => { // 模态框表单 document.getElementById('modal-form').addEventListener('submit', handleModalSubmit); + // 用户表单 + document.getElementById('user-form').addEventListener('submit', handleUserSubmit); + // 封面图上传 const coverFileInput = document.getElementById('post-cover-file'); if (coverFileInput) { @@ -104,6 +107,9 @@ function showPage(pageName) { case 'comments': loadComments(); break; + case 'users': + loadUsers(); + break; case 'files': loadFiles(); break; @@ -709,6 +715,203 @@ function getStatusText(status) { return map[status] || status; } +// ==================== 用户管理 ==================== + +// 加载用户列表 +async function loadUsers() { + try { + const res = await fetch(`${API_BASE}/admin/users?page_size=100`, { headers: getHeaders() }); + const data = await res.json(); + + const tbody = document.getElementById('users-list'); + tbody.innerHTML = data.data?.map(u => { + const isSelf = currentUser && u.id === currentUser.id; + return ` +