Go Web 后台管理系统项目详解

Go Web 后台管理系统项目详解

一、背景介绍

这是一个基于 Go 语言开发的 Web 后台管理系统,为笔者学习期间练手之作,较为粗糙

二、技术架构

后端

  • 语言 :采用 Go 语言(Golang)编写,因其简洁高效、并发能力强,且性能卓越,特别适合构建高并发的 Web 服务。
  • HTTP 路由 :使用 Gorilla Mux 库,它支持灵活的路由定义、中间件集成,方便构建复杂的 API 和 Web 页面路由。
  • 数据库 :选用 MySQL 数据库,通过 github.com/go-sql-driver/mysql 驱动实现与 Go 应用的交互,负责存储用户数据、会话信息等关键数据。

前端

笔者对前端知识不熟悉,使用AI生成相关代码

三、代码结构与功能模块

项目的代码结构清晰合理,按照功能模块划分为多个包,下面对主要包及其功能进行介绍:

config 包

package config

import (
	"database/sql"
	"os"

	"github.com/gorilla/sessions"
)

var (
	// SessionStore 会话存储
	SessionStore = sessions.NewCookieStore([]byte("这是一个固定的密钥,请在生产环境中替换为更安全的值"))

	// DB 数据库连接
	DB *sql.DB
)

// GetEnvOrDefault 获取环境变量,如果不存在则使用默认值
func GetEnvOrDefault(key, defaultValue string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return defaultValue
}

config 包主要负责存储一些全局配置和资源,如会话存储和数据库连接。它定义了一个全局的会话存储 SessionStore,用于管理用户会话。GetEnvOrDefault 函数用于获取环境变量的值,如果变量不存在则返回默认值,方便在不同环境下配置应用。

db 包

package db

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	"GoWeb1/config"
	"GoWeb1/models"

	_ "github.com/go-sql-driver/mysql"
)

// InitDB 初始化数据库
func InitDB() error {
	// 连接数据库
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
		config.GetEnvOrDefault("DB_USER", "root"),
		config.GetEnvOrDefault("DB_PASSWORD", "123456"),
		config.GetEnvOrDefault("DB_HOST", "localhost"),
		config.GetEnvOrDefault("DB_PORT", "3306"),
		config.GetEnvOrDefault("DB_NAME", "goweb"),
	)

	var err error
	config.DB, err = sql.Open("mysql", dsn)
	if err != nil {
		return fmt.Errorf("连接数据库失败: %v", err)
	}

	// 测试连接
	if err = config.DB.Ping(); err != nil {
		return fmt.Errorf("数据库连接测试失败: %v", err)
	}

	// 创建用户表
	_, err = config.DB.Exec(`
		CREATE TABLE IF NOT EXISTS users (
			id INT AUTO_INCREMENT PRIMARY KEY,
			username VARCHAR(50) NOT NULL UNIQUE,
			password_hash VARCHAR(255) NOT NULL,
			role VARCHAR(20) NOT NULL DEFAULT 'user',
			login_attempts INT NOT NULL DEFAULT 0,
			last_attempt DATETIME,
			created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			avatar VARCHAR(255) DEFAULT 'default.png',
			status INT NOT NULL DEFAULT 1
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
	`)
	if err != nil {
		return fmt.Errorf("创建用户表失败: %v", err)
	}

	// 检查是否存在默认管理员用户
	var count int
	err = config.DB.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'admin'").Scan(&count)
	if err != nil {
		return fmt.Errorf("查询管理员用户数量失败: %v", err)
	}

	// 如果没有名为 admin 的用户,才创建默认管理员
	if count == 0 {
		adminHash, _ := utils.HashPassword("123456")

		// 创建管理员用户
		_, err = config.DB.Exec(
			"INSERT INTO users (username, password_hash, role, status) VALUES (?, ?, ?, ?)",
			"admin", adminHash, "admin", 1,
		)
		if err != nil {
			return fmt.Errorf("创建管理员用户失败: %v", err)
		}

		log.Println("已创建默认管理员用户 admin,密码为 123456")
	} else {
		log.Println("默认管理员用户 admin 已存在,无需创建")
	}

	// 添加 avatar 字段到用户表(如果不存在)
	_, err = config.DB.Exec(`
		ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar VARCHAR(255) DEFAULT 'default.png'
	`)
	if err != nil {
		log.Printf("添加 avatar 字段警告: %v", err)
	}

	// 创建会话表
	_, err = config.DB.Exec(`
		CREATE TABLE IF NOT EXISTS sessions (
			id VARCHAR(100) PRIMARY KEY,
			username VARCHAR(50),
			role VARCHAR(20),
			created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
		)
	`)
	if err != nil {
		log.Printf("创建会话表失败: %v", err)
	}

	// 创建密码重置表
	_, err = config.DB.Exec(`
		CREATE TABLE IF NOT EXISTS password_resets (
			username VARCHAR(50) PRIMARY KEY,
			token VARCHAR(100),
			expiry DATETIME
		)
	`)
	if err != nil {
		log.Printf("创建密码重置表失败: %v", err)
	}

	log.Println("数据库初始化成功")
	return nil
}

