first commit

This commit is contained in:
JiXieShi
2024-11-14 22:55:43 +08:00
commit 421cfb8cfa
98 changed files with 12617 additions and 0 deletions

31
internal/utils/captcha.go Normal file
View File

@@ -0,0 +1,31 @@
package utils
import (
"crypto/rand"
"fmt"
)
// GenerateCaptcha 生成6位数字验证码
func GenerateCaptcha() (string, error) {
// 生成6位随机数字
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return "", err
}
// 将随机字节转换为6位数字
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
return fmt.Sprintf("%06d", num%1000000), nil
}
// GenerateEmailCaptchaContent 生成验证码邮件内容
func GenerateEmailCaptchaContent(code, username, action string) string {
return fmt.Sprintf(`
<h3>验证码</h3>
<p>您好,%s</p>
<p>您正在进行%s操作验证码为</p>
<h2 style="color: #1890ff;">%s</h2>
<p>验证码有效期为5分钟请勿泄露给他人。</p>
<p>如果这不是您的操作,请忽略此邮件。</p>
`, username, action, code)
}

77
internal/utils/config.go Normal file
View File

@@ -0,0 +1,77 @@
package utils
import (
"os"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Email EmailConfig
Upload UploadConfig
Site SiteConfig
}
type ServerConfig struct {
Port string
Mode string
}
type DatabaseConfig struct {
Type string
Path string
}
type JWTConfig struct {
Secret string
Expire string
}
type EmailConfig struct {
Host string
Port int
Username string
Password string
}
type UploadConfig struct {
Path string
}
type SiteConfig struct {
Title string `mapstructure:"title"`
Description string `mapstructure:"description"`
BaseURL string `mapstructure:"base_url"`
ICP string `mapstructure:"icp"`
Copyright string `mapstructure:"copyright"`
Logo string `mapstructure:"logo"`
Favicon string `mapstructure:"favicon"`
}
func LoadConfig() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./config")
// 读取环境变量
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
config := &Config{}
if err := viper.Unmarshal(config); err != nil {
return nil, err
}
// 环境变量优先
if port := os.Getenv("SERVER_PORT"); port != "" {
config.Server.Port = port
}
return config, nil
}

View File

