初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可

This commit is contained in:
2026-05-26 13:46:33 +08:00
parent 1315df6501
commit 483fdd919f
56 changed files with 5804 additions and 40 deletions

62
internal/common/cookie.go Normal file
View File

@ -0,0 +1,62 @@
package common
import (
"log"
"net/http"
"metazone.cc/metalab/internal/config"
"github.com/gin-gonic/gin"
)
// Cookie 名称常量
const (
CookieName = "mlb_token"
RefreshCookieName = "mlb_refresh"
RMCookieName = "mlb_rm" // 记住我标记JS 可读
)
// SetAuthCookies 设置 access + refresh Cookie
// rememberMe=true → Cookie 持久化30天关浏览器后仍保持登录
// rememberMe=false → Cookie 用 session 模式maxAge=0关浏览器即清除但页面开启期间自动刷新
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
if rememberMe {
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
} else {
// session cookie关浏览器即清除但页面开启期间自动刷新生效
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
setPlainCookie(c, RMCookieName, "1", 0, secure)
}
}
// SetAccessCookie 仅刷新 access Cookierefresh 续期调用)
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
}
// ClearAuthCookies 清除所有认证 Cookielogout 调用)
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
}
// setCookie 写入一个 HttpOnly Cookie
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
c.SetCookie(name, value, maxAge, "/", "", secure, true)
}
// setPlainCookie 写入非 HttpOnly CookieJS 可读)
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
c.SetCookie(name, value, maxAge, "/", "", secure, false)
}

20
internal/common/crypto.go Normal file
View File

@ -0,0 +1,20 @@
package common
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用 bcrypt 哈希密码
func HashPassword(password string, cost int) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
}
return string(bytes), nil
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

22
internal/common/errors.go Normal file
View File

@ -0,0 +1,22 @@
package common
import "errors"
// 认证相关错误哨兵(统一存放,避免 service 和 middleware 交叉引用)
var (
ErrEmailExists = errors.New("该邮箱已注册")
ErrUsernameTaken = errors.New("该用户名已被占用")
ErrInvalidCred = errors.New("邮箱或密码错误")
ErrUserNotFound = errors.New("用户不存在")
ErrUserBanned = errors.New("该账号已被封禁")
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
ErrUserLocked = errors.New("邮箱或密码错误")
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
ErrTokenExpired = errors.New("登录已过期,请重新登录")
ErrTokenInvalid = errors.New("无效的认证凭据")
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
ErrRateLimitAccount = errors.New("该账号登录尝试过于频繁")
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
ErrPermissionDenied = errors.New("权限不足")
)

44
internal/common/helper.go Normal file
View File

@ -0,0 +1,44 @@
package common
import (
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// BuildPageData 构建页面模板数据,自动注入登录状态与 CSRF token
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{}
for k, v := range extra {
data[k] = v
}
if username, exists := c.Get("username"); exists {
data["IsLoggedIn"] = true
data["Username"] = username
}
// 注入 CSRF token由 CSRF 中间件设置到上下文中)
if token, exists := c.Get("csrf_token"); exists {
data["CSRFToken"] = token
}
return data
}
// BuildAdminPageData 构建管理后台模板数据
// 额外注入 UID、Role、CanManageUsers、CurrentPath 等权限信息
func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
data := BuildPageData(c, extra)
data["IsAdmin"] = true
data["CurrentPath"] = c.Request.URL.Path
if uid, exists := c.Get("uid"); exists {
data["UID"] = uid
}
if role, exists := c.Get("role"); exists {
roleStr := role.(string)
data["Role"] = roleStr
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
}
return data
}

View File

@ -0,0 +1,51 @@
package common
import "time"
// Pagination 分页请求参数
type Pagination struct {
Page int `form:"page" json:"page" binding:"min=1"`
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
}
// PaginatedResult 分页响应
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// DefaultPagination 默认分页(未传参时使用)
func (p *Pagination) DefaultPagination() {
if p.Page < 1 {
p.Page = 1
}
if p.PageSize < 1 || p.PageSize > 100 {
p.PageSize = 20
}
}
// Offset 计算 SQL offset
func (p *Pagination) Offset() int {
return (p.Page - 1) * p.PageSize
}
// NewPaginatedResult 构建分页响应
func NewPaginatedResult(items interface{}, total int64, page, pageSize int) *PaginatedResult {
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
}
// 固定时间格式,前后端统一
const TimeFormat = time.RFC3339

View File

@ -0,0 +1,20 @@
package common
import (
"github.com/gin-gonic/gin"
"net/http"
)
// 统一 JSON 响应
func Ok(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
func OkMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
}
func Error(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"success": false, "message": message})
}

View File

@ -0,0 +1,51 @@
package common
import (
"crypto/rand"
"errors"
"math/big"
)
// usernameChars 随机用户名字符集(小写字母 + 数字)
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
const usernameLen = 10
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository
type UsernameChecker interface {
ExistsByUsername(username string) (bool, error)
}
// GenerateUsername 生成不重复的随机 10 位用户名(小写字母 + 数字)
// checker 提供去重查询maxRetry 次重试后仍冲突则返回错误
func GenerateUsername(checker UsernameChecker, maxRetry int) (string, error) {
if maxRetry <= 0 {
maxRetry = 20
}
for i := 0; i < maxRetry; i++ {
username, err := randomUsername(usernameLen)
if err != nil {
return "", err
}
exists, err := checker.ExistsByUsername(username)
if err != nil {
return "", err
}
if !exists {
return username, nil
}
}
return "", errors.New("生成用户名失败,请重试")
}
// randomUsername 通过 crypto/rand 生成安全的随机字符串
func randomUsername(n int) (string, error) {
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameChars))))
if err != nil {
return "", err
}
b[i] = usernameChars[idx.Int64()]
}
return string(b), nil
}