// CreateAccessLogTable 创建访问记录表
func CreateAccessLogTable() error {
	_, err := config.DB.Exec("CREATE TABLE IF NOT EXISTS access_log (id INT AUTO_INCREMENT PRIMARY KEY, access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
	return err
}

// GetUserByUsername 根据用户名获取用户信息
func GetUserByUsername(username string) (models.User, error) {
	var user models.User
	err := config.DB.QueryRow(
		"SELECT id, username, password_hash, role, login_attempts, IFNULL(last_attempt, NOW()), created_at, updated_at, IFNULL(avatar, 'default.png'), status FROM users WHERE username = ?",
		username,
	).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.LoginAttempts, &user.LastAttempt, &user.CreatedAt, &user.UpdatedAt, &user.Avatar, &user.Status)
	return user, err
}

// UpdateUserLoginAttempts 更新用户登录尝试信息
func UpdateUserLoginAttempts(userID int, attempts int, lastAttempt time.Time) error {
	_, err := config.DB.Exec(
		"UPDATE users SET login_attempts = ?, last_attempt = ? WHERE id = ?",
		attempts, lastAttempt, userID,
	)
	return err
}

// IsUsernameExists 检查用户名是否存在
func IsUsernameExists(username string) bool {
	var count int
	config.DB.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count)
	return count > 0
}

// CreateUser 创建新用户
func CreateUser(username, passwordHash string, role string, status int) error {
	_, err := config.DB.Exec(
		"INSERT INTO users (username, password_hash, role, status) VALUES (?, ?, ?, ?)",
		username, passwordHash, role, status,
	)
	return err
}

// CountRegisteredUsers 统计注册用户数量
func CountRegisteredUsers() (int, error) {
	var count int
	err := config.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
	return count, err
}

// CountAccessTrends 统计访问趋势
func CountAccessTrends(timeRange string) (int, error) {
	var query string
	var startDateStr string

	now := time.Now()

	switch timeRange {
	case "7天":
		startDateStr = now.AddDate(0, 0, -7).Format("2006-01-02")
		query = "SELECT COUNT(*) FROM access_log WHERE access_time >= ?"
	case "30天":
		startDateStr = now.AddDate(0, 0, -30).Format("2006-01-02")
		query = "SELECT COUNT(*) FROM access_log WHERE access_time >= ?"
	default:
		query = "SELECT COUNT(*) FROM access_log"
		// 不需要参数,计算所有访问量
		var count int
		err := config.DB.QueryRow(query).Scan(&count)
		return count, err
	}

	var count int
	err := config.DB.QueryRow(query, startDateStr).Scan(&count)
	return count, err
}

// GetAccessTrendData 获取按日期分组的访问趋势数据
func GetAccessTrendData(days int) ([]models.AccessTrendData, error) {
	// 计算开始日期,不依赖CURDATE()
	endDate := time.Now()
	startDate := endDate.AddDate(0, 0, -days)

	// 格式化日期为MySQL日期格式
	startDateStr := startDate.Format("2006-01-02")

	query := `
		SELECT 
			DATE(access_time) as date, 
			COUNT(*) as count 
		FROM 
			access_log 
		WHERE 
			access_time >= ? 
		GROUP BY 
			DATE(access_time) 
		ORDER BY 
			date ASC
	`

	rows, err := config.DB.Query(query, startDateStr)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var result []models.AccessTrendData
	for rows.Next() {
		var data models.AccessTrendData
		if err := rows.Scan(&data.Date, &data.Count); err != nil {
			return nil, err
		}
		result = append(result, data)
	}

	// 如果没有数据,填充空数据
	if len(result) == 0 {
		result = make([]models.AccessTrendData, days)
		for i := 0; i < days; i++ {
			date := time.Now().AddDate(0, 0, -days+i+1)
			result[i] = models.AccessTrendData{
				Date:  date.Format("2006-01-02"),
				Count: 0,
			}
		}
		return result, nil
	}

	// 填充缺失的日期
	filled := make([]models.AccessTrendData, 0)
	currentDate := startDate.AddDate(0, 0, 1)

	dataMap := make(map[string]int)
	for _, data := range result {
		dataMap[data.Date] = data.Count
	}

	for !currentDate.After(endDate) {
		dateStr := currentDate.Format("2006-01-02")
		count, exists := dataMap[dateStr]
		if !exists {
			count = 0
		}
		filled = append(filled, models.AccessTrendData{
			Date:  dateStr,
			Count: count,
		})
		currentDate = currentDate.AddDate(0, 0, 1)
	}

	return filled, nil
}

