refactor: 重构后端架构为 go-zero 框架,优化项目结构
主要变更: - 采用 go-zero 框架替代 Gin,提升开发效率 - 重构项目结构,API 文件模块化组织 - 将 model 移至 api/internal/model 目录 - 移除 common 包,改为标准 pkg 目录结构 - 实现统一的仓储模式,支持配置驱动数据库切换 - 简化测试策略,专注 API 集成测试 - 更新 CLAUDE.md 文档,提供详细的开发指导 技术栈更新: - 框架: Gin → go-zero v1.6.0+ - 代码生成: 引入 goctl 工具 - 架构模式: 四层架构 → go-zero 三层架构 (Handler→Logic→Model) - 项目布局: 遵循 Go 社区标准和 go-zero 最佳实践
This commit is contained in:
172
backend/internal/utils/file.go
Normal file
172
backend/internal/utils/file.go
Normal file
@ -0,0 +1,172 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetFileExtension 获取文件扩展名
|
||||
func GetFileExtension(filename string) string {
|
||||
return strings.ToLower(filepath.Ext(filename))
|
||||
}
|
||||
|
||||
// GetMimeType 根据文件扩展名获取MIME类型
|
||||
func GetMimeType(filename string) string {
|
||||
ext := GetFileExtension(filename)
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
|
||||
// IsImageFile 检查是否为图片文件
|
||||
func IsImageFile(filename string) bool {
|
||||
imageExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
|
||||
ext := GetFileExtension(filename)
|
||||
|
||||
for _, imageExt := range imageExtensions {
|
||||
if ext == imageExt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GenerateUniqueFilename 生成唯一的文件名
|
||||
func GenerateUniqueFilename(originalFilename string) string {
|
||||
ext := GetFileExtension(originalFilename)
|
||||
timestamp := time.Now().Unix()
|
||||
randomStr := GenerateRandomString(8)
|
||||
|
||||
return fmt.Sprintf("%d_%s%s", timestamp, randomStr, ext)
|
||||
}
|
||||
|
||||
// GenerateFilePath 生成文件路径
|
||||
func GenerateFilePath(baseDir, subDir, filename string) string {
|
||||
// 按日期组织文件夹
|
||||
now := time.Now()
|
||||
dateDir := now.Format("2006/01/02")
|
||||
|
||||
if subDir != "" {
|
||||
return filepath.Join(baseDir, subDir, dateDir, filename)
|
||||
}
|
||||
|
||||
return filepath.Join(baseDir, dateDir, filename)
|
||||
}
|
||||
|
||||
// EnsureDir 确保目录存在
|
||||
func EnsureDir(dirPath string) error {
|
||||
return os.MkdirAll(dirPath, 0755)
|
||||
}
|
||||
|
||||
// FileExists 检查文件是否存在
|
||||
func FileExists(filepath string) bool {
|
||||
_, err := os.Stat(filepath)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// GetFileSize 获取文件大小
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// CalculateFileMD5 计算文件MD5哈希
|
||||
func CalculateFileMD5(filepath string) (string, error) {
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// CalculateFileSHA256 计算文件SHA256哈希
|
||||
func CalculateFileSHA256(filepath string) (string, error) {
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// CopyFile 复制文件
|
||||
func CopyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
// 确保目标目录存在
|
||||
if err := EnsureDir(filepath.Dir(dst)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func DeleteFile(filepath string) error {
|
||||
if !FileExists(filepath) {
|
||||
return nil // 文件不存在,认为删除成功
|
||||
}
|
||||
return os.Remove(filepath)
|
||||
}
|
||||
|
||||
// FormatFileSize 格式化文件大小为人类可读格式
|
||||
func FormatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
units := []string{"KB", "MB", "GB", "TB", "PB"}
|
||||
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
|
||||
}
|
||||
|
||||
// GetImageDimensions 获取图片尺寸(需要额外的图片处理库)
|
||||
// 这里只是占位符,实际实现需要使用如 github.com/disintegration/imaging 等库
|
||||
func GetImageDimensions(filepath string) (width, height int, err error) {
|
||||
// TODO: 实现图片尺寸获取
|
||||
// 需要添加图片处理依赖
|
||||
return 0, 0, fmt.Errorf("not implemented")
|
||||
}
|
||||
77
backend/internal/utils/random.go
Normal file
77
backend/internal/utils/random.go
Normal file
@ -0,0 +1,77 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// 字符集
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
numbers = "0123456789"
|
||||
)
|
||||
|
||||
// GenerateRandomString 生成指定长度的随机字符串
|
||||
func GenerateRandomString(length int) string {
|
||||
return generateRandomFromCharset(length, alphanumeric)
|
||||
}
|
||||
|
||||
// GenerateRandomLetters 生成指定长度的随机字母字符串
|
||||
func GenerateRandomLetters(length int) string {
|
||||
return generateRandomFromCharset(length, letters)
|
||||
}
|
||||
|
||||
// GenerateRandomNumbers 生成指定长度的随机数字字符串
|
||||
func GenerateRandomNumbers(length int) string {
|
||||
return generateRandomFromCharset(length, numbers)
|
||||
}
|
||||
|
||||
// generateRandomFromCharset 从指定字符集生成随机字符串
|
||||
func generateRandomFromCharset(length int, charset string) string {
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
// 如果加密随机数生成失败,回退到时间种子
|
||||
return generateRandomFallback(length, charset)
|
||||
}
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// generateRandomFallback 回退的随机生成方法
|
||||
func generateRandomFallback(length int, charset string) string {
|
||||
// 使用时间作为种子的简单随机生成
|
||||
seed := time.Now().UnixNano()
|
||||
result := make([]byte, length)
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
seed = seed*1103515245 + 12345
|
||||
result[i] = charset[(seed/65536)%int64(len(charset))]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// GenerateSecureToken 生成安全令牌
|
||||
func GenerateSecureToken(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateID 生成唯一ID
|
||||
func GenerateID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
random := GenerateRandomString(8)
|
||||
return base64.URLEncoding.EncodeToString([]byte(string(timestamp) + random))[:16]
|
||||
}
|
||||
68
backend/internal/utils/slug.go
Normal file
68
backend/internal/utils/slug.go
Normal file
@ -0,0 +1,68 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GenerateSlug 生成URL友好的slug
|
||||
func GenerateSlug(text string) string {
|
||||
// 转换为小写
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// 移除重音字符
|
||||
text = removeAccents(text)
|
||||
|
||||
// 替换空格和特殊字符为连字符
|
||||
reg := regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
text = reg.ReplaceAllString(text, "-")
|
||||
|
||||
// 移除首尾的连字符
|
||||
text = strings.Trim(text, "-")
|
||||
|
||||
// 移除连续的连字符
|
||||
reg = regexp.MustCompile(`-+`)
|
||||
text = reg.ReplaceAllString(text, "-")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// removeAccents 移除重音字符的转换函数
|
||||
func removeAccents(text string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
if !unicode.Is(unicode.Mn, r) {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// TruncateString 截断字符串到指定长度
|
||||
func TruncateString(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length]
|
||||
}
|
||||
|
||||
// GenerateUniqueSlug 生成唯一的slug
|
||||
func GenerateUniqueSlug(base string, existingCheck func(string) bool) string {
|
||||
slug := GenerateSlug(base)
|
||||
if !existingCheck(slug) {
|
||||
return slug
|
||||
}
|
||||
|
||||
// 如果存在重复,添加数字后缀
|
||||
for i := 1; i <= 1000; i++ {
|
||||
candidateSlug := slug + "-" + strconv.Itoa(i)
|
||||
if !existingCheck(candidateSlug) {
|
||||
return candidateSlug
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还是重复,使用时间戳
|
||||
return slug + "-" + GenerateRandomString(6)
|
||||
}
|
||||
153
backend/internal/utils/time.go
Normal file
153
backend/internal/utils/time.go
Normal file
@ -0,0 +1,153 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatTime 格式化时间为字符串
|
||||
func FormatTime(t time.Time, layout string) string {
|
||||
if layout == "" {
|
||||
layout = "2006-01-02 15:04:05"
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ParseTime 解析时间字符串
|
||||
func ParseTime(timeStr, layout string) (time.Time, error) {
|
||||
if layout == "" {
|
||||
layout = "2006-01-02 15:04:05"
|
||||
}
|
||||
return time.Parse(layout, timeStr)
|
||||
}
|
||||
|
||||
// GetTimeAgo 获取相对时间描述
|
||||
func GetTimeAgo(t time.Time) string {
|
||||
now := time.Now()
|
||||
diff := now.Sub(t)
|
||||
|
||||
if diff < time.Minute {
|
||||
return "刚刚"
|
||||
}
|
||||
|
||||
if diff < time.Hour {
|
||||
minutes := int(diff.Minutes())
|
||||
return fmt.Sprintf("%d分钟前", minutes)
|
||||
}
|
||||
|
||||
if diff < 24*time.Hour {
|
||||
hours := int(diff.Hours())
|
||||
return fmt.Sprintf("%d小时前", hours)
|
||||
}
|
||||
|
||||
if diff < 30*24*time.Hour {
|
||||
days := int(diff.Hours() / 24)
|
||||
return fmt.Sprintf("%d天前", days)
|
||||
}
|
||||
|
||||
if diff < 365*24*time.Hour {
|
||||
months := int(diff.Hours() / (24 * 30))
|
||||
return fmt.Sprintf("%d个月前", months)
|
||||
}
|
||||
|
||||
years := int(diff.Hours() / (24 * 365))
|
||||
return fmt.Sprintf("%d年前", years)
|
||||
}
|
||||
|
||||
// IsToday 检查时间是否为今天
|
||||
func IsToday(t time.Time) bool {
|
||||
now := time.Now()
|
||||
return t.Year() == now.Year() && t.YearDay() == now.YearDay()
|
||||
}
|
||||
|
||||
// IsThisWeek 检查时间是否为本周
|
||||
func IsThisWeek(t time.Time) bool {
|
||||
now := time.Now()
|
||||
year, week := now.ISOWeek()
|
||||
tYear, tWeek := t.ISOWeek()
|
||||
return year == tYear && week == tWeek
|
||||
}
|
||||
|
||||
// IsThisMonth 检查时间是否为本月
|
||||
func IsThisMonth(t time.Time) bool {
|
||||
now := time.Now()
|
||||
return t.Year() == now.Year() && t.Month() == now.Month()
|
||||
}
|
||||
|
||||
// IsThisYear 检查时间是否为今年
|
||||
func IsThisYear(t time.Time) bool {
|
||||
now := time.Now()
|
||||
return t.Year() == now.Year()
|
||||
}
|
||||
|
||||
// GetWeekRange 获取本周的开始和结束时间
|
||||
func GetWeekRange(t time.Time) (start, end time.Time) {
|
||||
// 获取周一作为周开始
|
||||
weekday := int(t.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7 // 周日为7
|
||||
}
|
||||
|
||||
start = t.AddDate(0, 0, -(weekday-1))
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
|
||||
end = start.AddDate(0, 0, 6)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location())
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
// GetMonthRange 获取本月的开始和结束时间
|
||||
func GetMonthRange(t time.Time) (start, end time.Time) {
|
||||
start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
||||
end = start.AddDate(0, 1, -1)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location())
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
// GetYearRange 获取本年的开始和结束时间
|
||||
func GetYearRange(t time.Time) (start, end time.Time) {
|
||||
start = time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location())
|
||||
end = time.Date(t.Year(), 12, 31, 23, 59, 59, 999999999, t.Location())
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
// Timestamp 获取当前时间戳(秒)
|
||||
func Timestamp() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// TimestampMilli 获取当前时间戳(毫秒)
|
||||
func TimestampMilli() int64 {
|
||||
return time.Now().UnixNano() / 1e6
|
||||
}
|
||||
|
||||
// FromTimestamp 从时间戳创建时间对象
|
||||
func FromTimestamp(timestamp int64) time.Time {
|
||||
return time.Unix(timestamp, 0)
|
||||
}
|
||||
|
||||
// FromTimestampMilli 从毫秒时间戳创建时间对象
|
||||
func FromTimestampMilli(timestamp int64) time.Time {
|
||||
return time.Unix(0, timestamp*1e6)
|
||||
}
|
||||
|
||||
// FormatDuration 格式化持续时间
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.0f秒", d.Seconds())
|
||||
}
|
||||
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%.0f分钟", d.Minutes())
|
||||
}
|
||||
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%.1f小时", d.Hours())
|
||||
}
|
||||
|
||||
days := d.Hours() / 24
|
||||
return fmt.Sprintf("%.1f天", days)
|
||||
}
|
||||
128
backend/internal/utils/validation.go
Normal file
128
backend/internal/utils/validation.go
Normal file
@ -0,0 +1,128 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// IsValidEmail 验证邮箱格式
|
||||
func IsValidEmail(email string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// IsValidUsername 验证用户名格式
|
||||
func IsValidUsername(username string) bool {
|
||||
// 用户名长度3-20,只能包含字母、数字、下划线
|
||||
if len(username) < 3 || len(username) > 20 {
|
||||
return false
|
||||
}
|
||||
|
||||
usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
|
||||
return usernameRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// IsValidPassword 验证密码强度
|
||||
func IsValidPassword(password string) bool {
|
||||
// 密码长度至少6位
|
||||
if len(password) < 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否包含字母和数字
|
||||
hasLetter := false
|
||||
hasNumber := false
|
||||
|
||||
for _, char := range password {
|
||||
if unicode.IsLetter(char) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsNumber(char) {
|
||||
hasNumber = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasLetter && hasNumber
|
||||
}
|
||||
|
||||
// IsValidSlug 验证slug格式
|
||||
func IsValidSlug(slug string) bool {
|
||||
// slug只能包含小写字母、数字和连字符
|
||||
if len(slug) == 0 || len(slug) > 100 {
|
||||
return false
|
||||
}
|
||||
|
||||
slugRegex := regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
return slugRegex.MatchString(slug) && !strings.HasPrefix(slug, "-") && !strings.HasSuffix(slug, "-")
|
||||
}
|
||||
|
||||
// IsValidHexColor 验证十六进制颜色代码
|
||||
func IsValidHexColor(color string) bool {
|
||||
colorRegex := regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
|
||||
return colorRegex.MatchString(color)
|
||||
}
|
||||
|
||||
// IsValidURL 验证URL格式
|
||||
func IsValidURL(url string) bool {
|
||||
urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`)
|
||||
return urlRegex.MatchString(url)
|
||||
}
|
||||
|
||||
// SanitizeString 清理字符串,移除HTML标签和特殊字符
|
||||
func SanitizeString(input string) string {
|
||||
// 移除HTML标签
|
||||
htmlRegex := regexp.MustCompile(`<[^>]*>`)
|
||||
cleaned := htmlRegex.ReplaceAllString(input, "")
|
||||
|
||||
// 移除多余的空白字符
|
||||
whitespaceRegex := regexp.MustCompile(`\s+`)
|
||||
cleaned = whitespaceRegex.ReplaceAllString(cleaned, " ")
|
||||
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
// ValidateImageFormat 验证图片格式
|
||||
func ValidateImageFormat(filename string) bool {
|
||||
allowedExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
lowerFilename := strings.ToLower(filename)
|
||||
|
||||
for _, ext := range allowedExtensions {
|
||||
if strings.HasSuffix(lowerFilename, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateFileSize 验证文件大小(字节)
|
||||
func ValidateFileSize(size int64, maxSizeMB int64) bool {
|
||||
maxSizeBytes := maxSizeMB * 1024 * 1024
|
||||
return size <= maxSizeBytes && size > 0
|
||||
}
|
||||
|
||||
// NormalizeString 标准化字符串(去空格、转小写)
|
||||
func NormalizeString(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// ContainsOnlyASCII 检查字符串是否只包含ASCII字符
|
||||
func ContainsOnlyASCII(s string) bool {
|
||||
for _, char := range s {
|
||||
if char > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Contains 检查切片是否包含指定元素
|
||||
func Contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user