@@ -0,0 +1,232 @@
package utils
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type ConfigVersion struct {
Version int `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
Comment string `json:"comment"`
}
type ConfigWithVersion struct {
Config *Config `json:"config"`
Version ConfigVersion `json:"version"`
}
var (
configMutex sync.RWMutex
configFile = "config/config.json" // JSON格式更适合动态更新
)
// SaveConfig 保存配置到文件
func SaveConfig(config *Config, updatedBy, comment string) error {
configMutex.Lock()
defer configMutex.Unlock()
// 读取当前版本
currentVersion := 0
if existing, err := LoadPersistedConfig(); err == nil {
currentVersion = existing.Version.Version
}
// 创建新的配置版本
configWithVersion := ConfigWithVersion{
Config: config,
Version: ConfigVersion{
Version: currentVersion + 1,
UpdatedAt: time.Now(),
UpdatedBy: updatedBy,
Comment: comment,
},
}
// 确保配置目录存在
configDir := filepath.Dir(configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
// 备份旧配置
if err := backupConfig(); err != nil {
return err
}
// 将配置转换为JSON
data, err := json.MarshalIndent(configWithVersion, "", " ")
if err != nil {
return err
}
// 写入文件
return os.WriteFile(configFile, data, 0644)
}
// LoadPersistedConfig 加载持久化的配置
func LoadPersistedConfig() (*ConfigWithVersion, error) {
configMutex.RLock()
defer configMutex.RUnlock()
// 检查配置文件是否存在
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// 如果不存在,创建默认配置
config, err := LoadConfig()
if err != nil {
return nil, err
}
return &ConfigWithVersion{
Config: config,
Version: ConfigVersion{
Version: 1,
UpdatedAt: time.Now(),
UpdatedBy: "system",
Comment: "初始配置",
},
}, nil
}
// 读取配置文件
data, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}
// 解析JSON配置
var configWithVersion ConfigWithVersion
if err := json.Unmarshal(data, &configWithVersion); err != nil {
return nil, err
}
return &configWithVersion, nil
}
// MergeConfig 合并配置(环境变量优先)
func MergeConfig(persisted, env *Config) *Config {
if env.Server.Port != "" {
persisted.Server.Port = env.Server.Port
}
if env.Server.Mode != "" {
persisted.Server.Mode = env.Server.Mode
}
// ... 其他配置项的合并 ...
return persisted
}
// backupConfig 备份配置文件
func backupConfig() error {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return nil
}
// 确保备份目录存在
backupDir := "config/backups"
if err := os.MkdirAll(backupDir, 0755); err != nil {
return err
}
// 读取当前配置
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
// 创建备份文件名
backupFile := filepath.Join(backupDir,
fmt.Sprintf("config_%s.json", time.Now().Format("20060102150405")))
// 写入备份文件
return os.WriteFile(backupFile, data, 0644)
}

View File

@@ -0,0 +1,56 @@
package utils
import (
"licserver/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func InitDB(config *DatabaseConfig) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(config.Path), &gorm.Config{})
if err != nil {
return nil, err
}
// 自动迁移数据库结构
err = db.AutoMigrate(
&model.User{},
&model.Device{},
&model.DeviceModel{},
&model.PasswordResetToken{},
&model.Captcha{},
&model.FileUpload{},
&model.UploadChunk{},
&model.LicenseCode{},
&model.LicenseLog{},
&model.AccessToken{},
&model.TokenLog{},
)
if err != nil {
return nil, err
}
return db, nil
}

40
internal/utils/email.go Normal file
View File

@@ -0,0 +1,40 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/smtp"
)
type EmailService struct {
config *EmailConfig
auth smtp.Auth
}
func NewEmailService(config *EmailConfig) *EmailService {
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
return &EmailService{
config: config,
auth: auth,
}
}
func (s *EmailService) SendEmail(to, subject, body string) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
msg := []byte(fmt.Sprintf("To: %s\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n"+
"\r\n"+
"%s\r\n", to, subject, body))
return smtp.SendMail(addr, s.auth, s.config.Username, []string{to}, msg)
}
func GenerateResetToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

29
internal/utils/errors.go Normal file
View File

@@ -0,0 +1,29 @@
package utils
import "errors"
var (
// 通用错误
ErrInvalidInput = errors.New("无效的输入")
ErrNotFound = errors.New("资源不存在")
ErrUnauthorized = errors.New("未授权的访问")
ErrForbidden = errors.New("禁止访问")
// 授权相关错误
ErrInvalidToken = errors.New("无效的令牌")
ErrTokenExpired = errors.New("令牌已过期")
ErrInvalidCaptcha = errors.New("无效的验证码")
ErrCaptchaExpired = errors.New("验证码已过期")
ErrInvalidLicense = errors.New("无效的授权码")
ErrLicenseExpired = errors.New("授权码已过期")
ErrLicenseUsed = errors.New("授权码已被使用")
ErrDeviceNotFound = errors.New("设备不存在")
ErrDeviceRegistered = errors.New("设备已注册")
)
// ErrorResponse 统一错误响应结构
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}

51
internal/utils/jwt.go Normal file
View File

@@ -0,0 +1,51 @@
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint
Username string
Role string
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, role string, config *JWTConfig) (string, error) {
expDuration, err := time.ParseDuration(config.Expire)
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(config.Secret))
}
func ParseToken(tokenString string, config *JWTConfig) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(config.Secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}

50
internal/utils/logger.go Normal file
View File

@@ -0,0 +1,50 @@
package utils
import (
"fmt"
"os"
"path/filepath"
"time"
)
type Logger struct {
logFile *os.File
}
func NewLogger(logPath string) (*Logger, error) {
// 确保日志目录存在
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
return nil, err
}
// 打开日志文件
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &Logger{logFile: file}, nil
}
func (l *Logger) Info(format string, args ...interface{}) {
l.log("INFO", format, args...)
}
func (l *Logger) Error(format string, args ...interface{}) {
l.log("ERROR", format, args...)
}
func (l *Logger) Debug(format string, args ...interface{}) {
l.log("DEBUG", format, args...)
}
func (l *Logger) log(level, format string, args ...interface{}) {
timestamp := time.Now().Format("2006-01-02 15:04:05")
message := fmt.Sprintf(format, args...)
logLine := fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, message)
l.logFile.WriteString(logLine)
}
func (l *Logger) Close() error {
return l.logFile.Close()
}

View File

@@ -0,0 +1,68 @@
package utils
import (
"licserver/internal/model"
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestDB 创建测试数据库连接
func TestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
// 迁移测试表
err = db.AutoMigrate(
&model.User{},
&model.Device{},
&model.DeviceModel{},
&model.LicenseCode{},
&model.LicenseLog{},
&model.AccessToken{},
&model.TokenLog{},
&model.Captcha{},
&model.PasswordResetToken{},
&model.FileUpload{},
&model.UploadChunk{},
)
assert.NoError(t, err)
return db
}
// TestConfig 创建测试配置
func TestConfig() *Config {
return &Config{
Server: ServerConfig{
Port: "8080",
Mode: "test",
},
Database: DatabaseConfig{
Type: "sqlite3",
Path: ":memory:",
},
JWT: JWTConfig{
Secret: "test-secret",
Expire: "24h",
},
Email: EmailConfig{
Host: "smtp.example.com",
Port: 587,
Username: "test@example.com",
Password: "test-password",
},
Upload: UploadConfig{
Path: "./test-uploads",
},
Site: SiteConfig{
Title: "Test Site",
Description: "Test Description",
BaseURL: "http://localhost:8080",
ICP: "Test ICP",
Copyright: "Test Copyright",
},
}
}