db 包负责与数据库进行交互,完成各种数据的增删改查操作。它提供了从初始化数据库连接、创建必要表结构,到用户认证、数据统计等一系列功能。

InitDB 函数是数据库模块的核心入口,它根据环境变量配置连接到 MySQL 数据库,并创建必要的表结构,包括用户表、会话表和密码重置表。它还检查是否存在默认的管理员用户(admin),如果不存在则创建,并为其设置默认密码。

GetUserByUsername 函数根据用户名查询用户信息,返回一个包含用户详细信息的 models.User 结构体。

UpdateUserLoginAttempts 用于更新用户的登录尝试次数和最后登录时间。

IsUsernameExists 检查指定的用户名是否已被注册。

CreateUser 向数据库中插入新的用户记录。

CountRegisteredUsersCountAccessTrends 分别用于统计注册用户数量和访问趋势数据。

GetAccessTrendData 获取按日期分组的访问趋势数据,用于在前端绘制访问统计图表。

handlers 包

package handlers

import (
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"time"

	"GoWeb1/config"
	"GoWeb1/db"
	"GoWeb1/utils"

	"github.com/gorilla/sessions"
)

// LoginHandler 登录处理函数
func LoginHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		data := map[string]interface{}{}
		if r.URL.Query().Get("registered") == "1" {
			data["success"] = "注册成功,请登录"
		}
		if r.URL.Query().Get("reset") == "1" {
			data["success"] = "密码重置成功,请使用新密码登录"
		}

		tmpl := template.Must(template.ParseFiles("templates/login.html"))
		tmpl.Execute(w, data)
		return
	}

	// POST 处理
	username := r.FormValue("username")
	password := r.FormValue("password")

	log.Printf("尝试登录: 用户名=%s", username)

	// 查询用户
	user, err := db.GetUserByUsername(username)
	if err != nil {
		log.Printf("查询用户失败: %v", err)
		http.Error(w, "用户名或密码错误", http.StatusUnauthorized)
		return
	}

	log.Printf("找到用户: ID=%d, 用户名=%s, 角色=%s", user.ID, user.Username, user.Role)

	// 检查密码
	if !utils.CheckPassword(password, user.PasswordHash) {
		log.Printf("密码验证失败")
		http.Error(w, "用户名或密码错误", http.StatusUnauthorized)
		return
	}

	log.Printf("密码验证成功")

	// 检查用户状态是否被禁用
	if user.Status == 0 {
		log.Printf("用户 %s 已被管理员禁用", username)
		http.Error(w, "您的账户已被禁用,请联系管理员", http.StatusForbidden)
		return
	}

	// 更新用户的登录尝试次数和最后登录时间
	err = db.UpdateUserLoginAttempts(user.ID, 0, time.Now())
	if err != nil {
		log.Printf("更新用户登录时间失败: %v", err)
		// 继续处理,不中断登录流程
	}

	// 清除所有现有的Cookie
	for _, cookie := range r.Cookies() {
		newCookie := &http.Cookie{
			Name:     cookie.Name,
			Value:    "",
			Path:     "/",
			MaxAge:   -1,
		}
		http.SetCookie(w, newCookie)
	}

	// 删除该用户的所有旧会话记录
	_, err = config.DB.Exec("DELETE FROM sessions WHERE username = ?", username)
	if err != nil {
		log.Printf("删除旧会话记录失败: %v", err)
		// 继续处理,不中断登录流程
	}

	// 创建一个新的会话ID
	sessionID := utils.GenerateRandomString(32)
	http.SetCookie(w, &http.Cookie{
		Name:     "user_session",
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
		MaxAge:   86400, // 1天
	})

	// 插入新的会话记录
	_, err = config.DB.Exec("INSERT INTO sessions (id, username, role) VALUES (?, ?, ?)", sessionID, user.Username, user.Role)
	if err != nil {
		log.Printf("保存会话失败: %v", err)
		http.Error(w, "服务器内部错误", http.StatusInternalServerError)
		return
	}

	log.Printf("会话已创建,重定向到首页")
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

// RegisterHandler 注册处理
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		tmpl := template.Must(template.ParseFiles("templates/register.html"))
		tmpl.Execute(w, nil)
		return
	}

	// POST 请求处理
	username := r.FormValue("username")
	password := r.FormValue("password")
.confirmPassword := r.FormValue("confirm_password")

	// 表单验证
	var errorMsg string
	if !utils.IsValidUsername(username) {
		errorMsg = "用户名必须是4-20个字符,且只能包含字母、数字和下划线"
	} else if db.IsUsernameExists(username) {
		errorMsg = "用户名已被使用"
	} else if !utils.IsValidPassword(password) {
		errorMsg = "密码至少需要6个字符"
	} else if password != .confirmPassword {
		errorMsg = "两次输入的密码不一致"
	}

	if errorMsg != "" {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(errorMsg))
		return
	}

	// 创建用户
	hashedPassword, err := utils.HashPassword(password)
	if err != nil {
		log.Printf("密码加密失败: %v", err)
		http.Error(w, "注册失败,请稍后再试", http.StatusInternalServerError)
		return
	}

	err = db.CreateUser(username, hashedPassword, "user", 1)
	if err != nil {
		log.Printf("创建用户失败: %v", err)
		http.Error(w, "注册失败,请稍后再试", http.StatusInternalServerError)
		return
	}

	log.Printf("用户 %s 注册成功", username)
	http.Redirect(w, r, "/login?registered=1", http.StatusSeeOther)
}

// LogoutHandler 登出处理
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
	// 从Cookie获取会话ID
	cookie, err := r.Cookie("user_session")
	if err == nil {
		// 删除数据库中的会话记录
		_, err := config.DB.Exec("DELETE FROM sessions WHERE id = ?", cookie.Value)
		if err != nil {
			log.Printf("删除会话记录失败: %v", err)
		}

		// 清除Cookie
		http.SetCookie(w, &http.Cookie{
			Name:     "user_session",
			Value:    "",
			Path:     "/",
			MaxAge:   -1,
			HttpOnly: true,
		})
	}

	// 清除所有其他可能的Cookie
	for _, c := range r.Cookies() {
		http.SetCookie(w, &http.Cookie{
			Name:     c.Name,
			Value:    "",
			Path:     "/",
			MaxAge:   -1,
			HttpOnly: true,
		})
	}

	// 设置响应头,防止缓存
	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
	w.Header().Set("Pragma", "no-cache")
	w.Header().Set("Expires", "0")

	// 重定向到登录页面
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

// ClearCookieHandler 清除会话Cookie处理函数
func ClearCookieHandler(w http.ResponseWriter, r *http.Request) {
	// 清除会话Cookie
	cookie := &http.Cookie{
		Name:     "session",
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	}
	http.SetCookie(w, cookie)

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write([]byte(`
		<html>
		<head>
			<title>会话已清除</title>
			<meta http-equiv="refresh" content="2;url=/login">
			<style>
				body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
			</style>
		</head>
		<body>
			<h1>会话已清除</h1>
			<p>正在跳转到登录页面...</p>
		</body>
		</html>
	`))
}

handlers 包是项目的请求处理中心,它定义了各种 HTTP 请求的处理函数,实现了用户与系统的交互逻辑。

LoginHandler 处理用户的登录请求。对于 GET 请求,它渲染登录页面;对于 POST 请求,它验证用户输入的用户名和密码,查询数据库中的用户信息,检查密码是否匹配以及用户状态是否正常。如果验证通过,则为用户创建一个新的会话 ID,存储到数据库,并将其作为 Cookie 发送给客户端,最后重定向用户到首页。

RegisterHandler 处理用户的注册请求。它对用户输入的注册信息进行验证,包括用户名格式、密码强度以及两次输入密码是否一致。验证通过后,对密码进行加密,并将新用户信息插入到数据库中,注册成功后重定向用户到登录页面。

LogoutHandler 实现用户登出功能。它通过获取用户 Cookie 中的会话 ID,从数据库中删除对应的会话记录,并清除客户端的会话 Cookie,确保用户安全退出系统。

ClearCookieHandler 用于清除会话 Cookie,它重置会话相关的 Cookie,并重定向用户到登录页面。

这些处理函数共同协作,实现了用户认证与会话管理的核心功能,确保用户能够安全地登录、注册和退出系统。

middleware 包

package middleware

import (
	"context"
	"log"
	"net/http"

	"GoWeb1/config"
)

// AuthMiddleware 身份验证中间件
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 从Cookie获取会话ID
		cookie, err := r.Cookie("user_session")
		if err != nil {
			log.Printf("获取会话Cookie失败: %v", err)
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}

		// 从数据库中验证会话
		var username, role string
		err = config.DB.QueryRow("SELECT username, role FROM sessions WHERE id = ?", cookie.Value).Scan(&username, &role)
		if err != nil {
			log.Printf("查询会话记录失败: %v", err)
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}

		// 检查用户状态是否被禁用
		var status int
		err = config.DB.QueryRow("SELECT status FROM users WHERE username = ?", username).Scan(&status)
		if err != nil || status == 0 {
			// 如果用户被禁用,删除会话并重定向到登录页面
			config.DB.Exec("DELETE FROM sessions WHERE id = ?", cookie.Value)
			http.SetCookie(w, &http.Cookie{
				Name:     "user_session",
				Value:    "",
				Path:     "/",
				MaxAge:   -1,
				HttpOnly: true,
			})
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}

		// 会话有效,设置上下文并调用下一个处理函数
		r = r.WithContext(context.WithValue(r.Context(), "username", username))
		r = r.WithContext(context.WithValue(r.Context(), "role", role))
		next(w, r)
	}
}

// AdminMiddleware 管理员权限中间件
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 从Cookie获取会话ID
		cookie, err := r.Cookie("user_session")
		if err != nil {
			log.Printf("获取会话Cookie失败: %v", err)
			http.Error(w, "未授权", http.StatusUnauthorized)
			return
		}

		// 从数据库中获取用户名和角色
		var username, role string
		err = config.DB.QueryRow("SELECT username, role FROM sessions WHERE id = ?", cookie.Value).Scan(&username, &role)
		if err != nil {
			log.Printf("查询会话记录失败: %v", err)
			http.Error(w, "未授权", http.StatusUnauthorized)
			return
		}

		// 检查是否是管理员角色
		if role != "admin" {
			http.Error(w, "权限不足", http.StatusForbidden)
			return
		}

		next(w, r)
	}
}

middleware 包定义了两个核心中间件:AuthMiddlewareAdminMiddleware

AuthMiddleware 用于验证普通用户的会话。它通过检查请求中的会话 Cookie,查询数据库中的会话记录来验证用户是否已登录。如果会话无效或用户已被禁用,它会重定向用户到登录页面。对于有效会话,它将用户信息存储到请求上下文中,供后续处理函数使用。

AdminMiddleware 则在 AuthMiddleware 的基础上,进一步验证用户是否具有管理员权限。只有当用户角色为 admin 时,才允许访问受保护的管理员路由。

这些中间件通过拦截请求,在请求到达处理函数之前验证用户身份和权限,确保系统的安全性和功能的正确访问。

utils 包

package utils

import (
	"crypto/rand"
	"encoding/base64"
	"regexp"

	"golang.org/x/crypto/bcrypt"
)

// HashPassword 密码加密函数
func HashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
	return string(bytes), err
}

// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

// GenerateRandomString 生成随机字符串
func GenerateRandomString(length int) string {
	b := make([]byte, length)
	rand.Read(b)
	return base64.URLEncoding.EncodeToString(b)[:length]
}

// IsValidUsername 验证用户名格式
func IsValidUsername(username string) bool {
	if len(username) < 4 || len(username) > 20 {
		return false
	}
	// 只允许字母、数字和下划线
	re := regexp.MustCompile("^[a-zA-Z0-9_]+$")
	return re.MatchString(username)
}

// IsValidPassword 验证密码强度
func IsValidPassword(password string) bool {
	return len(password) >= 6
}

utils 包提供了项目中重复使用的工具函数。

HashPassword 使用 bcrypt 算法对密码进行加密,生成安全的密码哈希值。

CheckPassword 用于验证用户输入的明文密码与数据库中存储的密码哈希值是否匹配。

GenerateRandomString 生成指定长度的随机字符串,用于会话 ID、密码重置令牌等场景。

IsValidUsernameIsValidPassword 分别用于验证用户名和密码是否符合规定的格式和强度要求。

四、总结

这个基于 Go 语言的 Web 后台管理系统项目尚有许多bug和不足之处,如有指教将不胜感激

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

朱颜辞镜花辞树‎

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值