From ea4e2e305d5aae108215565ecbfd506d27f5a550 Mon Sep 17 00:00:00 2001 From: Jimmy Xue Date: Sun, 30 Nov 2025 13:02:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC,=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=9F=BA=E7=A1=80=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 167 +++++++++++ config/config.go | 466 ++++++++++++++++++++++++++++ config/example.json | 74 +++++ datetime/datetime.go | 358 ++++++++++++++++++++++ docs/README.md | 100 +++++++ docs/config.md | 500 +++++++++++++++++++++++++++++++ docs/datetime.md | 461 ++++++++++++++++++++++++++++ docs/email.md | 332 ++++++++++++++++++++ docs/http.md | 423 ++++++++++++++++++++++++++ docs/middleware.md | 438 +++++++++++++++++++++++++++ docs/migration.md | 242 +++++++++++++++ docs/sms.md | 370 +++++++++++++++++++++++ docs/storage.md | 496 ++++++++++++++++++++++++++++++ email/email.go | 306 +++++++++++++++++++ examples/config_example.go | 124 ++++++++ examples/datetime_example.go | 56 ++++ examples/datetime_utc_example.go | 74 +++++ examples/email_example.go | 121 ++++++++ examples/http_example.go | 101 +++++++ examples/middleware_example.go | 64 ++++ examples/migration_example.go | 58 ++++ examples/sms_example.go | 102 +++++++ examples/storage_example.go | 71 +++++ go.mod | 14 + go.sum | 12 + http/request.go | 207 +++++++++++++ http/response.go | 138 +++++++++ middleware/chain.go | 36 +++ middleware/cors.go | 207 +++++++++++++ middleware/timezone.go | 82 +++++ migration/migration.go | 373 +++++++++++++++++++++++ sms/sms.go | 290 ++++++++++++++++++ storage/handler.go | 212 +++++++++++++ storage/minio.go | 144 +++++++++ storage/oss.go | 155 ++++++++++ storage/storage.go | 105 +++++++ 37 files changed, 7480 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.go create mode 100644 config/example.json create mode 100644 datetime/datetime.go create mode 100644 docs/README.md create mode 100644 docs/config.md create mode 100644 docs/datetime.md create mode 100644 docs/email.md create mode 100644 docs/http.md create mode 100644 docs/middleware.md create mode 100644 docs/migration.md create mode 100644 docs/sms.md create mode 100644 docs/storage.md create mode 100644 email/email.go create mode 100644 examples/config_example.go create mode 100644 examples/datetime_example.go create mode 100644 examples/datetime_utc_example.go create mode 100644 examples/email_example.go create mode 100644 examples/http_example.go create mode 100644 examples/middleware_example.go create mode 100644 examples/migration_example.go create mode 100644 examples/sms_example.go create mode 100644 examples/storage_example.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http/request.go create mode 100644 http/response.go create mode 100644 middleware/chain.go create mode 100644 middleware/cors.go create mode 100644 middleware/timezone.go create mode 100644 migration/migration.go create mode 100644 sms/sms.go create mode 100644 storage/handler.go create mode 100644 storage/minio.go create mode 100644 storage/oss.go create mode 100644 storage/storage.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa39edf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cursor \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..83eec61 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# GoCommon - Go通用工具类库 + +这是一个Go语言开发的通用工具类库,为其他Go项目提供常用的工具方法集合。 + +## 功能模块 + +### 1. 数据库迁移工具 (migration) +提供数据库迁移功能,支持MySQL、PostgreSQL、SQLite等数据库。 + +### 2. 日期转换工具 (datetime) +提供日期时间转换功能,支持时区设定和多种格式转换。 + +### 3. HTTP Restful工具 (http) +提供HTTP请求/响应处理工具,包含标准化的响应结构、分页支持和HTTP状态码与业务状态码的分离。 + +### 4. 中间件工具 (middleware) +提供常用的HTTP中间件,包括CORS处理和时区管理。 + +### 5. 配置工具 (config) +提供从外部文件加载配置的功能,支持数据库、OSS、Redis、CORS、MinIO等配置。 + +### 6. 存储工具 (storage) +提供文件上传和查看功能,支持OSS和MinIO两种存储方式,并提供HTTP处理器。 + +### 7. 邮件工具 (email) +提供SMTP邮件发送功能,支持纯文本和HTML邮件,使用Go标准库实现。 + +### 8. 短信工具 (sms) +提供阿里云短信发送功能,支持模板短信和批量发送,使用Go标准库实现。 + +## 安装 + +```bash +go get github.com/go-common +``` + +## 使用示例 + +详细的使用说明请参考各模块的文档: +- [数据库迁移工具文档](./docs/migration.md) +- [日期转换工具文档](./docs/datetime.md) +- [HTTP Restful工具文档](./docs/http.md) +- [中间件工具文档](./docs/middleware.md) +- [配置工具文档](./docs/config.md) +- [存储工具文档](./docs/storage.md) +- [邮件工具文档](./docs/email.md) +- [短信工具文档](./docs/sms.md) + +### 快速示例 + +#### 数据库迁移 +```go +import "github.com/go-common/migration" + +migrator := migration.NewMigrator(db) +migrator.AddMigration(migration.Migration{ + Version: "20240101000001", + Description: "create_users_table", + Up: func(db *gorm.DB) error { + return db.Exec("CREATE TABLE users ...").Error + }, +}) +migrator.Up() +``` + +#### 日期转换 +```go +import "github.com/go-common/datetime" + +datetime.SetDefaultTimeZone(datetime.AsiaShanghai) +now := datetime.Now() +str := datetime.FormatDateTime(now) +``` + +#### HTTP响应 +```go +import "github.com/go-common/http" + +http.Success(w, data) +http.SuccessPage(w, list, total, page, pageSize) +http.Error(w, 1001, "业务错误") +``` + +#### 中间件 +```go +import ( + "github.com/go-common/middleware" + "github.com/go-common/http" +) + +// CORS + 时区中间件 +chain := middleware.NewChain( + middleware.CORS(), + middleware.Timezone, +) +handler := chain.ThenFunc(yourHandler) + +// 在处理器中获取时区 +timezone := http.GetTimezone(r) +``` + +#### 配置管理 +```go +import "github.com/go-common/config" + +// 从文件加载配置 +cfg, err := config.LoadFromFile("./config.json") + +// 获取各种配置 +dsn, _ := cfg.GetDatabaseDSN() +redisAddr := cfg.GetRedisAddr() +corsConfig := cfg.GetCORS() +``` + +#### 文件上传和查看 +```go +import "github.com/go-common/storage" + +// 创建存储实例 +storage, _ := storage.NewStorage(storage.StorageTypeOSS, cfg) + +// 创建上传处理器 +uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ + Storage: storage, + MaxFileSize: 10 * 1024 * 1024, + AllowedExts: []string{".jpg", ".png"}, +}) + +// 创建代理查看处理器 +proxyHandler := storage.NewProxyHandler(storage) +``` + +#### 邮件发送 +```go +import "github.com/go-common/email" + +// 从配置创建邮件发送器 +mailer, _ := email.NewEmail(cfg.GetEmail()) + +// 发送邮件 +mailer.SendSimple( + []string{"recipient@example.com"}, + "主题", + "正文", +) +``` + +#### 短信发送 +```go +import "github.com/go-common/sms" + +// 从配置创建短信发送器 +smsClient, _ := sms.NewSMS(cfg.GetSMS()) + +// 发送短信 +smsClient.SendSimple( + []string{"13800138000"}, + map[string]string{"code": "123456"}, +) +``` + +更多示例请查看 [examples](./examples/) 目录。 + +## 版本 + +v1.0.0 + diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..5d940d2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,466 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/go-common/middleware" +) + +// Config 应用配置 +type Config struct { + Database *DatabaseConfig `json:"database"` + OSS *OSSConfig `json:"oss"` + Redis *RedisConfig `json:"redis"` + CORS *CORSConfig `json:"cors"` + MinIO *MinIOConfig `json:"minio"` + Email *EmailConfig `json:"email"` + SMS *SMSConfig `json:"sms"` +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + // Type 数据库类型: mysql, postgres, sqlite + Type string `json:"type"` + + // Host 数据库主机 + Host string `json:"host"` + + // Port 数据库端口 + Port int `json:"port"` + + // User 数据库用户名 + User string `json:"user"` + + // Password 数据库密码 + Password string `json:"password"` + + // Database 数据库名称 + Database string `json:"database"` + + // Charset 字符集(MySQL使用) + Charset string `json:"charset"` + + // MaxOpenConns 最大打开连接数 + MaxOpenConns int `json:"maxOpenConns"` + + // MaxIdleConns 最大空闲连接数 + MaxIdleConns int `json:"maxIdleConns"` + + // ConnMaxLifetime 连接最大生存时间(秒) + ConnMaxLifetime int `json:"connMaxLifetime"` + + // DSN 数据库连接字符串(如果设置了,会优先使用) + DSN string `json:"dsn"` +} + +// OSSConfig OSS对象存储配置 +type OSSConfig struct { + // Provider 提供商: aliyun, tencent, aws, qiniu + Provider string `json:"provider"` + + // Endpoint 端点地址 + Endpoint string `json:"endpoint"` + + // AccessKeyID 访问密钥ID + AccessKeyID string `json:"accessKeyId"` + + // AccessKeySecret 访问密钥 + AccessKeySecret string `json:"accessKeySecret"` + + // Bucket 存储桶名称 + Bucket string `json:"bucket"` + + // Region 区域 + Region string `json:"region"` + + // UseSSL 是否使用SSL + UseSSL bool `json:"useSSL"` + + // Domain 自定义域名(CDN域名) + Domain string `json:"domain"` +} + +// RedisConfig Redis配置 +type RedisConfig struct { + // Host Redis主机 + Host string `json:"host"` + + // Port Redis端口 + Port int `json:"port"` + + // Password Redis密码 + Password string `json:"password"` + + // Database Redis数据库编号 + Database int `json:"database"` + + // MaxRetries 最大重试次数 + MaxRetries int `json:"maxRetries"` + + // PoolSize 连接池大小 + PoolSize int `json:"poolSize"` + + // MinIdleConns 最小空闲连接数 + MinIdleConns int `json:"minIdleConns"` + + // DialTimeout 连接超时时间(秒) + DialTimeout int `json:"dialTimeout"` + + // ReadTimeout 读取超时时间(秒) + ReadTimeout int `json:"readTimeout"` + + // WriteTimeout 写入超时时间(秒) + WriteTimeout int `json:"writeTimeout"` + + // Addr Redis地址(如果设置了,会优先使用,格式: host:port) + Addr string `json:"addr"` +} + +// CORSConfig CORS配置(与middleware.CORSConfig兼容) +type CORSConfig struct { + // AllowedOrigins 允许的源 + AllowedOrigins []string `json:"allowedOrigins"` + + // AllowedMethods 允许的HTTP方法 + AllowedMethods []string `json:"allowedMethods"` + + // AllowedHeaders 允许的请求头 + AllowedHeaders []string `json:"allowedHeaders"` + + // ExposedHeaders 暴露给客户端的响应头 + ExposedHeaders []string `json:"exposedHeaders"` + + // AllowCredentials 是否允许发送凭证 + AllowCredentials bool `json:"allowCredentials"` + + // MaxAge 预检请求的缓存时间(秒) + MaxAge int `json:"maxAge"` +} + +// MinIOConfig MinIO配置 +type MinIOConfig struct { + // Endpoint MinIO端点地址 + Endpoint string `json:"endpoint"` + + // AccessKeyID 访问密钥ID + AccessKeyID string `json:"accessKeyId"` + + // SecretAccessKey 密钥 + SecretAccessKey string `json:"secretAccessKey"` + + // UseSSL 是否使用SSL + UseSSL bool `json:"useSSL"` + + // Bucket 存储桶名称 + Bucket string `json:"bucket"` + + // Region 区域 + Region string `json:"region"` + + // Domain 自定义域名 + Domain string `json:"domain"` +} + +// EmailConfig 邮件配置 +type EmailConfig struct { + // Host SMTP服务器地址 + Host string `json:"host"` + + // Port SMTP服务器端口 + Port int `json:"port"` + + // Username 发件人邮箱 + Username string `json:"username"` + + // Password 邮箱密码或授权码 + Password string `json:"password"` + + // From 发件人邮箱地址(如果为空,使用Username) + From string `json:"from"` + + // FromName 发件人名称 + FromName string `json:"fromName"` + + // UseTLS 是否使用TLS + UseTLS bool `json:"useTLS"` + + // UseSSL 是否使用SSL + UseSSL bool `json:"useSSL"` + + // Timeout 连接超时时间(秒) + Timeout int `json:"timeout"` +} + +// SMSConfig 短信配置(阿里云短信) +type SMSConfig struct { + // AccessKeyID 阿里云AccessKey ID + AccessKeyID string `json:"accessKeyId"` + + // AccessKeySecret 阿里云AccessKey Secret + AccessKeySecret string `json:"accessKeySecret"` + + // Region 区域(如:cn-hangzhou) + Region string `json:"region"` + + // SignName 短信签名 + SignName string `json:"signName"` + + // TemplateCode 短信模板代码 + TemplateCode string `json:"templateCode"` + + // Endpoint 服务端点(可选,默认使用区域端点) + Endpoint string `json:"endpoint"` + + // Timeout 请求超时时间(秒) + Timeout int `json:"timeout"` +} + +// LoadFromFile 从文件加载配置 +// filePath: 配置文件路径(支持绝对路径和相对路径) +func LoadFromFile(filePath string) (*Config, error) { + // 转换为绝对路径 + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // 读取文件 + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // 解析JSON + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // 设置默认值 + config.setDefaults() + + return &config, nil +} + +// LoadFromBytes 从字节数组加载配置 +func LoadFromBytes(data []byte) (*Config, error) { + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + // 设置默认值 + config.setDefaults() + + return &config, nil +} + +// setDefaults 设置默认值 +func (c *Config) setDefaults() { + // 数据库默认值 + if c.Database != nil { + if c.Database.Charset == "" { + c.Database.Charset = "utf8mb4" + } + if c.Database.MaxOpenConns == 0 { + c.Database.MaxOpenConns = 100 + } + if c.Database.MaxIdleConns == 0 { + c.Database.MaxIdleConns = 10 + } + if c.Database.ConnMaxLifetime == 0 { + c.Database.ConnMaxLifetime = 3600 + } + } + + // Redis默认值 + if c.Redis != nil { + if c.Redis.Port == 0 { + c.Redis.Port = 6379 + } + if c.Redis.Database == 0 { + c.Redis.Database = 0 + } + if c.Redis.MaxRetries == 0 { + c.Redis.MaxRetries = 3 + } + if c.Redis.PoolSize == 0 { + c.Redis.PoolSize = 10 + } + if c.Redis.MinIdleConns == 0 { + c.Redis.MinIdleConns = 5 + } + if c.Redis.DialTimeout == 0 { + c.Redis.DialTimeout = 5 + } + if c.Redis.ReadTimeout == 0 { + c.Redis.ReadTimeout = 3 + } + if c.Redis.WriteTimeout == 0 { + c.Redis.WriteTimeout = 3 + } + } + + // CORS默认值 + if c.CORS != nil { + if len(c.CORS.AllowedOrigins) == 0 { + c.CORS.AllowedOrigins = []string{"*"} + } + if len(c.CORS.AllowedMethods) == 0 { + c.CORS.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} + } + if len(c.CORS.AllowedHeaders) == 0 { + c.CORS.AllowedHeaders = []string{"Content-Type", "Authorization", "X-Requested-With", "X-Timezone"} + } + if c.CORS.MaxAge == 0 { + c.CORS.MaxAge = 86400 + } + } + + // 邮件默认值 + if c.Email != nil { + if c.Email.Port == 0 { + c.Email.Port = 587 // 默认使用587端口(TLS) + } + if c.Email.From == "" { + c.Email.From = c.Email.Username + } + if c.Email.Timeout == 0 { + c.Email.Timeout = 30 + } + } + + // 短信默认值 + if c.SMS != nil { + if c.SMS.Region == "" { + c.SMS.Region = "cn-hangzhou" + } + if c.SMS.Timeout == 0 { + c.SMS.Timeout = 10 + } + } +} + +// GetDatabase 获取数据库配置 +func (c *Config) GetDatabase() *DatabaseConfig { + return c.Database +} + +// GetOSS 获取OSS配置 +func (c *Config) GetOSS() *OSSConfig { + return c.OSS +} + +// GetRedis 获取Redis配置 +func (c *Config) GetRedis() *RedisConfig { + return c.Redis +} + +// GetCORS 获取CORS配置,并转换为middleware.CORSConfig +func (c *Config) GetCORS() *middleware.CORSConfig { + if c.CORS == nil { + return middleware.DefaultCORSConfig() + } + + return &middleware.CORSConfig{ + AllowedOrigins: c.CORS.AllowedOrigins, + AllowedMethods: c.CORS.AllowedMethods, + AllowedHeaders: c.CORS.AllowedHeaders, + ExposedHeaders: c.CORS.ExposedHeaders, + AllowCredentials: c.CORS.AllowCredentials, + MaxAge: c.CORS.MaxAge, + } +} + +// GetMinIO 获取MinIO配置 +func (c *Config) GetMinIO() *MinIOConfig { + return c.MinIO +} + +// GetEmail 获取邮件配置 +func (c *Config) GetEmail() *EmailConfig { + return c.Email +} + +// GetSMS 获取短信配置 +func (c *Config) GetSMS() *SMSConfig { + return c.SMS +} + +// GetDatabaseDSN 获取数据库连接字符串 +func (c *Config) GetDatabaseDSN() (string, error) { + if c.Database == nil { + return "", fmt.Errorf("database config is nil") + } + + // 如果已经设置了DSN,直接返回 + if c.Database.DSN != "" { + return c.Database.DSN, nil + } + + // 根据数据库类型生成DSN + switch c.Database.Type { + case "mysql": + return c.buildMySQLDSN(), nil + case "postgres": + return c.buildPostgresDSN(), nil + case "sqlite": + return c.Database.Database, nil + default: + return "", fmt.Errorf("unsupported database type: %s", c.Database.Type) + } +} + +// buildMySQLDSN 构建MySQL连接字符串 +// 注意:数据库时间统一使用UTC时间,不设置时区 +func (c *Config) buildMySQLDSN() string { + db := c.Database + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", db.User, db.Password, db.Host, db.Port, db.Database) + + params := []string{} + if db.Charset != "" { + params = append(params, "charset="+db.Charset) + } + params = append(params, "parseTime=True") + params = append(params, "loc=UTC") // 统一使用UTC时区 + + if len(params) > 0 { + dsn += "?" + params[0] + for i := 1; i < len(params); i++ { + dsn += "&" + params[i] + } + } + + return dsn +} + +// buildPostgresDSN 构建PostgreSQL连接字符串 +// 注意:数据库时间统一使用UTC时间,不设置时区 +func (c *Config) buildPostgresDSN() string { + db := c.Database + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", + db.Host, db.Port, db.User, db.Password, db.Database) + + // 统一使用UTC时区 + dsn += " timezone=UTC" + dsn += " sslmode=disable" + return dsn +} + +// GetRedisAddr 获取Redis地址 +func (c *Config) GetRedisAddr() string { + if c.Redis == nil { + return "" + } + + // 如果已经设置了Addr,直接返回 + if c.Redis.Addr != "" { + return c.Redis.Addr + } + + // 构建地址 + return fmt.Sprintf("%s:%d", c.Redis.Host, c.Redis.Port) +} diff --git a/config/example.json b/config/example.json new file mode 100644 index 0000000..ba35d26 --- /dev/null +++ b/config/example.json @@ -0,0 +1,74 @@ +{ + "database": { + "type": "mysql", + "host": "localhost", + "port": 3306, + "user": "root", + "password": "password", + "database": "testdb", + "charset": "utf8mb4", + "maxOpenConns": 100, + "maxIdleConns": 10, + "connMaxLifetime": 3600 + }, + "oss": { + "provider": "aliyun", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "accessKeyId": "your-access-key-id", + "accessKeySecret": "your-access-key-secret", + "bucket": "your-bucket-name", + "region": "cn-hangzhou", + "useSSL": true, + "domain": "https://cdn.example.com" + }, + "redis": { + "host": "localhost", + "port": 6379, + "password": "", + "database": 0, + "maxRetries": 3, + "poolSize": 10, + "minIdleConns": 5, + "dialTimeout": 5, + "readTimeout": 3, + "writeTimeout": 3 + }, + "cors": { + "allowedOrigins": ["*"], + "allowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + "allowedHeaders": ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"], + "exposedHeaders": [], + "allowCredentials": false, + "maxAge": 86400 + }, + "minio": { + "endpoint": "localhost:9000", + "accessKeyId": "minioadmin", + "secretAccessKey": "minioadmin", + "useSSL": false, + "bucket": "test-bucket", + "region": "us-east-1", + "domain": "http://localhost:9000" + }, + "email": { + "host": "smtp.example.com", + "port": 587, + "username": "your-email@example.com", + "password": "your-email-password", + "from": "your-email@example.com", + "fromName": "Your App Name", + "useTLS": true, + "useSSL": false, + "timeout": 30 + }, + "sms": { + "accessKeyId": "your-aliyun-access-key-id", + "accessKeySecret": "your-aliyun-access-key-secret", + "region": "cn-hangzhou", + "signName": "Your Sign Name", + "templateCode": "SMS_123456789", + "endpoint": "", + "timeout": 10 + } +} + diff --git a/datetime/datetime.go b/datetime/datetime.go new file mode 100644 index 0000000..364bf81 --- /dev/null +++ b/datetime/datetime.go @@ -0,0 +1,358 @@ +package datetime + +import ( + "fmt" + "time" +) + +// TimeZone 时区常量 +const ( + UTC = "UTC" + AsiaShanghai = "Asia/Shanghai" + AmericaNewYork = "America/New_York" + EuropeLondon = "Europe/London" + AsiaTokyo = "Asia/Tokyo" +) + +// DefaultTimeZone 默认时区 +var DefaultTimeZone = UTC + +// SetDefaultTimeZone 设置默认时区 +func SetDefaultTimeZone(timezone string) error { + _, err := time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone: %w", err) + } + DefaultTimeZone = timezone + return nil +} + +// GetLocation 获取时区Location对象 +func GetLocation(timezone string) (*time.Location, error) { + if timezone == "" { + timezone = DefaultTimeZone + } + return time.LoadLocation(timezone) +} + +// Now 获取当前时间(使用指定时区) +func Now(timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + // 如果时区无效,使用UTC + loc, _ = time.LoadLocation(UTC) + } + return time.Now().In(loc) +} + +// Parse 解析时间字符串 +// layout: 时间格式,如 "2006-01-02 15:04:05" +// value: 时间字符串 +// timezone: 时区,如果为空则使用默认时区 +func Parse(layout, value string, timezone ...string) (time.Time, error) { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + + loc, err := GetLocation(tz) + if err != nil { + return time.Time{}, fmt.Errorf("invalid timezone: %w", err) + } + + t, err := time.ParseInLocation(layout, value, loc) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse time: %w", err) + } + + return t, nil +} + +// Format 格式化时间 +// t: 时间对象 +// layout: 时间格式,如 "2006-01-02 15:04:05" +// timezone: 时区,如果为空则使用时间对象本身的时区 +func Format(t time.Time, layout string, timezone ...string) string { + if len(timezone) > 0 && timezone[0] != "" { + loc, err := GetLocation(timezone[0]) + if err == nil { + t = t.In(loc) + } + } + return t.Format(layout) +} + +// ToTimezone 将时间转换到指定时区 +func ToTimezone(t time.Time, timezone string) (time.Time, error) { + loc, err := GetLocation(timezone) + if err != nil { + return time.Time{}, fmt.Errorf("invalid timezone: %w", err) + } + return t.In(loc), nil +} + +// CommonLayouts 常用时间格式 +var CommonLayouts = struct { + DateTime string + DateTimeSec string + Date string + Time string + TimeSec string + ISO8601 string + RFC3339 string + RFC3339Nano string + Unix string +}{ + DateTime: "2006-01-02 15:04", + DateTimeSec: "2006-01-02 15:04:05", + Date: "2006-01-02", + Time: "15:04", + TimeSec: "15:04:05", + ISO8601: "2006-01-02T15:04:05Z07:00", + RFC3339: time.RFC3339, + RFC3339Nano: time.RFC3339Nano, + Unix: "unix", +} + +// FormatDateTime 格式化日期时间(2006-01-02 15:04:05) +func FormatDateTime(t time.Time, timezone ...string) string { + return Format(t, CommonLayouts.DateTimeSec, timezone...) +} + +// FormatDate 格式化日期(2006-01-02) +func FormatDate(t time.Time, timezone ...string) string { + return Format(t, CommonLayouts.Date, timezone...) +} + +// FormatTime 格式化时间(15:04:05) +func FormatTime(t time.Time, timezone ...string) string { + return Format(t, CommonLayouts.TimeSec, timezone...) +} + +// ParseDateTime 解析日期时间字符串(2006-01-02 15:04:05) +func ParseDateTime(value string, timezone ...string) (time.Time, error) { + return Parse(CommonLayouts.DateTimeSec, value, timezone...) +} + +// ParseDate 解析日期字符串(2006-01-02) +func ParseDate(value string, timezone ...string) (time.Time, error) { + return Parse(CommonLayouts.Date, value, timezone...) +} + +// ToUnix 转换为Unix时间戳 +func ToUnix(t time.Time) int64 { + return t.Unix() +} + +// FromUnix 从Unix时间戳创建时间 +func FromUnix(sec int64, timezone ...string) time.Time { + t := time.Unix(sec, 0) + if len(timezone) > 0 && timezone[0] != "" { + loc, err := GetLocation(timezone[0]) + if err == nil { + t = t.In(loc) + } + } + return t +} + +// ToUnixMilli 转换为Unix毫秒时间戳 +func ToUnixMilli(t time.Time) int64 { + return t.UnixMilli() +} + +// FromUnixMilli 从Unix毫秒时间戳创建时间 +func FromUnixMilli(msec int64, timezone ...string) time.Time { + t := time.UnixMilli(msec) + if len(timezone) > 0 && timezone[0] != "" { + loc, err := GetLocation(timezone[0]) + if err == nil { + t = t.In(loc) + } + } + return t +} + +// AddDays 添加天数 +func AddDays(t time.Time, days int) time.Time { + return t.AddDate(0, 0, days) +} + +// AddMonths 添加月数 +func AddMonths(t time.Time, months int) time.Time { + return t.AddDate(0, months, 0) +} + +// AddYears 添加年数 +func AddYears(t time.Time, years int) time.Time { + return t.AddDate(years, 0, 0) +} + +// StartOfDay 获取一天的开始时间(00:00:00) +func StartOfDay(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, loc) +} + +// EndOfDay 获取一天的结束时间(23:59:59.999999999) +func EndOfDay(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, month, day := t.Date() + return time.Date(year, month, day, 23, 59, 59, 999999999, loc) +} + +// StartOfMonth 获取月份的开始时间 +func StartOfMonth(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, month, _ := t.Date() + return time.Date(year, month, 1, 0, 0, 0, 0, loc) +} + +// EndOfMonth 获取月份的结束时间 +func EndOfMonth(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, month, _ := t.Date() + // 获取下个月的第一天,然后减去1纳秒 + nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, loc) + return nextMonth.Add(-time.Nanosecond) +} + +// StartOfYear 获取年份的开始时间 +func StartOfYear(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, _, _ := t.Date() + return time.Date(year, 1, 1, 0, 0, 0, 0, loc) +} + +// EndOfYear 获取年份的结束时间 +func EndOfYear(t time.Time, timezone ...string) time.Time { + tz := DefaultTimeZone + if len(timezone) > 0 && timezone[0] != "" { + tz = timezone[0] + } + loc, err := GetLocation(tz) + if err != nil { + loc, _ = time.LoadLocation(UTC) + } + t = t.In(loc) + year, _, _ := t.Date() + return time.Date(year, 12, 31, 23, 59, 59, 999999999, loc) +} + +// DiffDays 计算两个时间之间的天数差 +func DiffDays(t1, t2 time.Time) int { + return int(t2.Sub(t1).Hours() / 24) +} + +// DiffHours 计算两个时间之间的小时差 +func DiffHours(t1, t2 time.Time) int64 { + return int64(t2.Sub(t1).Hours()) +} + +// DiffMinutes 计算两个时间之间的分钟差 +func DiffMinutes(t1, t2 time.Time) int64 { + return int64(t2.Sub(t1).Minutes()) +} + +// DiffSeconds 计算两个时间之间的秒数差 +func DiffSeconds(t1, t2 time.Time) int64 { + return int64(t2.Sub(t1).Seconds()) +} + +// ToUTC 将时间转换为UTC时间 +// t: 时间对象(可以是任意时区) +// 返回: UTC时间 +func ToUTC(t time.Time) time.Time { + return t.UTC() +} + +// ToUTCFromTimezone 从指定时区转换为UTC时间 +// t: 时间对象(会被视为指定时区的时间) +// timezone: 源时区 +// 返回: UTC时间 +// 注意:此方法假设时间对象t表示的是指定时区的本地时间,然后转换为UTC +func ToUTCFromTimezone(t time.Time, timezone string) (time.Time, error) { + loc, err := GetLocation(timezone) + if err != nil { + return time.Time{}, fmt.Errorf("invalid timezone: %w", err) + } + + // 将时间视为指定时区的本地时间,然后转换为UTC + // 首先将时间转换到指定时区,然后转换为UTC + localTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc) + return localTime.UTC(), nil +} + +// ParseToUTC 解析时间字符串并转换为UTC时间 +// layout: 时间格式,如 "2006-01-02 15:04:05" +// value: 时间字符串 +// timezone: 源时区,如果为空则使用默认时区 +// 返回: UTC时间 +func ParseToUTC(layout, value string, timezone ...string) (time.Time, error) { + t, err := Parse(layout, value, timezone...) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} + +// ParseDateTimeToUTC 解析日期时间字符串并转换为UTC时间(便捷方法) +// value: 时间字符串(格式: 2006-01-02 15:04:05) +// timezone: 源时区,如果为空则使用默认时区 +// 返回: UTC时间 +func ParseDateTimeToUTC(value string, timezone ...string) (time.Time, error) { + return ParseToUTC(CommonLayouts.DateTimeSec, value, timezone...) +} + +// ParseDateToUTC 解析日期字符串并转换为UTC时间(便捷方法) +// value: 日期字符串(格式: 2006-01-02) +// timezone: 源时区,如果为空则使用默认时区 +// 返回: UTC时间(当天的00:00:00 UTC时间) +func ParseDateToUTC(value string, timezone ...string) (time.Time, error) { + return ParseToUTC(CommonLayouts.Date, value, timezone...) +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0decd0c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,100 @@ +# GoCommon 工具类库文档 + +## 目录 + +- [数据库迁移工具](./migration.md) - 数据库版本管理和迁移 +- [日期转换工具](./datetime.md) - 日期时间处理和时区转换 +- [HTTP Restful工具](./http.md) - HTTP请求响应处理和分页 +- [中间件工具](./middleware.md) - CORS和时区处理中间件 +- [配置工具](./config.md) - 外部配置文件加载和管理 +- [存储工具](./storage.md) - 文件上传和查看(OSS、MinIO) +- [邮件工具](./email.md) - SMTP邮件发送 +- [短信工具](./sms.md) - 阿里云短信发送 + +## 快速开始 + +### 安装 + +```bash +go get github.com/go-common +``` + +### 使用示例 + +#### 数据库迁移 + +```go +import "github.com/go-common/migration" + +migrator := migration.NewMigrator(db) +migrator.AddMigration(migration.Migration{ + Version: "20240101000001", + Description: "create_users_table", + Up: func(db *gorm.DB) error { + return db.Exec("CREATE TABLE users ...").Error + }, +}) +migrator.Up() +``` + +#### 日期转换 + +```go +import "github.com/go-common/datetime" + +datetime.SetDefaultTimeZone(datetime.AsiaShanghai) +now := datetime.Now() +str := datetime.FormatDateTime(now) +``` + +#### HTTP响应 + +```go +import "github.com/go-common/http" + +http.Success(w, data) +http.SuccessPage(w, list, total, page, pageSize) +http.Error(w, 1001, "业务错误") +``` + +#### 中间件 + +```go +import ( + "github.com/go-common/middleware" + "github.com/go-common/http" +) + +// CORS + 时区中间件 +chain := middleware.NewChain( + middleware.CORS(), + middleware.Timezone, +) +handler := chain.ThenFunc(yourHandler) + +// 在处理器中获取时区 +timezone := http.GetTimezone(r) +``` + +#### 配置管理 + +```go +import "github.com/go-common/config" + +// 从文件加载配置 +cfg, err := config.LoadFromFile("./config.json") + +// 获取各种配置 +dsn, _ := cfg.GetDatabaseDSN() +redisAddr := cfg.GetRedisAddr() +corsConfig := cfg.GetCORS() +``` + +## 版本 + +v1.0.0 + +## 许可证 + +MIT + diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..1d768c6 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,500 @@ +# 配置工具文档 + +## 概述 + +配置工具提供了从外部文件加载和管理应用配置的功能,支持数据库、OSS、Redis、CORS、MinIO、邮件、短信等常用服务的配置。 + +## 功能特性 + +- 支持从外部JSON文件加载配置 +- 支持数据库配置(MySQL、PostgreSQL、SQLite) +- 支持OSS对象存储配置(阿里云、腾讯云、AWS、七牛云等) +- 支持Redis配置 +- 支持CORS配置(与middleware包集成) +- 支持MinIO配置 +- 支持邮件配置(SMTP) +- 支持短信配置(阿里云短信) +- 自动设置默认值 +- 自动生成数据库连接字符串(DSN) +- 自动生成Redis地址 + +## 配置文件格式 + +配置文件采用JSON格式,支持以下配置项: + +```json +{ + "database": { + "type": "mysql", + "host": "localhost", + "port": 3306, + "user": "root", + "password": "password", + "database": "testdb", + "charset": "utf8mb4", + "maxOpenConns": 100, + "maxIdleConns": 10, + "connMaxLifetime": 3600 + }, + "oss": { + "provider": "aliyun", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "accessKeyId": "your-access-key-id", + "accessKeySecret": "your-access-key-secret", + "bucket": "your-bucket-name", + "region": "cn-hangzhou", + "useSSL": true, + "domain": "https://cdn.example.com" + }, + "redis": { + "host": "localhost", + "port": 6379, + "password": "", + "database": 0, + "maxRetries": 3, + "poolSize": 10, + "minIdleConns": 5, + "dialTimeout": 5, + "readTimeout": 3, + "writeTimeout": 3 + }, + "cors": { + "allowedOrigins": ["*"], + "allowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + "allowedHeaders": ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"], + "exposedHeaders": [], + "allowCredentials": false, + "maxAge": 86400 + }, + "minio": { + "endpoint": "localhost:9000", + "accessKeyId": "minioadmin", + "secretAccessKey": "minioadmin", + "useSSL": false, + "bucket": "test-bucket", + "region": "us-east-1", + "domain": "http://localhost:9000" + }, + "email": { + "host": "smtp.example.com", + "port": 587, + "username": "your-email@example.com", + "password": "your-email-password", + "from": "your-email@example.com", + "fromName": "Your App Name", + "useTLS": true, + "useSSL": false, + "timeout": 30 + }, + "sms": { + "accessKeyId": "your-aliyun-access-key-id", + "accessKeySecret": "your-aliyun-access-key-secret", + "region": "cn-hangzhou", + "signName": "Your Sign Name", + "templateCode": "SMS_123456789", + "endpoint": "", + "timeout": 10 + } +} +``` + +## 使用方法 + +### 1. 加载配置文件 + +```go +import "github.com/go-common/config" + +// 从文件加载配置(支持绝对路径和相对路径) +config, err := config.LoadFromFile("/path/to/config.json") +if err != nil { + log.Fatal(err) +} + +// 或者从字节数组加载 +data := []byte(`{"database": {...}}`) +config, err := config.LoadFromBytes(data) +``` + +### 2. 获取数据库配置 + +```go +// 获取数据库配置对象 +dbConfig := config.GetDatabase() +if dbConfig != nil { + fmt.Printf("Database: %s@%s:%d/%s\n", + dbConfig.User, dbConfig.Host, dbConfig.Port, dbConfig.Database) +} + +// 获取数据库连接字符串(DSN) +dsn, err := config.GetDatabaseDSN() +if err != nil { + log.Fatal(err) +} +// MySQL: "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=UTC" +// PostgreSQL: "host=localhost port=5432 user=root password=password dbname=testdb timezone=UTC sslmode=disable" +// 注意:数据库时间统一使用UTC时间 +``` + +### 3. 获取OSS配置 + +```go +ossConfig := config.GetOSS() +if ossConfig != nil { + fmt.Printf("OSS Provider: %s\n", ossConfig.Provider) + fmt.Printf("Endpoint: %s\n", ossConfig.Endpoint) + fmt.Printf("Bucket: %s\n", ossConfig.Bucket) +} +``` + +### 4. 获取Redis配置 + +```go +redisConfig := config.GetRedis() +if redisConfig != nil { + fmt.Printf("Redis: %s:%d\n", redisConfig.Host, redisConfig.Port) +} + +// 获取Redis地址(格式: host:port) +addr := config.GetRedisAddr() +// 输出: "localhost:6379" +``` + +### 5. 获取CORS配置 + +```go +// 获取CORS配置(返回middleware.CORSConfig类型,可直接用于中间件) +corsConfig := config.GetCORS() + +// 使用CORS中间件 +import "github.com/go-common/middleware" +chain := middleware.NewChain( + middleware.CORS(corsConfig), +) +``` + +### 6. 获取MinIO配置 + +```go +minioConfig := config.GetMinIO() +if minioConfig != nil { + fmt.Printf("MinIO Endpoint: %s\n", minioConfig.Endpoint) + fmt.Printf("Bucket: %s\n", minioConfig.Bucket) +} +``` + +## 配置项说明 + +### DatabaseConfig 数据库配置 + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| Type | string | 数据库类型: mysql, postgres, sqlite | - | +| Host | string | 数据库主机 | - | +| Port | int | 数据库端口 | - | +| User | string | 数据库用户名 | - | +| Password | string | 数据库密码 | - | +| Database | string | 数据库名称 | - | +| Charset | string | 字符集(MySQL使用) | utf8mb4 | +| MaxOpenConns | int | 最大打开连接数 | 100 | +| MaxIdleConns | int | 最大空闲连接数 | 10 | +| ConnMaxLifetime | int | 连接最大生存时间(秒) | 3600 | +| DSN | string | 数据库连接字符串(如果设置,优先使用) | - | + +### OSSConfig OSS配置 + +| 字段 | 类型 | 说明 | +|------|------|------| +| Provider | string | 提供商: aliyun, tencent, aws, qiniu | +| Endpoint | string | 端点地址 | +| AccessKeyID | string | 访问密钥ID | +| AccessKeySecret | string | 访问密钥 | +| Bucket | string | 存储桶名称 | +| Region | string | 区域 | +| UseSSL | bool | 是否使用SSL | +| Domain | string | 自定义域名(CDN域名) | + +### RedisConfig Redis配置 + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| Host | string | Redis主机 | - | +| Port | int | Redis端口 | 6379 | +| Password | string | Redis密码 | - | +| Database | int | Redis数据库编号 | 0 | +| MaxRetries | int | 最大重试次数 | 3 | +| PoolSize | int | 连接池大小 | 10 | +| MinIdleConns | int | 最小空闲连接数 | 5 | +| DialTimeout | int | 连接超时时间(秒) | 5 | +| ReadTimeout | int | 读取超时时间(秒) | 3 | +| WriteTimeout | int | 写入超时时间(秒) | 3 | +| Addr | string | Redis地址(如果设置,优先使用) | - | + +### CORSConfig CORS配置 + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| AllowedOrigins | []string | 允许的源 | ["*"] | +| AllowedMethods | []string | 允许的HTTP方法 | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] | +| AllowedHeaders | []string | 允许的请求头 | ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"] | +| ExposedHeaders | []string | 暴露给客户端的响应头 | [] | +| AllowCredentials | bool | 是否允许发送凭证 | false | +| MaxAge | int | 预检请求的缓存时间(秒) | 86400 | + +### MinIOConfig MinIO配置 + +| 字段 | 类型 | 说明 | +|------|------|------| +| Endpoint | string | MinIO端点地址 | +| AccessKeyID | string | 访问密钥ID | +| SecretAccessKey | string | 密钥 | +| UseSSL | bool | 是否使用SSL | +| Bucket | string | 存储桶名称 | +| Region | string | 区域 | +| Domain | string | 自定义域名 | + +### EmailConfig 邮件配置 + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| Host | string | SMTP服务器地址 | - | +| Port | int | SMTP服务器端口 | 587 | +| Username | string | 发件人邮箱 | - | +| Password | string | 邮箱密码或授权码 | - | +| From | string | 发件人邮箱地址(如果为空,使用Username) | Username | +| FromName | string | 发件人名称 | - | +| UseTLS | bool | 是否使用TLS | false | +| UseSSL | bool | 是否使用SSL | false | +| Timeout | int | 连接超时时间(秒) | 30 | + +### SMSConfig 短信配置(阿里云短信) + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| AccessKeyID | string | 阿里云AccessKey ID | - | +| AccessKeySecret | string | 阿里云AccessKey Secret | - | +| Region | string | 区域(如:cn-hangzhou) | cn-hangzhou | +| SignName | string | 短信签名 | - | +| TemplateCode | string | 短信模板代码 | - | +| Endpoint | string | 服务端点(可选,默认使用区域端点) | - | +| Timeout | int | 请求超时时间(秒) | 10 | + +## 完整示例 + +### 示例1:加载配置并使用 + +```go +package main + +import ( + "log" + + "github.com/go-common/config" + "github.com/go-common/middleware" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config.json") + if err != nil { + log.Fatal(err) + } + + // 使用数据库配置 + dsn, err := cfg.GetDatabaseDSN() + if err != nil { + log.Fatal(err) + } + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + // 使用Redis配置 + redisAddr := cfg.GetRedisAddr() + fmt.Printf("Redis Address: %s\n", redisAddr) + + // 使用CORS配置 + corsConfig := cfg.GetCORS() + chain := middleware.NewChain( + middleware.CORS(corsConfig), + ) + + // 使用OSS配置 + ossConfig := cfg.GetOSS() + if ossConfig != nil { + fmt.Printf("OSS Provider: %s\n", ossConfig.Provider) + } + + // 使用MinIO配置 + minioConfig := cfg.GetMinIO() + if minioConfig != nil { + fmt.Printf("MinIO Endpoint: %s\n", minioConfig.Endpoint) + } +} +``` + +### 示例2:部分配置 + +配置文件可以只包含需要的配置项: + +```json +{ + "database": { + "type": "mysql", + "host": "localhost", + "port": 3306, + "user": "root", + "password": "password", + "database": "testdb" + }, + "redis": { + "host": "localhost", + "port": 6379 + } +} +``` + +未配置的部分会返回nil,需要在使用前检查: + +```go +cfg, _ := config.LoadFromFile("./config.json") + +dbConfig := cfg.GetDatabase() +if dbConfig != nil { + // 使用数据库配置 +} + +ossConfig := cfg.GetOSS() +if ossConfig == nil { + // OSS未配置 +} +``` + +### 示例3:使用DSN字段 + +如果配置文件中直接提供了DSN,会优先使用: + +```json +{ + "database": { + "type": "mysql", + "dsn": "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True" + } +} +``` + +```go +dsn, err := cfg.GetDatabaseDSN() +// 直接返回配置中的DSN,不会重新构建 +``` + +## API 参考 + +### LoadFromFile(filePath string) (*Config, error) + +从文件加载配置。 + +**参数:** +- `filePath`: 配置文件路径(支持绝对路径和相对路径) + +**返回:** 配置对象和错误信息 + +### LoadFromBytes(data []byte) (*Config, error) + +从字节数组加载配置。 + +**参数:** +- `data`: JSON格式的配置数据 + +**返回:** 配置对象和错误信息 + +### (c *Config) GetDatabase() *DatabaseConfig + +获取数据库配置。 + +**返回:** 数据库配置对象(可能为nil) + +### (c *Config) GetDatabaseDSN() (string, error) + +获取数据库连接字符串。 + +**返回:** DSN字符串和错误信息 + +### (c *Config) GetOSS() *OSSConfig + +获取OSS配置。 + +**返回:** OSS配置对象(可能为nil) + +### (c *Config) GetRedis() *RedisConfig + +获取Redis配置。 + +**返回:** Redis配置对象(可能为nil) + +### (c *Config) GetRedisAddr() string + +获取Redis地址(格式: host:port)。 + +**返回:** Redis地址字符串 + +### (c *Config) GetCORS() *middleware.CORSConfig + +获取CORS配置,并转换为middleware.CORSConfig类型。 + +**返回:** CORS配置对象(如果配置为nil,返回默认配置) + +### (c *Config) GetMinIO() *MinIOConfig + +获取MinIO配置。 + +**返回:** MinIO配置对象(可能为nil) + +### (c *Config) GetEmail() *EmailConfig + +获取邮件配置。 + +**返回:** 邮件配置对象(可能为nil) + +### (c *Config) GetSMS() *SMSConfig + +获取短信配置。 + +**返回:** 短信配置对象(可能为nil) + +## 注意事项 + +1. **配置文件路径**: + - 支持绝对路径和相对路径 + - 相对路径基于当前工作目录 + +2. **默认值**: + - 配置加载时会自动设置默认值 + - 如果配置项为nil,对应的Get方法会返回nil + +3. **DSN优先级**: + - 如果配置中设置了DSN字段,会优先使用 + - 否则会根据配置项自动构建DSN + +4. **配置验证**: + - 当前版本不进行配置验证,请确保配置正确 + - 建议在生产环境中添加配置验证逻辑 + +5. **安全性**: + - 配置文件可能包含敏感信息(密码、密钥等) + - 建议将配置文件放在安全的位置,不要提交到版本控制系统 + - 可以使用环境变量或配置管理服务 + +6. **数据库时区**: + - 数据库时间统一使用UTC时间存储 + - DSN中会自动设置UTC时区(MySQL: loc=UTC, PostgreSQL: timezone=UTC) + - 时区转换应在应用层处理,使用datetime工具包进行时区转换 + +## 配置文件示例 + +完整配置文件示例请参考 `config/example.json` + diff --git a/docs/datetime.md b/docs/datetime.md new file mode 100644 index 0000000..c55be02 --- /dev/null +++ b/docs/datetime.md @@ -0,0 +1,461 @@ +# 日期转换工具文档 + +## 概述 + +日期转换工具提供了丰富的日期时间处理功能,支持时区设定、格式转换、时间计算等常用操作。 + +## 功能特性 + +- 支持时区设定和转换 +- 支持多种时间格式的解析和格式化 +- 提供常用时间格式常量 +- 支持Unix时间戳转换 +- 提供时间计算功能(添加天数、月数、年数等) +- 提供时间范围获取功能(开始/结束时间) +- 支持将任意时区时间转换为UTC时间(用于数据库存储) + +## 使用方法 + +### 1. 设置默认时区 + +```go +import "github.com/go-common/datetime" + +// 设置默认时区为上海时区 +err := datetime.SetDefaultTimeZone(datetime.AsiaShanghai) +if err != nil { + log.Fatal(err) +} +``` + +### 2. 获取当前时间 + +```go +// 使用默认时区 +now := datetime.Now() + +// 使用指定时区 +now := datetime.Now(datetime.AsiaShanghai) +now := datetime.Now("America/New_York") +``` + +### 3. 解析时间字符串 + +```go +// 使用默认时区解析 +t, err := datetime.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00") +if err != nil { + log.Fatal(err) +} + +// 使用指定时区解析 +t, err := datetime.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00", datetime.AsiaShanghai) + +// 使用常用格式解析 +t, err := datetime.ParseDateTime("2024-01-01 12:00:00") +t, err := datetime.ParseDate("2024-01-01") +``` + +### 4. 格式化时间 + +```go +t := time.Now() + +// 使用默认时区格式化 +str := datetime.Format(t, "2006-01-02 15:04:05") + +// 使用指定时区格式化 +str := datetime.Format(t, "2006-01-02 15:04:05", datetime.AsiaShanghai) + +// 使用常用格式 +str := datetime.FormatDateTime(t) // "2006-01-02 15:04:05" +str := datetime.FormatDate(t) // "2006-01-02" +str := datetime.FormatTime(t) // "15:04:05" +``` + +### 5. 时区转换 + +```go +t := time.Now() + +// 转换到指定时区 +t2, err := datetime.ToTimezone(t, datetime.AsiaShanghai) +if err != nil { + log.Fatal(err) +} +``` + +### 6. Unix时间戳转换 + +```go +t := time.Now() + +// 转换为Unix时间戳(秒) +unix := datetime.ToUnix(t) + +// 从Unix时间戳创建时间 +t2 := datetime.FromUnix(unix) + +// 转换为Unix毫秒时间戳 +unixMilli := datetime.ToUnixMilli(t) + +// 从Unix毫秒时间戳创建时间 +t3 := datetime.FromUnixMilli(unixMilli) +``` + +### 7. 时间计算 + +```go +t := time.Now() + +// 添加天数 +t1 := datetime.AddDays(t, 7) + +// 添加月数 +t2 := datetime.AddMonths(t, 1) + +// 添加年数 +t3 := datetime.AddYears(t, 1) +``` + +### 8. 时间范围获取 + +```go +t := time.Now() + +// 获取一天的开始时间(00:00:00) +start := datetime.StartOfDay(t) + +// 获取一天的结束时间(23:59:59.999999999) +end := datetime.EndOfDay(t) + +// 获取月份的开始时间 +monthStart := datetime.StartOfMonth(t) + +// 获取月份的结束时间 +monthEnd := datetime.EndOfMonth(t) + +// 获取年份的开始时间 +yearStart := datetime.StartOfYear(t) + +// 获取年份的结束时间 +yearEnd := datetime.EndOfYear(t) +``` + +### 9. 时间差计算 + +```go +t1 := time.Now() +t2 := time.Now().Add(24 * time.Hour) + +// 计算天数差 +days := datetime.DiffDays(t1, t2) + +// 计算小时差 +hours := datetime.DiffHours(t1, t2) + +// 计算分钟差 +minutes := datetime.DiffMinutes(t1, t2) + +// 计算秒数差 +seconds := datetime.DiffSeconds(t1, t2) +``` + +### 10. 转换为UTC时间(用于数据库存储) + +```go +// 将任意时区的时间转换为UTC +t := time.Now() // 当前时区的时间 +utcTime := datetime.ToUTC(t) + +// 从指定时区转换为UTC +t, _ := datetime.ParseDateTime("2024-01-01 12:00:00", datetime.AsiaShanghai) +utcTime, err := datetime.ToUTCFromTimezone(t, datetime.AsiaShanghai) + +// 解析时间字符串并直接转换为UTC +utcTime, err := datetime.ParseDateTimeToUTC("2024-01-01 12:00:00", datetime.AsiaShanghai) + +// 解析日期并转换为UTC(当天的00:00:00 UTC) +utcTime, err := datetime.ParseDateToUTC("2024-01-01", datetime.AsiaShanghai) +``` + +## API 参考 + +### 时区常量 + +```go +const ( + UTC = "UTC" + AsiaShanghai = "Asia/Shanghai" + AmericaNewYork = "America/New_York" + EuropeLondon = "Europe/London" + AsiaTokyo = "Asia/Tokyo" +) +``` + +### 常用时间格式 + +```go +CommonLayouts.DateTime = "2006-01-02 15:04" +CommonLayouts.DateTimeSec = "2006-01-02 15:04:05" +CommonLayouts.Date = "2006-01-02" +CommonLayouts.Time = "15:04" +CommonLayouts.TimeSec = "15:04:05" +CommonLayouts.ISO8601 = "2006-01-02T15:04:05Z07:00" +CommonLayouts.RFC3339 = time.RFC3339 +CommonLayouts.RFC3339Nano = time.RFC3339Nano +``` + +### 主要函数 + +#### SetDefaultTimeZone(timezone string) error + +设置默认时区。 + +**参数:** +- `timezone`: 时区字符串,如 "Asia/Shanghai" + +**返回:** 错误信息 + +#### Now(timezone ...string) time.Time + +获取当前时间。 + +**参数:** +- `timezone`: 可选,时区字符串,不指定则使用默认时区 + +**返回:** 时间对象 + +#### Parse(layout, value string, timezone ...string) (time.Time, error) + +解析时间字符串。 + +**参数:** +- `layout`: 时间格式,如 "2006-01-02 15:04:05" +- `value`: 时间字符串 +- `timezone`: 可选,时区字符串 + +**返回:** 时间对象和错误信息 + +#### Format(t time.Time, layout string, timezone ...string) string + +格式化时间。 + +**参数:** +- `t`: 时间对象 +- `layout`: 时间格式 +- `timezone`: 可选,时区字符串 + +**返回:** 格式化后的时间字符串 + +#### ToTimezone(t time.Time, timezone string) (time.Time, error) + +转换时区。 + +**参数:** +- `t`: 时间对象 +- `timezone`: 目标时区 + +**返回:** 转换后的时间对象和错误信息 + +#### ToUnix(t time.Time) int64 + +转换为Unix时间戳(秒)。 + +#### FromUnix(sec int64, timezone ...string) time.Time + +从Unix时间戳创建时间。 + +#### ToUnixMilli(t time.Time) int64 + +转换为Unix毫秒时间戳。 + +#### FromUnixMilli(msec int64, timezone ...string) time.Time + +从Unix毫秒时间戳创建时间。 + +#### AddDays(t time.Time, days int) time.Time + +添加天数。 + +#### AddMonths(t time.Time, months int) time.Time + +添加月数。 + +#### AddYears(t time.Time, years int) time.Time + +添加年数。 + +#### StartOfDay(t time.Time, timezone ...string) time.Time + +获取一天的开始时间。 + +#### EndOfDay(t time.Time, timezone ...string) time.Time + +获取一天的结束时间。 + +#### StartOfMonth(t time.Time, timezone ...string) time.Time + +获取月份的开始时间。 + +#### EndOfMonth(t time.Time, timezone ...string) time.Time + +获取月份的结束时间。 + +#### StartOfYear(t time.Time, timezone ...string) time.Time + +获取年份的开始时间。 + +#### EndOfYear(t time.Time, timezone ...string) time.Time + +获取年份的结束时间。 + +#### DiffDays(t1, t2 time.Time) int + +计算两个时间之间的天数差。 + +#### DiffHours(t1, t2 time.Time) int64 + +计算两个时间之间的小时差。 + +#### DiffMinutes(t1, t2 time.Time) int64 + +计算两个时间之间的分钟差。 + +#### DiffSeconds(t1, t2 time.Time) int64 + +计算两个时间之间的秒数差。 + +### UTC转换函数 + +#### ToUTC(t time.Time) time.Time + +将时间转换为UTC时间。 + +**参数:** +- `t`: 时间对象(可以是任意时区) + +**返回:** UTC时间 + +#### ToUTCFromTimezone(t time.Time, timezone string) (time.Time, error) + +从指定时区转换为UTC时间。 + +**参数:** +- `t`: 时间对象(会被视为指定时区的时间) +- `timezone`: 源时区 + +**返回:** UTC时间和错误信息 + +#### ParseToUTC(layout, value string, timezone ...string) (time.Time, error) + +解析时间字符串并转换为UTC时间。 + +**参数:** +- `layout`: 时间格式,如 "2006-01-02 15:04:05" +- `value`: 时间字符串 +- `timezone`: 源时区,如果为空则使用默认时区 + +**返回:** UTC时间和错误信息 + +#### ParseDateTimeToUTC(value string, timezone ...string) (time.Time, error) + +解析日期时间字符串并转换为UTC时间(便捷方法)。 + +**参数:** +- `value`: 时间字符串(格式: 2006-01-02 15:04:05) +- `timezone`: 源时区,如果为空则使用默认时区 + +**返回:** UTC时间和错误信息 + +#### ParseDateToUTC(value string, timezone ...string) (time.Time, error) + +解析日期字符串并转换为UTC时间(便捷方法)。 + +**参数:** +- `value`: 日期字符串(格式: 2006-01-02) +- `timezone`: 源时区,如果为空则使用默认时区 + +**返回:** UTC时间(当天的00:00:00 UTC时间)和错误信息 + +## 注意事项 + +1. **时区字符串**:必须符合IANA时区数据库格式 +2. **默认时区**:默认时区为UTC,建议在应用启动时设置合适的默认时区 +3. **时间格式**:时间格式字符串必须使用Go的特定时间(2006-01-02 15:04:05) +4. **时间范围函数**:所有时间范围函数(StartOfDay、EndOfDay等)都会考虑时区 +5. **数据库存储**: + - 数据库时间统一使用UTC时间存储 + - 使用`ToUTC`、`ParseToUTC`等方法将时间转换为UTC后存储到数据库 + - 从数据库读取UTC时间后,使用`ToTimezone`转换为用户时区显示 + +## 完整示例 + +### 示例1:基本使用 + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/go-common/datetime" +) + +func main() { + // 设置默认时区 + datetime.SetDefaultTimeZone(datetime.AsiaShanghai) + + // 获取当前时间 + now := datetime.Now() + fmt.Printf("Current time: %s\n", datetime.FormatDateTime(now)) + + // 时区转换 + t, _ := datetime.ParseDateTime("2024-01-01 12:00:00") + t2, _ := datetime.ToTimezone(t, datetime.AmericaNewYork) + fmt.Printf("Time in New York: %s\n", datetime.FormatDateTime(t2)) +} +``` + +### 示例2:UTC转换(数据库存储场景) + +```go +package main + +import ( + "fmt" + "log" + + "github.com/go-common/datetime" +) + +func main() { + // 从请求中获取时间(假设是上海时区) + requestTimeStr := "2024-01-01 12:00:00" + requestTimezone := datetime.AsiaShanghai + + // 转换为UTC时间(用于数据库存储) + dbTime, err := datetime.ParseDateTimeToUTC(requestTimeStr, requestTimezone) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Request time (Shanghai): %s\n", requestTimeStr) + fmt.Printf("Database time (UTC): %s\n", datetime.FormatDateTime(dbTime, datetime.UTC)) + + // 从数据库读取UTC时间,转换为用户时区显示 + userTimezone := datetime.AsiaShanghai + displayTime, err := datetime.ToTimezone(dbTime, userTimezone) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Display time (Shanghai): %s\n", datetime.FormatDateTime(displayTime, userTimezone)) +} +``` + +完整示例请参考: +- `examples/datetime_example.go` - 基本使用示例 +- `examples/datetime_utc_example.go` - UTC转换示例 + diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..3f89600 --- /dev/null +++ b/docs/email.md @@ -0,0 +1,332 @@ +# 邮件工具文档 + +## 概述 + +邮件工具提供了SMTP邮件发送功能,使用Go标准库实现,无需第三方依赖。 + +## 功能特性 + +- 支持SMTP邮件发送 +- 支持TLS/SSL加密 +- 支持发送原始邮件内容(完全由外部控制) +- 支持便捷方法发送简单邮件 +- 使用配置工具统一管理配置 + +## 使用方法 + +### 1. 创建邮件发送器 + +```go +import ( + "github.com/go-common/config" + "github.com/go-common/email" +) + +// 从配置加载 +cfg, err := config.LoadFromFile("./config.json") +if err != nil { + log.Fatal(err) +} + +emailConfig := cfg.GetEmail() +if emailConfig == nil { + log.Fatal("email config is nil") +} + +// 创建邮件发送器 +mailer, err := email.NewEmail(emailConfig) +if err != nil { + log.Fatal(err) +} +``` + +### 2. 发送原始邮件内容(推荐,最灵活) + +```go +// 外部构建完整的邮件内容(MIME格式) +emailBody := []byte(`From: sender@example.com +To: recipient@example.com +Subject: 邮件主题 +Content-Type: text/html; charset=UTF-8 + + + +

邮件内容

+

这是由外部构建的完整邮件内容

+ + +`) + +// 发送邮件(工具只负责SMTP发送,不构建内容) +err := mailer.SendRaw( + []string{"recipient@example.com"}, // 收件人列表 + emailBody, // 完整的邮件内容 +) +if err != nil { + log.Fatal(err) +} +``` + +### 3. 发送简单邮件(便捷方法) + +```go +// 发送纯文本邮件(内部会构建邮件内容) +err := mailer.SendSimple( + []string{"recipient@example.com"}, + "邮件主题", + "邮件正文内容", +) +if err != nil { + log.Fatal(err) +} +``` + +### 4. 发送HTML邮件(便捷方法) + +```go +// 发送HTML邮件(内部会构建邮件内容) +htmlBody := ` + + +

欢迎

+

这是一封HTML邮件

+ + +` + +err := mailer.SendHTML( + []string{"recipient@example.com"}, + "邮件主题", + htmlBody, +) +if err != nil { + log.Fatal(err) +} +``` + +### 5. 使用Message结构发送(便捷方法) + +```go +import "github.com/go-common/email" + +msg := &email.Message{ + To: []string{"to@example.com"}, + Cc: []string{"cc@example.com"}, + Bcc: []string{"bcc@example.com"}, + Subject: "邮件主题", + Body: "纯文本正文", + HTMLBody: "

HTML正文

", +} + +err := mailer.Send(msg) +if err != nil { + log.Fatal(err) +} +``` + +## API 参考 + +### NewEmail(cfg *config.EmailConfig) (*Email, error) + +创建邮件发送器。 + +**参数:** +- `cfg`: 邮件配置对象 + +**返回:** 邮件发送器实例和错误信息 + +### (e *Email) SendRaw(recipients []string, body []byte) error + +发送原始邮件内容(推荐使用,最灵活)。 + +**参数:** +- `recipients`: 收件人列表(To、Cc、Bcc的合并列表) +- `body`: 完整的邮件内容(MIME格式),由外部构建 + +**返回:** 错误信息 + +**说明:** 此方法允许外部完全控制邮件内容,工具只负责SMTP发送。 + +### (e *Email) Send(msg *Message) error + +发送邮件(使用Message结构,内部会构建邮件内容)。 + +**参数:** +- `msg`: 邮件消息对象 + +**返回:** 错误信息 + +**说明:** 如果需要完全控制邮件内容,请使用SendRaw方法。 + +### (e *Email) SendSimple(to []string, subject, body string) error + +发送简单邮件(便捷方法)。 + +**参数:** +- `to`: 收件人列表 +- `subject`: 主题 +- `body`: 正文 + +### (e *Email) SendHTML(to []string, subject, htmlBody string) error + +发送HTML邮件(便捷方法)。 + +**参数:** +- `to`: 收件人列表 +- `subject`: 主题 +- `htmlBody`: HTML正文 + +### Message 结构体 + +```go +type Message struct { + To []string // 收件人列表 + Cc []string // 抄送列表(可选) + Bcc []string // 密送列表(可选) + Subject string // 主题 + Body string // 正文(纯文本) + HTMLBody string // HTML正文(可选) + Attachments []Attachment // 附件列表(可选) +} +``` + +### Attachment 结构体 + +```go +type Attachment struct { + Filename string // 文件名 + Content []byte // 文件内容 + ContentType string // 文件类型 +} +``` + +## 配置说明 + +邮件配置通过 `config.EmailConfig` 提供: + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| Host | string | SMTP服务器地址 | - | +| Port | int | SMTP服务器端口 | 587 | +| Username | string | 发件人邮箱 | - | +| Password | string | 邮箱密码或授权码 | - | +| From | string | 发件人邮箱地址 | Username | +| FromName | string | 发件人名称 | - | +| UseTLS | bool | 是否使用TLS | false | +| UseSSL | bool | 是否使用SSL | false | +| Timeout | int | 连接超时时间(秒) | 30 | + +## 常见SMTP服务器配置 + +### Gmail +```json +{ + "host": "smtp.gmail.com", + "port": 587, + "useTLS": true, + "useSSL": false +} +``` + +### QQ邮箱 +```json +{ + "host": "smtp.qq.com", + "port": 587, + "useTLS": true, + "useSSL": false +} +``` + +### 163邮箱 +```json +{ + "host": "smtp.163.com", + "port": 25, + "useTLS": false, + "useSSL": false +} +``` + +### 企业邮箱(SSL) +```json +{ + "host": "smtp.exmail.qq.com", + "port": 465, + "useTLS": false, + "useSSL": true +} +``` + +## 注意事项 + +1. **推荐使用SendRaw方法**: + - `SendRaw`方法允许外部完全控制邮件内容 + - 可以构建任意格式的MIME邮件(包括复杂附件、多部分内容等) + - 工具只负责SMTP发送,不构建内容 + +2. **邮件内容构建**: + - 使用`SendRaw`时,需要外部构建完整的MIME格式邮件内容 + - 可以参考RFC 5322标准构建邮件内容 + - 便捷方法(`Send`、`SendSimple`、`SendHTML`)内部会构建简单格式的邮件内容 + +3. **密码/授权码**: + - 很多邮箱服务商需要使用授权码而不是登录密码 + - Gmail、QQ邮箱等需要开启SMTP服务并获取授权码 + +4. **端口选择**: + - 587端口:通常使用TLS(STARTTLS) + - 465端口:通常使用SSL + - 25端口:通常不使用加密(不推荐) + +5. **TLS vs SSL**: + - UseTLS=true:使用STARTTLS(推荐,端口587) + - UseSSL=true:使用SSL(端口465) + +6. **错误处理**: + - 所有操作都应该进行错误处理 + - 建议记录详细的错误日志 + +## 完整示例 + +```go +package main + +import ( + "log" + + "github.com/go-common/config" + "github.com/go-common/email" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config.json") + if err != nil { + log.Fatal(err) + } + + // 创建邮件发送器 + mailer, err := email.NewEmail(cfg.GetEmail()) + if err != nil { + log.Fatal(err) + } + + // 发送邮件 + err = mailer.SendSimple( + []string{"recipient@example.com"}, + "测试邮件", + "这是一封测试邮件", + ) + if err != nil { + log.Fatal(err) + } + + log.Println("邮件发送成功") +} +``` + +## 示例 + +完整示例请参考 `examples/email_example.go` + diff --git a/docs/http.md b/docs/http.md new file mode 100644 index 0000000..4531c1e --- /dev/null +++ b/docs/http.md @@ -0,0 +1,423 @@ +# HTTP Restful工具文档 + +## 概述 + +HTTP Restful工具提供了标准化的HTTP请求和响应处理功能,包含统一的响应结构、分页支持和HTTP状态码与业务状态码的分离。 + +## 功能特性 + +- 标准化的响应结构:`{code, message, timestamp, data}` +- 分离HTTP状态码和业务状态码 +- 支持分页响应 +- 提供便捷的请求参数解析方法 +- 支持JSON请求体解析 +- 提供常用的HTTP错误响应方法 + +## 响应结构 + +### 标准响应结构 + +```json +{ + "code": 0, + "message": "success", + "timestamp": 1704067200, + "data": {} +} +``` + +### 分页响应结构 + +```json +{ + "code": 0, + "message": "success", + "timestamp": 1704067200, + "data": { + "list": [], + "total": 100, + "page": 1, + "pageSize": 10 + } +} +``` + +## 使用方法 + +### 1. 成功响应 + +```go +import ( + "net/http" + "github.com/go-common/http" +) + +// 简单成功响应(data为nil) +http.Success(w, nil) + +// 带数据的成功响应 +data := map[string]interface{}{ + "id": 1, + "name": "test", +} +http.Success(w, data) + +// 带消息的成功响应 +http.SuccessWithMessage(w, "操作成功", data) +``` + +### 2. 错误响应 + +```go +// 业务错误(HTTP 200,业务code非0) +http.Error(w, 1001, "用户不存在") + +// 系统错误(HTTP 500) +http.SystemError(w, "服务器内部错误") + +// 请求错误(HTTP 400) +http.BadRequest(w, "请求参数错误") + +// 未授权(HTTP 401) +http.Unauthorized(w, "未登录") + +// 禁止访问(HTTP 403) +http.Forbidden(w, "无权限访问") + +// 未找到(HTTP 404) +http.NotFound(w, "资源不存在") +``` + +### 3. 分页响应 + +```go +// 获取分页参数 +page, pageSize := http.GetPaginationParams(r) + +// 查询数据(示例) +list, total := getDataList(page, pageSize) + +// 返回分页响应 +http.SuccessPage(w, list, total, page, pageSize) + +// 带消息的分页响应 +http.SuccessPageWithMessage(w, "查询成功", list, total, page, pageSize) +``` + +### 4. 解析请求 + +#### 解析JSON请求体 + +```go +type CreateUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` +} + +var req CreateUserRequest +err := http.ParseJSON(r, &req) +if err != nil { + http.BadRequest(w, "请求参数解析失败") + return +} +``` + +#### 获取查询参数 + +```go +// 获取字符串参数 +name := http.GetQuery(r, "name", "") +email := http.GetQuery(r, "email", "default@example.com") + +// 获取整数参数 +id := http.GetQueryInt(r, "id", 0) +age := http.GetQueryInt(r, "age", 18) + +// 获取int64参数 +userId := http.GetQueryInt64(r, "userId", 0) + +// 获取布尔参数 +isActive := http.GetQueryBool(r, "isActive", false) + +// 获取浮点数参数 +price := http.GetQueryFloat64(r, "price", 0.0) +``` + +#### 获取表单参数 + +```go +// 获取表单字符串 +name := http.GetFormValue(r, "name", "") + +// 获取表单整数 +age := http.GetFormInt(r, "age", 0) + +// 获取表单int64 +userId := http.GetFormInt64(r, "userId", 0) + +// 获取表单布尔值 +isActive := http.GetFormBool(r, "isActive", false) +``` + +#### 获取请求头 + +```go +token := http.GetHeader(r, "Authorization", "") +contentType := http.GetHeader(r, "Content-Type", "application/json") +``` + +#### 获取分页参数 + +```go +// 自动解析page和pageSize参数 +// 默认: page=1, pageSize=10 +// 限制: pageSize最大1000 +page, pageSize := http.GetPaginationParams(r) + +// 计算数据库查询偏移量 +offset := http.GetOffset(page, pageSize) +``` + +### 5. 自定义响应 + +```go +// 使用WriteJSON自定义响应 +http.WriteJSON(w, http.StatusOK, 0, "success", data) + +// 参数说明: +// - httpCode: HTTP状态码(200, 400, 500等) +// - code: 业务状态码(0表示成功,非0表示业务错误) +// - message: 响应消息 +// - data: 响应数据 +``` + +## 完整示例 + +```go +package main + +import ( + "net/http" + "github.com/go-common/http" +) + +// 用户列表接口 +func GetUserList(w http.ResponseWriter, r *http.Request) { + // 获取分页参数 + page, pageSize := http.GetPaginationParams(r) + + // 获取查询参数 + keyword := http.GetQuery(r, "keyword", "") + + // 查询数据 + users, total := queryUsers(keyword, page, pageSize) + + // 返回分页响应 + http.SuccessPage(w, users, total, page, pageSize) +} + +// 创建用户接口 +func CreateUser(w http.ResponseWriter, r *http.Request) { + // 解析请求体 + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + + if err := http.ParseJSON(r, &req); err != nil { + http.BadRequest(w, "请求参数解析失败") + return + } + + // 参数验证 + if req.Name == "" { + http.Error(w, 1001, "用户名不能为空") + return + } + + // 创建用户 + user, err := createUser(req.Name, req.Email) + if err != nil { + http.SystemError(w, "创建用户失败") + return + } + + // 返回成功响应 + http.SuccessWithMessage(w, "创建成功", user) +} + +// 获取用户详情接口 +func GetUser(w http.ResponseWriter, r *http.Request) { + // 获取路径参数(需要配合路由框架使用) + id := http.GetQueryInt64(r, "id", 0) + + if id == 0 { + http.BadRequest(w, "用户ID不能为空") + return + } + + // 查询用户 + user, err := getUserByID(id) + if err != nil { + http.SystemError(w, "查询用户失败") + return + } + + if user == nil { + http.Error(w, 1002, "用户不存在") + return + } + + http.Success(w, user) +} +``` + +## API 参考 + +### 响应方法 + +#### Success(w http.ResponseWriter, data interface{}) + +成功响应,HTTP 200,业务code 0。 + +#### SuccessWithMessage(w http.ResponseWriter, message string, data interface{}) + +带消息的成功响应。 + +#### Error(w http.ResponseWriter, code int, message string) + +业务错误响应,HTTP 200,业务code非0。 + +#### SystemError(w http.ResponseWriter, message string) + +系统错误响应,HTTP 500,业务code 500。 + +#### BadRequest(w http.ResponseWriter, message string) + +请求错误响应,HTTP 400。 + +#### Unauthorized(w http.ResponseWriter, message string) + +未授权响应,HTTP 401。 + +#### Forbidden(w http.ResponseWriter, message string) + +禁止访问响应,HTTP 403。 + +#### NotFound(w http.ResponseWriter, message string) + +未找到响应,HTTP 404。 + +#### WriteJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) + +写入JSON响应(自定义)。 + +**参数:** +- `httpCode`: HTTP状态码 +- `code`: 业务状态码 +- `message`: 响应消息 +- `data`: 响应数据 + +#### SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int) + +分页成功响应。 + +#### SuccessPageWithMessage(w http.ResponseWriter, message string, list interface{}, total int64, page, pageSize int) + +带消息的分页成功响应。 + +### 请求方法 + +#### ParseJSON(r *http.Request, v interface{}) error + +解析JSON请求体。 + +#### GetQuery(r *http.Request, key, defaultValue string) string + +获取查询参数(字符串)。 + +#### GetQueryInt(r *http.Request, key string, defaultValue int) int + +获取查询参数(整数)。 + +#### GetQueryInt64(r *http.Request, key string, defaultValue int64) int64 + +获取查询参数(int64)。 + +#### GetQueryBool(r *http.Request, key string, defaultValue bool) bool + +获取查询参数(布尔值)。 + +#### GetQueryFloat64(r *http.Request, key string, defaultValue float64) float64 + +获取查询参数(浮点数)。 + +#### GetFormValue(r *http.Request, key, defaultValue string) string + +获取表单值(字符串)。 + +#### GetFormInt(r *http.Request, key string, defaultValue int) int + +获取表单值(整数)。 + +#### GetFormInt64(r *http.Request, key string, defaultValue int64) int64 + +获取表单值(int64)。 + +#### GetFormBool(r *http.Request, key string, defaultValue bool) bool + +获取表单值(布尔值)。 + +#### GetHeader(r *http.Request, key, defaultValue string) string + +获取请求头。 + +#### GetPaginationParams(r *http.Request) (page, pageSize int) + +获取分页参数。 + +**返回:** page(页码,最小1),pageSize(每页大小,最小1,最大1000) + +#### GetOffset(page, pageSize int) int + +根据页码和每页大小计算偏移量。 + +## 状态码说明 + +### HTTP状态码 + +- `200`: 正常响应(包括业务错误) +- `400`: 请求参数错误 +- `401`: 未授权 +- `403`: 禁止访问 +- `404`: 资源不存在 +- `500`: 系统内部错误 + +### 业务状态码 + +- `0`: 成功 +- `非0`: 业务错误(具体错误码由业务定义) + +## 注意事项 + +1. **HTTP状态码与业务状态码分离**: + - 业务错误(如用户不存在、参数验证失败等)返回HTTP 200,业务code非0 + - 只有系统异常(如数据库连接失败、程序panic等)才返回HTTP 500 + +2. **分页参数限制**: + - page最小值为1 + - pageSize最小值为1,最大值为1000 + +3. **响应格式统一**: + - 所有响应都遵循标准结构 + - timestamp为Unix时间戳(秒) + +4. **错误处理**: + - 使用Error方法返回业务错误 + - 使用SystemError返回系统错误 + - 使用BadRequest等返回HTTP级别的错误 + +## 示例 + +完整示例请参考 `examples/http_example.go` + diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..56cf653 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,438 @@ +# 中间件工具文档 + +## 概述 + +中间件工具提供了常用的HTTP中间件功能,包括CORS处理和时区管理。 + +## 功能特性 + +- **CORS中间件**:支持跨域资源共享配置 +- **时区中间件**:从请求头读取时区信息,支持默认时区设置 +- **中间件链**:提供便捷的中间件链式调用 + +## CORS中间件 + +### 功能说明 + +CORS中间件用于处理跨域资源共享,支持: +- 配置允许的源(支持通配符) +- 配置允许的HTTP方法 +- 配置允许的请求头 +- 配置暴露的响应头 +- 支持凭证传递 +- 预检请求缓存时间设置 + +### 使用方法 + +#### 基本使用(默认配置) + +```go +import ( + "net/http" + "github.com/go-common/middleware" +) + +func main() { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 处理请求 + }) + + // 使用默认CORS配置 + corsHandler := middleware.CORS()(handler) + + http.Handle("/api", corsHandler) + http.ListenAndServe(":8080", nil) +} +``` + +#### 自定义配置 + +```go +import ( + "net/http" + "github.com/go-common/middleware" +) + +func main() { + // 自定义CORS配置 + corsConfig := &middleware.CORSConfig{ + AllowedOrigins: []string{ + "https://example.com", + "https://app.example.com", + "*.example.com", // 支持通配符 + }, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{ + "Content-Type", + "Authorization", + "X-Requested-With", + "X-Timezone", + }, + ExposedHeaders: []string{"X-Total-Count"}, + AllowCredentials: true, + MaxAge: 3600, // 1小时 + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 处理请求 + }) + + corsHandler := middleware.CORS(corsConfig)(handler) + + http.Handle("/api", corsHandler) + http.ListenAndServe(":8080", nil) +} +``` + +#### 允许所有源(开发环境) + +```go +corsConfig := &middleware.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"*"}, +} + +corsHandler := middleware.CORS(corsConfig)(handler) +``` + +### CORSConfig 配置说明 + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| AllowedOrigins | []string | 允许的源,支持 "*" 和 "*.example.com" | ["*"] | +| AllowedMethods | []string | 允许的HTTP方法 | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] | +| AllowedHeaders | []string | 允许的请求头 | ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"] | +| ExposedHeaders | []string | 暴露给客户端的响应头 | [] | +| AllowCredentials | bool | 是否允许发送凭证 | false | +| MaxAge | int | 预检请求缓存时间(秒) | 86400 | + +### 注意事项 + +1. 如果 `AllowCredentials` 为 `true`,`AllowedOrigins` 不能使用 "*",必须指定具体的源 +2. 通配符支持:`"*.example.com"` 会匹配 `"https://app.example.com"` 等子域名 +3. 预检请求(OPTIONS)会自动处理,无需在业务代码中处理 + +## 时区中间件 + +### 功能说明 + +时区中间件用于从请求头读取时区信息,并存储到context中,方便后续使用。 + +- 从请求头 `X-Timezone` 读取时区 +- 如果未传递时区信息,使用默认时区 `AsiaShanghai` +- 时区信息存储到context中,可通过 `http.GetTimezone()` 获取 +- 自动验证时区有效性,无效时区会回退到默认时区 + +### 使用方法 + +#### 基本使用(默认时区 AsiaShanghai) + +```go +import ( + "net/http" + "github.com/go-common/middleware" + "github.com/go-common/http" + "github.com/go-common/datetime" +) + +func handler(w http.ResponseWriter, r *http.Request) { + // 从context获取时区 + timezone := http.GetTimezone(r) + + // 使用时区 + now := datetime.Now(timezone) + datetime.FormatDateTime(now, timezone) + + http.Success(w, map[string]interface{}{ + "timezone": timezone, + "time": datetime.FormatDateTime(now), + }) +} + +func main() { + handler := middleware.Timezone(http.HandlerFunc(handler)) + http.Handle("/api", handler) + http.ListenAndServe(":8080", nil) +} +``` + +#### 自定义默认时区 + +```go +import ( + "net/http" + "github.com/go-common/middleware" + "github.com/go-common/datetime" +) + +func handler(w http.ResponseWriter, r *http.Request) { + // 处理请求 +} + +func main() { + // 使用自定义默认时区 + handler := middleware.TimezoneWithDefault(datetime.UTC)(http.HandlerFunc(handler)) + http.Handle("/api", handler) + http.ListenAndServe(":8080", nil) +} +``` + +#### 在业务代码中使用时区 + +```go +import ( + "net/http" + "github.com/go-common/http" + "github.com/go-common/datetime" +) + +func GetUserList(w http.ResponseWriter, r *http.Request) { + // 从请求context获取时区 + timezone := http.GetTimezone(r) + + // 使用时区进行时间处理 + now := datetime.Now(timezone) + + // 查询数据时使用时区 + startTime := datetime.StartOfDay(now, timezone) + endTime := datetime.EndOfDay(now, timezone) + + // 返回数据 + http.Success(w, map[string]interface{}{ + "timezone": timezone, + "startTime": datetime.FormatDateTime(startTime), + "endTime": datetime.FormatDateTime(endTime), + }) +} +``` + +### 请求头格式 + +客户端需要在请求头中传递时区信息: + +``` +X-Timezone: Asia/Shanghai +``` + +支持的时区格式(IANA时区数据库): +- `Asia/Shanghai` +- `America/New_York` +- `Europe/London` +- `UTC` +- 等等 + +### 注意事项 + +1. 如果请求头中未传递 `X-Timezone`,默认使用 `AsiaShanghai` +2. 如果传递的时区无效,会自动回退到默认时区 +3. 时区信息存储在context中,可以在整个请求生命周期中使用 +4. 建议在CORS配置中包含 `X-Timezone` 请求头 + +## 中间件链 + +### 功能说明 + +中间件链提供便捷的中间件组合方式,支持链式调用。 + +### 使用方法 + +```go +import ( + "net/http" + "github.com/go-common/middleware" +) + +func handler(w http.ResponseWriter, r *http.Request) { + // 处理请求 +} + +func main() { + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(), + middleware.Timezone, + ) + + // 应用到处理器 + handler := chain.ThenFunc(handler) + + http.Handle("/api", handler) + http.ListenAndServe(":8080", nil) +} +``` + +#### 链式追加中间件 + +```go +chain := middleware.NewChain(middleware.CORS()) +chain.Append(middleware.Timezone) + +handler := chain.ThenFunc(handler) +``` + +## 完整示例 + +### 示例1:CORS + 时区中间件 + +```go +package main + +import ( + "log" + "net/http" + + "github.com/go-common/middleware" + "github.com/go-common/http" + "github.com/go-common/datetime" +) + +func apiHandler(w http.ResponseWriter, r *http.Request) { + // 获取时区 + timezone := http.GetTimezone(r) + now := datetime.Now(timezone) + + http.Success(w, map[string]interface{}{ + "message": "Hello", + "timezone": timezone, + "time": datetime.FormatDateTime(now), + }) +} + +func main() { + // 配置CORS + corsConfig := &middleware.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Timezone"}, + } + + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(corsConfig), + middleware.Timezone, + ) + + // 应用中间件 + handler := chain.ThenFunc(apiHandler) + + http.Handle("/api", handler) + + log.Println("Server started on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} +``` + +### 示例2:与路由框架集成 + +```go +package main + +import ( + "net/http" + + "github.com/go-common/middleware" + "github.com/go-common/http" +) + +func main() { + mux := http.NewServeMux() + + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(), + middleware.Timezone, + ) + + // 应用中间件到所有路由 + mux.Handle("/api/users", chain.ThenFunc(getUsers)) + mux.Handle("/api/posts", chain.ThenFunc(getPosts)) + + http.ListenAndServe(":8080", mux) +} + +func getUsers(w http.ResponseWriter, r *http.Request) { + timezone := http.GetTimezone(r) + // 处理逻辑 + http.Success(w, nil) +} + +func getPosts(w http.ResponseWriter, r *http.Request) { + timezone := http.GetTimezone(r) + // 处理逻辑 + http.Success(w, nil) +} +``` + +## API 参考 + +### CORS中间件 + +#### CORS(config ...*CORSConfig) func(http.Handler) http.Handler + +创建CORS中间件。 + +**参数:** +- `config`: 可选的CORS配置,不指定则使用默认配置 + +**返回:** 中间件函数 + +#### DefaultCORSConfig() *CORSConfig + +返回默认的CORS配置。 + +### 时区中间件 + +#### Timezone(next http.Handler) http.Handler + +时区处理中间件(默认时区为 AsiaShanghai)。 + +#### TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler + +时区处理中间件(可自定义默认时区)。 + +**参数:** +- `defaultTimezone`: 默认时区字符串 + +**返回:** 中间件函数 + +#### GetTimezoneFromContext(ctx context.Context) string + +从context中获取时区。 + +### 中间件链 + +#### NewChain(middlewares ...func(http.Handler) http.Handler) *Chain + +创建新的中间件链。 + +#### (c *Chain) Then(handler http.Handler) http.Handler + +将中间件链应用到处理器。 + +#### (c *Chain) ThenFunc(handler http.HandlerFunc) http.Handler + +将中间件链应用到处理器函数。 + +#### (c *Chain) Append(middlewares ...func(http.Handler) http.Handler) *Chain + +追加中间件到链中。 + +## 注意事项 + +1. **CORS配置**: + - 生产环境建议明确指定允许的源,避免使用 "*" + - 如果使用凭证(cookies),必须明确指定源,不能使用 "*" + +2. **时区处理**: + - 时区信息存储在context中,确保中间件在处理器之前执行 + - 时区验证失败时会自动回退到默认时区,不会返回错误 + +3. **中间件顺序**: + - CORS中间件应该放在最外层,以便处理预检请求 + - 时区中间件可以放在CORS之后 + +4. **性能考虑**: + - CORS预检请求会被缓存,减少重复请求 + - 时区验证只在请求头存在时进行,性能影响很小 + diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..da17b4c --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,242 @@ +# 数据库迁移工具文档 + +## 概述 + +数据库迁移工具提供了数据库版本管理和迁移功能,支持MySQL、PostgreSQL、SQLite等数据库。使用GORM作为数据库操作库,可以方便地进行数据库结构的版本控制。 + +## 功能特性 + +- 支持迁移版本管理 +- 支持迁移和回滚操作 +- 支持从文件系统加载迁移文件 +- 支持迁移状态查询 +- 自动创建迁移记录表 +- 事务支持,确保迁移的原子性 + +## 使用方法 + +### 1. 创建迁移器 + +```go +import ( + "gorm.io/driver/mysql" + "gorm.io/gorm" + "github.com/go-common/migration" +) + +// 初始化数据库连接 +dsn := "user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" +db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + +// 创建迁移器 +migrator := migration.NewMigrator(db) +// 或者指定自定义的迁移记录表名 +migrator := migration.NewMigrator(db, "my_migrations") +``` + +### 2. 添加迁移 + +#### 方式一:代码方式添加迁移 + +```go +migrator.AddMigration(migration.Migration{ + Version: "20240101000001", + Description: "create_users_table", + Up: func(db *gorm.DB) error { + return db.Exec(` + CREATE TABLE users ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `).Error + }, + Down: func(db *gorm.DB) error { + return db.Exec("DROP TABLE IF EXISTS users").Error + }, +}) +``` + +#### 方式二:批量添加迁移 + +```go +migrations := []migration.Migration{ + { + Version: "20240101000001", + Description: "create_users_table", + Up: func(db *gorm.DB) error { + return db.Exec("CREATE TABLE users ...").Error + }, + Down: func(db *gorm.DB) error { + return db.Exec("DROP TABLE users").Error + }, + }, + { + Version: "20240101000002", + Description: "add_index_to_users", + Up: func(db *gorm.DB) error { + return db.Exec("CREATE INDEX idx_email ON users(email)").Error + }, + Down: func(db *gorm.DB) error { + return db.Exec("DROP INDEX idx_email ON users").Error + }, + }, +} +migrator.AddMigrations(migrations...) +``` + +#### 方式三:从文件加载迁移 + +```go +// 文件命名格式: {version}_{description}.sql 或 {version}_{description}.up.sql +// 例如: 20240101000001_create_users_table.up.sql +// 对应的回滚文件: 20240101000001_create_users_table.down.sql + +migrations, err := migration.LoadMigrationsFromFiles("./migrations", "*.up.sql") +if err != nil { + log.Fatal(err) +} + +migrator.AddMigrations(migrations...) +``` + +### 3. 执行迁移 + +```go +// 执行所有未应用的迁移 +err := migrator.Up() +if err != nil { + log.Fatal(err) +} +``` + +### 4. 回滚迁移 + +```go +// 回滚最后一个迁移 +err := migrator.Down() +if err != nil { + log.Fatal(err) +} +``` + +### 5. 查看迁移状态 + +```go +status, err := migrator.Status() +if err != nil { + log.Fatal(err) +} + +for _, s := range status { + fmt.Printf("Version: %s, Description: %s, Applied: %v\n", + s.Version, s.Description, s.Applied) +} +``` + +### 6. 生成迁移版本号 + +```go +// 生成基于时间戳的版本号 +version := migration.GenerateVersion() +// 输出: 1704067200 (Unix时间戳) +``` + +## API 参考 + +### Migration 结构体 + +```go +type Migration struct { + Version string // 迁移版本号(必须唯一) + Description string // 迁移描述 + Up func(*gorm.DB) error // 升级函数 + Down func(*gorm.DB) error // 回滚函数(可选) +} +``` + +### Migrator 方法 + +#### NewMigrator(db *gorm.DB, tableName ...string) *Migrator + +创建新的迁移器。 + +**参数:** +- `db`: GORM数据库连接 +- `tableName`: 可选,迁移记录表名,默认为 "schema_migrations" + +**返回:** 迁移器实例 + +#### AddMigration(migration Migration) + +添加单个迁移。 + +#### AddMigrations(migrations ...Migration) + +批量添加迁移。 + +#### Up() error + +执行所有未应用的迁移。按版本号升序执行。 + +**返回:** 错误信息 + +#### Down() error + +回滚最后一个已应用的迁移。 + +**返回:** 错误信息 + +#### Status() ([]MigrationStatus, error) + +查看所有迁移的状态。 + +**返回:** 迁移状态列表和错误信息 + +### MigrationStatus 结构体 + +```go +type MigrationStatus struct { + Version string // 版本号 + Description string // 描述 + Applied bool // 是否已应用 +} +``` + +### 辅助函数 + +#### LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) + +从文件系统加载迁移文件。 + +**参数:** +- `dir`: 迁移文件目录 +- `pattern`: 文件匹配模式,如 "*.up.sql" + +**返回:** 迁移列表和错误信息 + +**文件命名格式:** `{version}_{description}.up.sql` + +**示例:** +- `20240101000001_create_users_table.up.sql` - 升级文件 +- `20240101000001_create_users_table.down.sql` - 回滚文件(可选) + +#### GenerateVersion() string + +生成基于时间戳的迁移版本号。 + +**返回:** Unix时间戳字符串 + +## 注意事项 + +1. 迁移版本号必须唯一,建议使用时间戳格式 +2. 迁移操作在事务中执行,失败会自动回滚 +3. 迁移记录表会自动创建,无需手动创建 +4. 如果迁移文件没有对应的down文件,回滚操作会失败 +5. 迁移按版本号升序执行,确保顺序正确 + +## 示例 + +完整示例请参考 `examples/migration_example.go` + diff --git a/docs/sms.md b/docs/sms.md new file mode 100644 index 0000000..9ee3c17 --- /dev/null +++ b/docs/sms.md @@ -0,0 +1,370 @@ +# 短信工具文档 + +## 概述 + +短信工具提供了阿里云短信发送功能,使用Go标准库实现,无需第三方依赖。 + +## 功能特性 + +- 支持阿里云短信服务 +- 支持发送原始请求(完全由外部控制请求参数) +- 支持模板短信发送 +- 支持批量发送 +- 自动签名计算 +- 使用配置工具统一管理配置 + +## 使用方法 + +### 1. 创建短信发送器 + +```go +import ( + "github.com/go-common/config" + "github.com/go-common/sms" +) + +// 从配置加载 +cfg, err := config.LoadFromFile("./config.json") +if err != nil { + log.Fatal(err) +} + +smsConfig := cfg.GetSMS() +if smsConfig == nil { + log.Fatal("SMS config is nil") +} + +// 创建短信发送器 +smsClient, err := sms.NewSMS(smsConfig) +if err != nil { + log.Fatal(err) +} +``` + +### 2. 发送原始请求(推荐,最灵活) + +```go +// 外部构建完整的请求参数 +params := map[string]string{ + "PhoneNumbers": "13800138000,13900139000", + "SignName": "我的签名", + "TemplateCode": "SMS_123456789", + "TemplateParam": `{"code":"123456","expire":"5"}`, +} + +// 发送短信(工具只负责添加系统参数、计算签名并发送) +resp, err := smsClient.SendRaw(params) +if err != nil { + log.Fatal(err) +} + +fmt.Printf("发送成功,RequestID: %s\n", resp.RequestID) +``` + +### 3. 发送简单短信(便捷方法) + +```go +// 使用配置中的模板代码发送短信 +templateParam := map[string]string{ + "code": "123456", +} + +resp, err := smsClient.SendSimple( + []string{"13800138000"}, + templateParam, +) +if err != nil { + log.Fatal(err) +} + +fmt.Printf("发送成功,RequestID: %s\n", resp.RequestID) +``` + +### 4. 使用指定模板发送短信(便捷方法) + +```go +// 使用指定的模板代码发送短信 +templateParam := map[string]string{ + "code": "123456", + "expire": "5", +} + +resp, err := smsClient.SendWithTemplate( + []string{"13800138000"}, + "SMS_123456789", // 模板代码 + templateParam, +) +if err != nil { + log.Fatal(err) +} +``` + +### 5. 使用SendRequest结构发送(便捷方法) + +```go +import "github.com/go-common/sms" + +req := &sms.SendRequest{ + PhoneNumbers: []string{"13800138000", "13900139000"}, + TemplateCode: "SMS_123456789", + TemplateParam: map[string]string{ + "code": "123456", + }, + SignName: "我的签名", // 可选,如果为空使用配置中的签名 +} + +resp, err := smsClient.Send(req) +if err != nil { + log.Fatal(err) +} +``` + +### 6. 使用JSON字符串作为模板参数 + +```go +// TemplateParam可以是JSON字符串 +req := &sms.SendRequest{ + PhoneNumbers: []string{"13800138000"}, + TemplateCode: "SMS_123456789", + TemplateParam: `{"code":"123456","expire":"5"}`, // 直接使用JSON字符串 +} + +resp, err := smsClient.Send(req) +``` + +## API 参考 + +### NewSMS(cfg *config.SMSConfig) (*SMS, error) + +创建短信发送器。 + +**参数:** +- `cfg`: 短信配置对象 + +**返回:** 短信发送器实例和错误信息 + +### (s *SMS) SendRaw(params map[string]string) (*SendResponse, error) + +发送原始请求(推荐使用,最灵活)。 + +**参数:** +- `params`: 请求参数map,工具只负责添加必要的系统参数(如签名、时间戳等)并发送 + +**返回:** 发送响应和错误信息 + +**说明:** 此方法允许外部完全控制请求参数,工具只负责添加系统参数、计算签名并发送。 + +### (s *SMS) Send(req *SendRequest) (*SendResponse, error) + +发送短信(使用SendRequest结构)。 + +**参数:** +- `req`: 发送请求对象 + +**返回:** 发送响应和错误信息 + +**说明:** 如果需要完全控制请求参数,请使用SendRaw方法。 + +### (s *SMS) SendSimple(phoneNumbers []string, templateParam map[string]string) (*SendResponse, error) + +发送简单短信(便捷方法,使用配置中的模板代码)。 + +**参数:** +- `phoneNumbers`: 手机号列表 +- `templateParam`: 模板参数 + +### (s *SMS) SendWithTemplate(phoneNumbers []string, templateCode string, templateParam map[string]string) (*SendResponse, error) + +使用指定模板发送短信(便捷方法)。 + +**参数:** +- `phoneNumbers`: 手机号列表 +- `templateCode`: 模板代码 +- `templateParam`: 模板参数 + +### SendRequest 结构体 + +```go +type SendRequest struct { + PhoneNumbers []string // 手机号列表 + TemplateCode string // 模板代码(可选,如果为空使用配置中的) + TemplateParam interface{} // 模板参数(可以是map[string]string或JSON字符串) + SignName string // 签名(可选,如果为空使用配置中的) +} +``` + +### SendResponse 结构体 + +```go +type SendResponse struct { + RequestID string // 请求ID + Code string // 响应码(OK表示成功) + Message string // 响应消息 + BizID string // 业务ID +} +``` + +## 配置说明 + +短信配置通过 `config.SMSConfig` 提供: + +| 字段 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| AccessKeyID | string | 阿里云AccessKey ID | - | +| AccessKeySecret | string | 阿里云AccessKey Secret | - | +| Region | string | 区域(如:cn-hangzhou) | cn-hangzhou | +| SignName | string | 短信签名 | - | +| TemplateCode | string | 短信模板代码 | - | +| Endpoint | string | 服务端点(可选) | - | +| Timeout | int | 请求超时时间(秒) | 10 | + +## 阿里云短信配置步骤 + +1. **开通阿里云短信服务** + - 登录阿里云控制台 + - 开通短信服务 + +2. **创建AccessKey** + - 在AccessKey管理页面创建AccessKey + - 保存AccessKey ID和Secret + +3. **申请短信签名** + - 在短信服务控制台申请签名 + - 等待审核通过 + +4. **创建短信模板** + - 在短信服务控制台创建模板 + - 模板格式示例:`您的验证码是${code},有效期${expire}分钟` + - 等待审核通过,获取模板代码(如:SMS_123456789) + +5. **配置参数** + ```json + { + "sms": { + "accessKeyId": "your-access-key-id", + "accessKeySecret": "your-access-key-secret", + "region": "cn-hangzhou", + "signName": "您的签名", + "templateCode": "SMS_123456789" + } + } + ``` + +## 模板参数示例 + +### 验证码模板 +模板内容:`您的验证码是${code},有效期${expire}分钟` + +```go +templateParam := map[string]string{ + "code": "123456", + "expire": "5", +} +``` + +### 通知模板 +模板内容:`您的订单${orderNo}已发货,物流单号:${trackingNo}` + +```go +templateParam := map[string]string{ + "orderNo": "ORD123456", + "trackingNo": "SF1234567890", +} +``` + +## 响应码说明 + +| Code | 说明 | +|------|------| +| OK | 发送成功 | +| InvalidSignName | 签名不存在 | +| InvalidTemplateCode | 模板不存在 | +| InvalidPhoneNumbers | 手机号格式错误 | +| Throttling | 请求被限流 | +| 其他 | 参考阿里云短信服务错误码文档 | + +## 注意事项 + +1. **推荐使用SendRaw方法**: + - `SendRaw`方法允许外部完全控制请求参数 + - 可以发送任意阿里云短信API支持的请求 + - 工具只负责添加系统参数、计算签名并发送 + +2. **模板参数格式**: + - `TemplateParam`可以是`map[string]string`或JSON字符串 + - 如果使用JSON字符串,必须是有效的JSON格式 + - 模板参数必须与模板中定义的变量匹配 + +3. **AccessKey安全**: + - AccessKey具有账户权限,请妥善保管 + - 建议使用子账户AccessKey,并限制权限 + +4. **签名和模板**: + - 签名和模板需要先申请并审核通过 + - 模板参数必须与模板中定义的变量匹配 + +5. **手机号格式**: + - 支持国内手机号(11位数字) + - 支持国际手机号(需要加国家代码) + +6. **发送频率**: + - 注意阿里云的发送频率限制 + - 建议实现发送频率控制 + +7. **错误处理**: + - 所有操作都应该进行错误处理 + - 建议记录详细的错误日志 + - 注意区分业务错误和系统错误 + +8. **批量发送**: + - 支持一次发送给多个手机号 + - 注意批量发送的数量限制 + +## 完整示例 + +```go +package main + +import ( + "fmt" + "log" + + "github.com/go-common/config" + "github.com/go-common/sms" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config.json") + if err != nil { + log.Fatal(err) + } + + // 创建短信发送器 + smsClient, err := sms.NewSMS(cfg.GetSMS()) + if err != nil { + log.Fatal(err) + } + + // 发送验证码短信 + templateParam := map[string]string{ + "code": "123456", + "expire": "5", + } + + resp, err := smsClient.SendSimple( + []string{"13800138000"}, + templateParam, + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("发送成功,RequestID: %s\n", resp.RequestID) +} +``` + +## 示例 + +完整示例请参考 `examples/sms_example.go` + diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..cca976e --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,496 @@ +# 存储工具文档 + +## 概述 + +存储工具提供了文件上传和查看功能,支持OSS和MinIO两种存储方式,并提供HTTP处理器用于文件上传和代理查看。 + +## 功能特性 + +- 支持OSS对象存储(阿里云、腾讯云、AWS、七牛云等) +- 支持MinIO对象存储 +- 提供统一的存储接口 +- 支持文件上传HTTP处理器 +- 支持文件代理查看HTTP处理器 +- 支持文件大小和扩展名限制 +- 自动生成唯一文件名 +- 支持自定义对象键前缀 + +## 使用方法 + +### 1. 创建存储实例 + +```go +import ( + "github.com/go-common/config" + "github.com/go-common/storage" +) + +// 加载配置 +cfg, err := config.LoadFromFile("./config.json") +if err != nil { + log.Fatal(err) +} + +// 创建OSS存储实例 +ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg) +if err != nil { + log.Fatal(err) +} + +// 创建MinIO存储实例 +minioStorage, err := storage.NewStorage(storage.StorageTypeMinIO, cfg) +if err != nil { + log.Fatal(err) +} +``` + +### 2. 上传文件 + +```go +import ( + "context" + "os" + "github.com/go-common/storage" +) + +// 打开文件 +file, err := os.Open("test.jpg") +if err != nil { + log.Fatal(err) +} +defer file.Close() + +// 上传文件 +ctx := context.Background() +objectKey := "images/test.jpg" +err = ossStorage.Upload(ctx, objectKey, file, "image/jpeg") +if err != nil { + log.Fatal(err) +} + +// 获取文件URL +url, err := ossStorage.GetURL(objectKey, 0) +if err != nil { + log.Fatal(err) +} +fmt.Printf("File URL: %s\n", url) +``` + +### 3. 使用HTTP处理器上传文件 + +```go +import ( + "net/http" + "github.com/go-common/storage" +) + +// 创建上传处理器 +uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ + Storage: ossStorage, + MaxFileSize: 10 * 1024 * 1024, // 10MB + AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif"}, + ObjectPrefix: "images/", +}) + +// 注册路由 +http.Handle("/upload", uploadHandler) +http.ListenAndServe(":8080", nil) +``` + +**上传请求示例:** +```bash +curl -X POST http://localhost:8080/upload \ + -F "file=@test.jpg" \ + -F "prefix=images/" +``` + +**响应示例:** +```json +{ + "code": 0, + "message": "Upload successful", + "timestamp": 1704067200, + "data": { + "objectKey": "images/test_1704067200000000000.jpg", + "url": "https://bucket.oss-cn-hangzhou.aliyuncs.com/images/test_1704067200000000000.jpg", + "size": 102400, + "contentType": "image/jpeg", + "uploadTime": "2024-01-01T12:00:00Z" + } +} +``` + +### 4. 使用HTTP处理器查看文件 + +```go +import ( + "net/http" + "github.com/go-common/storage" +) + +// 创建代理查看处理器 +proxyHandler := storage.NewProxyHandler(ossStorage) + +// 注册路由 +http.Handle("/file", proxyHandler) +http.ListenAndServe(":8080", nil) +``` + +**查看请求示例:** +``` +GET /file?key=images/test.jpg +``` + +### 5. 生成对象键 + +```go +import "github.com/go-common/storage" + +// 生成简单对象键 +objectKey := storage.GenerateObjectKey("images/", "test.jpg") +// 输出: "images/test.jpg" + +// 生成带日期的对象键 +objectKey := storage.GenerateObjectKeyWithDate("images", "test.jpg") +// 输出: "images/2024/01/01/test.jpg" +``` + +### 6. 删除文件 + +```go +ctx := context.Background() +err := ossStorage.Delete(ctx, "images/test.jpg") +if err != nil { + log.Fatal(err) +} +``` + +### 7. 检查文件是否存在 + +```go +ctx := context.Background() +exists, err := ossStorage.Exists(ctx, "images/test.jpg") +if err != nil { + log.Fatal(err) +} +if exists { + fmt.Println("File exists") +} +``` + +## API 参考 + +### Storage 接口 + +```go +type Storage interface { + // Upload 上传文件 + Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error + + // GetURL 获取文件访问URL + GetURL(objectKey string, expires int64) (string, error) + + // Delete 删除文件 + Delete(ctx context.Context, objectKey string) error + + // Exists 检查文件是否存在 + Exists(ctx context.Context, objectKey string) (bool, error) + + // GetObject 获取文件内容 + GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) +} +``` + +### NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) + +创建存储实例。 + +**参数:** +- `storageType`: 存储类型(`storage.StorageTypeOSS` 或 `storage.StorageTypeMinIO`) +- `cfg`: 配置对象 + +**返回:** 存储实例和错误信息 + +### UploadHandler + +文件上传HTTP处理器。 + +#### NewUploadHandler(cfg UploadHandlerConfig) *UploadHandler + +创建上传处理器。 + +**配置参数:** +- `Storage`: 存储实例 +- `MaxFileSize`: 最大文件大小(字节),0表示不限制 +- `AllowedExts`: 允许的文件扩展名,空表示不限制 +- `ObjectPrefix`: 对象键前缀 + +#### 请求格式 + +- **方法**: POST +- **表单字段**: + - `file`: 文件(必需) + - `prefix`: 对象键前缀(可选,会覆盖配置中的前缀) + +#### 响应格式 + +```json +{ + "code": 0, + "message": "Upload successful", + "timestamp": 1704067200, + "data": { + "objectKey": "images/test.jpg", + "url": "https://...", + "size": 102400, + "contentType": "image/jpeg", + "uploadTime": "2024-01-01T12:00:00Z" + } +} +``` + +### ProxyHandler + +文件代理查看HTTP处理器。 + +#### NewProxyHandler(storage Storage) *ProxyHandler + +创建代理查看处理器。 + +#### 请求格式 + +- **方法**: GET +- **URL参数**: + - `key`: 对象键(必需) + +#### 响应 + +直接返回文件内容,设置适当的Content-Type。 + +### 辅助函数 + +#### GenerateObjectKey(prefix, filename string) string + +生成对象键。 + +#### GenerateObjectKeyWithDate(prefix, filename string) string + +生成带日期的对象键(格式: prefix/YYYY/MM/DD/filename)。 + +## 完整示例 + +### 示例1:文件上传和查看 + +```go +package main + +import ( + "log" + "net/http" + + "github.com/go-common/config" + "github.com/go-common/middleware" + "github.com/go-common/storage" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config.json") + if err != nil { + log.Fatal(err) + } + + // 创建存储实例(使用OSS) + ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg) + if err != nil { + log.Fatal(err) + } + + // 创建上传处理器 + uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ + Storage: ossStorage, + MaxFileSize: 10 * 1024 * 1024, // 10MB + AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}, + ObjectPrefix: "uploads/", + }) + + // 创建代理查看处理器 + proxyHandler := storage.NewProxyHandler(ossStorage) + + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(cfg.GetCORS()), + middleware.Timezone, + ) + + // 注册路由 + mux := http.NewServeMux() + mux.Handle("/upload", chain.Then(uploadHandler)) + mux.Handle("/file", chain.Then(proxyHandler)) + + log.Println("Server started on :8080") + log.Fatal(http.ListenAndServe(":8080", mux)) +} +``` + +### 示例2:直接使用存储接口 + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/go-common/config" + "github.com/go-common/storage" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config.json") + if err != nil { + log.Fatal(err) + } + + // 创建存储实例 + s, err := storage.NewStorage(storage.StorageTypeMinIO, cfg) + if err != nil { + log.Fatal(err) + } + + // 打开文件 + file, err := os.Open("test.jpg") + if err != nil { + log.Fatal(err) + } + defer file.Close() + + // 生成对象键 + objectKey := storage.GenerateObjectKeyWithDate("images", "test.jpg") + + // 上传文件 + ctx := context.Background() + err = s.Upload(ctx, objectKey, file, "image/jpeg") + if err != nil { + log.Fatal(err) + } + + // 获取文件URL + url, err := s.GetURL(objectKey, 0) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("File uploaded: %s\n", url) +} +``` + +## 注意事项 + +1. **OSS和MinIO SDK实现**: + - 当前实现提供了接口和框架,但具体的OSS和MinIO SDK集成需要根据实际使用的SDK实现 + - 需要在`oss.go`和`minio.go`中实现具体的SDK调用 + +2. **文件大小限制**: + - 建议设置合理的文件大小限制 + - 大文件上传可能需要分片上传 + +3. **文件扩展名验证**: + - 建议限制允许的文件类型,防止上传恶意文件 + - 仅验证扩展名不够安全,建议结合文件内容验证 + +4. **安全性**: + - 上传接口应该添加身份验证 + - 代理查看接口可以添加访问控制 + +5. **性能优化**: + - 对于大文件,考虑使用分片上传 + - 代理查看可以添加缓存机制 + +6. **错误处理**: + - 所有操作都应该进行错误处理 + - 建议记录详细的错误日志 + +## 实现OSS和MinIO SDK集成 + +由于不同的OSS提供商和MinIO有不同的SDK,当前实现提供了框架,需要根据实际情况集成: + +### OSS SDK集成示例(阿里云OSS) + +```go +import ( + "github.com/aliyun/aliyun-oss-go-sdk/oss" +) + +func NewOSSStorage(cfg *config.OSSConfig) (*OSSStorage, error) { + client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) + if err != nil { + return nil, err + } + + storage := &OSSStorage{ + config: cfg, + client: client, + } + return storage, nil +} + +func (s *OSSStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error { + bucket, err := s.client.Bucket(s.config.Bucket) + if err != nil { + return err + } + + options := []oss.Option{} + if len(contentType) > 0 && contentType[0] != "" { + options = append(options, oss.ContentType(contentType[0])) + } + + return bucket.PutObject(objectKey, reader, options...) +} +``` + +### MinIO SDK集成示例 + +```go +import ( + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func NewMinIOStorage(cfg *config.MinIOConfig) (*MinIOStorage, error) { + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, err + } + + storage := &MinIOStorage{ + config: cfg, + client: client, + } + return storage, nil +} + +func (s *MinIOStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error { + ct := "application/octet-stream" + if len(contentType) > 0 && contentType[0] != "" { + ct = contentType[0] + } + + _, err := s.client.PutObject(ctx, s.config.Bucket, objectKey, reader, -1, minio.PutObjectOptions{ + ContentType: ct, + }) + return err +} +``` + +## 示例 + +完整示例请参考 `examples/storage_example.go` + diff --git a/email/email.go b/email/email.go new file mode 100644 index 0000000..8bd9a1c --- /dev/null +++ b/email/email.go @@ -0,0 +1,306 @@ +package email + +import ( + "bytes" + "crypto/tls" + "fmt" + "net" + "net/smtp" + "time" + + "github.com/go-common/config" +) + +// Email 邮件发送器 +type Email struct { + config *config.EmailConfig +} + +// NewEmail 创建邮件发送器 +func NewEmail(cfg *config.EmailConfig) (*Email, error) { + if cfg == nil { + return nil, fmt.Errorf("email config is nil") + } + + if cfg.Host == "" { + return nil, fmt.Errorf("email host is required") + } + + if cfg.Username == "" { + return nil, fmt.Errorf("email username is required") + } + + if cfg.Password == "" { + return nil, fmt.Errorf("email password is required") + } + + // 设置默认值 + if cfg.Port == 0 { + cfg.Port = 587 + } + if cfg.From == "" { + cfg.From = cfg.Username + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 + } + + return &Email{ + config: cfg, + }, nil +} + +// Message 邮件消息 +type Message struct { + // To 收件人列表 + To []string + + // Cc 抄送列表(可选) + Cc []string + + // Bcc 密送列表(可选) + Bcc []string + + // Subject 主题 + Subject string + + // Body 正文(纯文本) + Body string + + // HTMLBody HTML正文(可选,如果设置了会优先使用) + HTMLBody string + + // Attachments 附件列表(可选) + Attachments []Attachment +} + +// Attachment 附件 +type Attachment struct { + // Filename 文件名 + Filename string + + // Content 文件内容 + Content []byte + + // ContentType 文件类型(如:application/pdf) + ContentType string +} + +// SendRaw 发送原始邮件内容 +// recipients: 收件人列表(To、Cc、Bcc的合并列表) +// body: 完整的邮件内容(MIME格式),由外部构建 +func (e *Email) SendRaw(recipients []string, body []byte) error { + if len(recipients) == 0 { + return fmt.Errorf("recipients are required") + } + + if len(body) == 0 { + return fmt.Errorf("email body is required") + } + + // 连接SMTP服务器 + addr := fmt.Sprintf("%s:%d", e.config.Host, e.config.Port) + auth := smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.Host) + + // 创建连接 + conn, err := net.DialTimeout("tcp", addr, time.Duration(e.config.Timeout)*time.Second) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer conn.Close() + + // 创建SMTP客户端 + client, err := smtp.NewClient(conn, e.config.Host) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer client.Close() + + // TLS/SSL处理 + if e.config.UseSSL { + // SSL模式(端口通常是465) + tlsConfig := &tls.Config{ + ServerName: e.config.Host, + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + } else if e.config.UseTLS { + // TLS模式(STARTTLS,端口通常是587) + tlsConfig := &tls.Config{ + ServerName: e.config.Host, + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + } + + // 认证 + if err := client.Auth(auth); err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + + // 设置发件人 + if err := client.Mail(e.config.From); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + + // 设置收件人 + for _, to := range recipients { + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient %s: %w", to, err) + } + } + + // 发送邮件内容 + writer, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + + _, err = writer.Write(body) + if err != nil { + writer.Close() + return fmt.Errorf("failed to write email body: %w", err) + } + + err = writer.Close() + if err != nil { + return fmt.Errorf("failed to close writer: %w", err) + } + + // 退出 + if err := client.Quit(); err != nil { + return fmt.Errorf("failed to quit: %w", err) + } + + return nil +} + +// Send 发送邮件(使用Message结构,内部会构建邮件内容) +// 注意:如果需要完全控制邮件内容,请使用SendRaw方法 +func (e *Email) Send(msg *Message) error { + if msg == nil { + return fmt.Errorf("message is nil") + } + + if len(msg.To) == 0 { + return fmt.Errorf("recipients are required") + } + + if msg.Subject == "" { + return fmt.Errorf("subject is required") + } + + if msg.Body == "" && msg.HTMLBody == "" { + return fmt.Errorf("body or HTMLBody is required") + } + + // 构建邮件内容 + emailBody, err := e.buildEmailBody(msg) + if err != nil { + return fmt.Errorf("failed to build email body: %w", err) + } + + // 合并收件人列表 + recipients := append(msg.To, msg.Cc...) + recipients = append(recipients, msg.Bcc...) + + // 使用SendRaw发送 + return e.SendRaw(recipients, emailBody) +} + +// buildEmailBody 构建邮件内容 +func (e *Email) buildEmailBody(msg *Message) ([]byte, error) { + var buf bytes.Buffer + + // 邮件头 + from := e.config.From + if e.config.FromName != "" { + from = fmt.Sprintf("%s <%s>", e.config.FromName, e.config.From) + } + buf.WriteString(fmt.Sprintf("From: %s\r\n", from)) + + // 收件人 + buf.WriteString(fmt.Sprintf("To: %s\r\n", joinEmails(msg.To))) + + // 抄送 + if len(msg.Cc) > 0 { + buf.WriteString(fmt.Sprintf("Cc: %s\r\n", joinEmails(msg.Cc))) + } + + // 主题 + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + + // 内容类型 + if msg.HTMLBody != "" { + // 多部分邮件(HTML + 纯文本) + boundary := "----=_Part_" + fmt.Sprint(time.Now().UnixNano()) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + buf.WriteString("\r\n") + + // 纯文本部分 + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + buf.WriteString(msg.Body) + buf.WriteString("\r\n") + + // HTML部分 + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + buf.WriteString(msg.HTMLBody) + buf.WriteString("\r\n") + + buf.WriteString("--" + boundary + "--\r\n") + } else { + // 纯文本邮件 + buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + buf.WriteString("\r\n") + buf.WriteString(msg.Body) + buf.WriteString("\r\n") + } + + return buf.Bytes(), nil +} + +// joinEmails 连接邮箱地址 +func joinEmails(emails []string) string { + if len(emails) == 0 { + return "" + } + result := emails[0] + for i := 1; i < len(emails); i++ { + result += ", " + emails[i] + } + return result +} + +// SendSimple 发送简单邮件(便捷方法) +// to: 收件人 +// subject: 主题 +// body: 正文 +func (e *Email) SendSimple(to []string, subject, body string) error { + return e.Send(&Message{ + To: to, + Subject: subject, + Body: body, + }) +} + +// SendHTML 发送HTML邮件(便捷方法) +// to: 收件人 +// subject: 主题 +// htmlBody: HTML正文 +func (e *Email) SendHTML(to []string, subject, htmlBody string) error { + return e.Send(&Message{ + To: to, + Subject: subject, + HTMLBody: htmlBody, + }) +} diff --git a/examples/config_example.go b/examples/config_example.go new file mode 100644 index 0000000..cd83542 --- /dev/null +++ b/examples/config_example.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "log" + + "github.com/go-common/config" + "github.com/go-common/middleware" + // "gorm.io/driver/mysql" + // "gorm.io/gorm" +) + +func main() { + // 加载配置文件 + cfg, err := config.LoadFromFile("./config/example.json") + if err != nil { + log.Fatal("Failed to load config:", err) + } + + // 1. 使用数据库配置 + fmt.Println("=== Database Config ===") + dbConfig := cfg.GetDatabase() + if dbConfig != nil { + fmt.Printf("Type: %s\n", dbConfig.Type) + fmt.Printf("Host: %s:%d\n", dbConfig.Host, dbConfig.Port) + fmt.Printf("Database: %s\n", dbConfig.Database) + fmt.Printf("User: %s\n", dbConfig.User) + } + + // 获取数据库连接字符串 + dsn, err := cfg.GetDatabaseDSN() + if err != nil { + log.Printf("Failed to get DSN: %v", err) + } else { + fmt.Printf("DSN: %s\n", dsn) + // 实际使用时可以这样连接数据库 + // db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + // if err != nil { + // log.Fatal(err) + // } + } + + // 2. 使用OSS配置 + fmt.Println("\n=== OSS Config ===") + ossConfig := cfg.GetOSS() + if ossConfig != nil { + fmt.Printf("Provider: %s\n", ossConfig.Provider) + fmt.Printf("Endpoint: %s\n", ossConfig.Endpoint) + fmt.Printf("Bucket: %s\n", ossConfig.Bucket) + fmt.Printf("Region: %s\n", ossConfig.Region) + } + + // 3. 使用Redis配置 + fmt.Println("\n=== Redis Config ===") + redisConfig := cfg.GetRedis() + if redisConfig != nil { + fmt.Printf("Host: %s\n", redisConfig.Host) + fmt.Printf("Port: %d\n", redisConfig.Port) + fmt.Printf("Database: %d\n", redisConfig.Database) + } + + // 获取Redis地址 + redisAddr := cfg.GetRedisAddr() + fmt.Printf("Redis Address: %s\n", redisAddr) + + // 4. 使用CORS配置 + fmt.Println("\n=== CORS Config ===") + corsConfig := cfg.GetCORS() + if corsConfig != nil { + fmt.Printf("Allowed Origins: %v\n", corsConfig.AllowedOrigins) + fmt.Printf("Allowed Methods: %v\n", corsConfig.AllowedMethods) + fmt.Printf("Max Age: %d\n", corsConfig.MaxAge) + } + + // 使用CORS配置创建中间件 + chain := middleware.NewChain( + middleware.CORS(corsConfig), + ) + fmt.Printf("CORS middleware created: %v\n", chain != nil) + + // 5. 使用MinIO配置 + fmt.Println("\n=== MinIO Config ===") + minioConfig := cfg.GetMinIO() + if minioConfig != nil { + fmt.Printf("Endpoint: %s\n", minioConfig.Endpoint) + fmt.Printf("Bucket: %s\n", minioConfig.Bucket) + fmt.Printf("UseSSL: %v\n", minioConfig.UseSSL) + } + + // 6. 使用邮件配置 + fmt.Println("\n=== Email Config ===") + emailConfig := cfg.GetEmail() + if emailConfig != nil { + fmt.Printf("Host: %s:%d\n", emailConfig.Host, emailConfig.Port) + fmt.Printf("From: %s <%s>\n", emailConfig.FromName, emailConfig.From) + fmt.Printf("UseTLS: %v\n", emailConfig.UseTLS) + } + + // 7. 使用短信配置 + fmt.Println("\n=== SMS Config ===") + smsConfig := cfg.GetSMS() + if smsConfig != nil { + fmt.Printf("Region: %s\n", smsConfig.Region) + fmt.Printf("Sign Name: %s\n", smsConfig.SignName) + fmt.Printf("Template Code: %s\n", smsConfig.TemplateCode) + } + + // 示例:实际使用配置连接数据库 + fmt.Println("\n=== Example: Connect to Database ===") + if dbConfig != nil && dbConfig.Type == "mysql" { + // 注意:这里只是示例,实际使用时需要确保数据库服务正在运行 + // db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + // if err != nil { + // log.Printf("Failed to connect to database: %v", err) + // } else { + // fmt.Println("Database connected successfully") + // sqlDB, _ := db.DB() + // sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConns) + // sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConns) + // sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second) + // } + fmt.Println("Database connection example (commented out)") + } +} diff --git a/examples/datetime_example.go b/examples/datetime_example.go new file mode 100644 index 0000000..7b4765b --- /dev/null +++ b/examples/datetime_example.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/go-common/datetime" +) + +func main() { + // 设置默认时区 + err := datetime.SetDefaultTimeZone(datetime.AsiaShanghai) + if err != nil { + log.Fatal(err) + } + + // 获取当前时间 + now := datetime.Now() + fmt.Printf("Current time: %s\n", datetime.FormatDateTime(now)) + + // 解析时间字符串 + t, err := datetime.ParseDateTime("2024-01-01 12:00:00") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Parsed time: %s\n", datetime.FormatDateTime(t)) + + // 时区转换 + t2, err := datetime.ToTimezone(t, datetime.AmericaNewYork) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Time in New York: %s\n", datetime.FormatDateTime(t2)) + + // Unix时间戳 + unix := datetime.ToUnix(now) + fmt.Printf("Unix timestamp: %d\n", unix) + t3 := datetime.FromUnix(unix) + fmt.Printf("From Unix: %s\n", datetime.FormatDateTime(t3)) + + // 时间计算 + tomorrow := datetime.AddDays(now, 1) + fmt.Printf("Tomorrow: %s\n", datetime.FormatDate(tomorrow)) + + // 时间范围 + startOfDay := datetime.StartOfDay(now) + endOfDay := datetime.EndOfDay(now) + fmt.Printf("Start of day: %s\n", datetime.FormatDateTime(startOfDay)) + fmt.Printf("End of day: %s\n", datetime.FormatDateTime(endOfDay)) + + // 时间差 + diff := datetime.DiffDays(now, tomorrow) + fmt.Printf("Days difference: %d\n", diff) +} + diff --git a/examples/datetime_utc_example.go b/examples/datetime_utc_example.go new file mode 100644 index 0000000..2711db6 --- /dev/null +++ b/examples/datetime_utc_example.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/go-common/datetime" +) + +func main() { + // 示例1:将当前时间转换为UTC + fmt.Println("=== Example 1: Convert Current Time to UTC ===") + now := time.Now() + utcTime := datetime.ToUTC(now) + fmt.Printf("Local time: %s\n", datetime.FormatDateTime(now)) + fmt.Printf("UTC time: %s\n", datetime.FormatDateTime(utcTime)) + + // 示例2:从指定时区转换为UTC + fmt.Println("\n=== Example 2: Convert from Specific Timezone to UTC ===") + // 解析上海时区的时间 + shanghaiTime, err := datetime.ParseDateTime("2024-01-01 12:00:00", datetime.AsiaShanghai) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Shanghai time: %s\n", datetime.FormatDateTime(shanghaiTime, datetime.AsiaShanghai)) + + // 转换为UTC + utcTime2, err := datetime.ToUTCFromTimezone(shanghaiTime, datetime.AsiaShanghai) + if err != nil { + log.Fatal(err) + } + fmt.Printf("UTC time: %s\n", datetime.FormatDateTime(utcTime2, datetime.UTC)) + + // 示例3:解析时间字符串并直接转换为UTC + fmt.Println("\n=== Example 3: Parse and Convert to UTC ===") + utcTime3, err := datetime.ParseDateTimeToUTC("2024-01-01 12:00:00", datetime.AsiaShanghai) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Parsed from Shanghai timezone, UTC: %s\n", datetime.FormatDateTime(utcTime3, datetime.UTC)) + + // 示例4:解析日期并转换为UTC + fmt.Println("\n=== Example 4: Parse Date and Convert to UTC ===") + utcTime4, err := datetime.ParseDateToUTC("2024-01-01", datetime.AsiaShanghai) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Date parsed from Shanghai timezone, UTC: %s\n", datetime.FormatDateTime(utcTime4, datetime.UTC)) + + // 示例5:数据库存储场景 + fmt.Println("\n=== Example 5: Database Storage Scenario ===") + // 从请求中获取时间(假设是上海时区) + requestTimeStr := "2024-01-01 12:00:00" + requestTimezone := datetime.AsiaShanghai + + // 转换为UTC时间(用于数据库存储) + dbTime, err := datetime.ParseDateTimeToUTC(requestTimeStr, requestTimezone) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Request time (Shanghai): %s\n", requestTimeStr) + fmt.Printf("Database time (UTC): %s\n", datetime.FormatDateTime(dbTime, datetime.UTC)) + + // 从数据库读取UTC时间,转换为用户时区显示 + userTimezone := datetime.AsiaShanghai + displayTime, err := datetime.ToTimezone(dbTime, userTimezone) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Display time (Shanghai): %s\n", datetime.FormatDateTime(displayTime, userTimezone)) +} + diff --git a/examples/email_example.go b/examples/email_example.go new file mode 100644 index 0000000..b499c15 --- /dev/null +++ b/examples/email_example.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "log" + + "github.com/go-common/config" + "github.com/go-common/email" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config/example.json") + if err != nil { + log.Fatal("Failed to load config:", err) + } + + // 创建邮件发送器 + emailConfig := cfg.GetEmail() + if emailConfig == nil { + log.Fatal("Email config is nil") + } + + mailer, err := email.NewEmail(emailConfig) + if err != nil { + log.Fatal("Failed to create email client:", err) + } + + // 示例1:发送原始邮件内容(推荐,最灵活) + fmt.Println("=== Example 1: Send Raw Email Content ===") + // 外部构建完整的邮件内容(MIME格式) + emailBody := []byte(`From: ` + emailConfig.From + ` +To: recipient@example.com +Subject: 原始邮件测试 +Content-Type: text/html; charset=UTF-8 + + + +

这是原始邮件内容

+

由外部完全控制邮件格式和内容

+ + +`) + + err = mailer.SendRaw( + []string{"recipient@example.com"}, + emailBody, + ) + if err != nil { + log.Printf("Failed to send raw email: %v", err) + } else { + fmt.Println("Raw email sent successfully") + } + + // 示例2:发送简单邮件(便捷方法) + fmt.Println("\n=== Example 2: Send Simple Email ===") + err = mailer.SendSimple( + []string{"recipient@example.com"}, + "测试邮件", + "这是一封测试邮件,使用Go标准库发送。", + ) + if err != nil { + log.Printf("Failed to send email: %v", err) + } else { + fmt.Println("Email sent successfully") + } + + // 示例3:发送HTML邮件 + fmt.Println("\n=== Example 3: Send HTML Email ===") + htmlBody := ` + + + + + +

欢迎使用邮件服务

+

这是一封HTML格式的邮件。

+

支持富文本格式。

+ + +` + + err = mailer.SendHTML( + []string{"recipient@example.com"}, + "HTML邮件测试", + htmlBody, + ) + if err != nil { + log.Printf("Failed to send HTML email: %v", err) + } else { + fmt.Println("HTML email sent successfully") + } + + // 示例4:发送完整邮件(包含抄送、密送) + fmt.Println("\n=== Example 4: Send Full Email ===") + msg := &email.Message{ + To: []string{"to@example.com"}, + Cc: []string{"cc@example.com"}, + Bcc: []string{"bcc@example.com"}, + Subject: "完整邮件示例", + Body: "这是纯文本正文", + HTMLBody: ` + + +

这是HTML正文

+

支持同时发送纯文本和HTML版本。

+ + +`, + } + + err = mailer.Send(msg) + if err != nil { + log.Printf("Failed to send full email: %v", err) + } else { + fmt.Println("Full email sent successfully") + } + + fmt.Println("\nNote: Make sure your email configuration is correct and SMTP service is enabled.") +} + diff --git a/examples/http_example.go b/examples/http_example.go new file mode 100644 index 0000000..5e000f6 --- /dev/null +++ b/examples/http_example.go @@ -0,0 +1,101 @@ +package main + +import ( + "log" + "net/http" + + "github.com/go-common/http" +) + +// 用户结构 +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// 获取用户列表 +func GetUserList(w http.ResponseWriter, r *http.Request) { + // 获取分页参数 + page, pageSize := http.GetPaginationParams(r) + + // 获取查询参数 + keyword := http.GetQuery(r, "keyword", "") + + // 模拟查询数据 + users := []User{ + {ID: 1, Name: "User1", Email: "user1@example.com"}, + {ID: 2, Name: "User2", Email: "user2@example.com"}, + } + total := int64(100) + + // 返回分页响应 + http.SuccessPage(w, users, total, page, pageSize) +} + +// 创建用户 +func CreateUser(w http.ResponseWriter, r *http.Request) { + // 解析请求体 + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + + if err := http.ParseJSON(r, &req); err != nil { + http.BadRequest(w, "请求参数解析失败") + return + } + + // 参数验证 + if req.Name == "" { + http.Error(w, 1001, "用户名不能为空") + return + } + + // 模拟创建用户 + user := User{ + ID: 1, + Name: req.Name, + Email: req.Email, + } + + // 返回成功响应 + http.SuccessWithMessage(w, "创建成功", user) +} + +// 获取用户详情 +func GetUser(w http.ResponseWriter, r *http.Request) { + // 获取查询参数 + id := http.GetQueryInt64(r, "id", 0) + + if id == 0 { + http.BadRequest(w, "用户ID不能为空") + return + } + + // 模拟查询用户 + if id == 1 { + user := User{ID: 1, Name: "User1", Email: "user1@example.com"} + http.Success(w, user) + } else { + http.Error(w, 1002, "用户不存在") + } +} + +func main() { + http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + GetUserList(w, r) + case http.MethodPost: + CreateUser(w, r) + default: + http.NotFound(w, "方法不支持") + } + }) + + http.HandleFunc("/user", GetUser) + + log.Println("Server started on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/examples/middleware_example.go b/examples/middleware_example.go new file mode 100644 index 0000000..e702b82 --- /dev/null +++ b/examples/middleware_example.go @@ -0,0 +1,64 @@ +package main + +import ( + "log" + "net/http" + + "github.com/go-common/datetime" + "github.com/go-common/http" + "github.com/go-common/middleware" +) + +// 示例:使用CORS和时区中间件 +func main() { + // 配置CORS + corsConfig := &middleware.CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{ + "Content-Type", + "Authorization", + "X-Requested-With", + "X-Timezone", + }, + AllowCredentials: false, + MaxAge: 3600, + } + + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(corsConfig), + middleware.Timezone, + ) + + // 定义处理器 + handler := chain.ThenFunc(apiHandler) + + // 注册路由 + http.Handle("/api", handler) + + log.Println("Server started on :8080") + log.Println("Try: curl -H 'X-Timezone: America/New_York' http://localhost:8080/api") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// apiHandler 处理API请求 +func apiHandler(w http.ResponseWriter, r *http.Request) { + // 从context获取时区 + timezone := http.GetTimezone(r) + + // 使用时区进行时间处理 + now := datetime.Now(timezone) + startOfDay := datetime.StartOfDay(now, timezone) + endOfDay := datetime.EndOfDay(now, timezone) + + // 返回响应 + http.Success(w, map[string]interface{}{ + "message": "Hello from API", + "timezone": timezone, + "currentTime": datetime.FormatDateTime(now), + "startOfDay": datetime.FormatDateTime(startOfDay), + "endOfDay": datetime.FormatDateTime(endOfDay), + }) +} + diff --git a/examples/migration_example.go b/examples/migration_example.go new file mode 100644 index 0000000..91f7652 --- /dev/null +++ b/examples/migration_example.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "github.com/go-common/migration" +) + +func main() { + // 初始化数据库连接 + dsn := "user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + // 创建迁移器 + migrator := migration.NewMigrator(db) + + // 添加迁移 + migrator.AddMigration(migration.Migration{ + Version: "20240101000001", + Description: "create_users_table", + Up: func(db *gorm.DB) error { + return db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `).Error + }, + Down: func(db *gorm.DB) error { + return db.Exec("DROP TABLE IF EXISTS users").Error + }, + }) + + // 执行迁移 + if err := migrator.Up(); err != nil { + log.Fatal(err) + } + + // 查看迁移状态 + status, err := migrator.Status() + if err != nil { + log.Fatal(err) + } + + for _, s := range status { + fmt.Printf("Version: %s, Description: %s, Applied: %v\n", + s.Version, s.Description, s.Applied) + } +} + diff --git a/examples/sms_example.go b/examples/sms_example.go new file mode 100644 index 0000000..cbde14c --- /dev/null +++ b/examples/sms_example.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "log" + + "github.com/go-common/config" + "github.com/go-common/sms" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config/example.json") + if err != nil { + log.Fatal("Failed to load config:", err) + } + + // 创建短信发送器 + smsConfig := cfg.GetSMS() + if smsConfig == nil { + log.Fatal("SMS config is nil") + } + + smsClient, err := sms.NewSMS(smsConfig) + if err != nil { + log.Fatal("Failed to create SMS client:", err) + } + + // 示例1:发送原始请求(推荐,最灵活) + fmt.Println("=== Example 1: Send Raw SMS Request ===") + // 外部构建完整的请求参数 + params := map[string]string{ + "PhoneNumbers": "13800138000", + "SignName": smsConfig.SignName, + "TemplateCode": smsConfig.TemplateCode, + "TemplateParam": `{"code":"123456","expire":"5"}`, + } + + resp, err := smsClient.SendRaw(params) + if err != nil { + log.Printf("Failed to send raw SMS: %v", err) + } else { + fmt.Printf("Raw SMS sent successfully, RequestID: %s\n", resp.RequestID) + } + + // 示例2:发送简单短信(使用配置中的模板代码) + fmt.Println("\n=== Example 2: Send Simple SMS ===") + templateParam := map[string]string{ + "code": "123456", + "expire": "5", + } + + resp2, err := smsClient.SendSimple( + []string{"13800138000"}, + templateParam, + ) + if err != nil { + log.Printf("Failed to send SMS: %v", err) + } else { + fmt.Printf("SMS sent successfully, RequestID: %s\n", resp2.RequestID) + } + + // 示例3:使用指定模板发送短信 + fmt.Println("\n=== Example 3: Send SMS with Template ===") + templateParam3 := map[string]string{ + "code": "654321", + "expire": "10", + } + + resp3, err := smsClient.SendWithTemplate( + []string{"13800138000"}, + "SMS_123456789", // 使用指定的模板代码 + templateParam3, + ) + if err != nil { + log.Printf("Failed to send SMS: %v", err) + } else { + fmt.Printf("SMS sent successfully, RequestID: %s\n", resp3.RequestID) + } + + // 示例4:使用JSON字符串作为模板参数 + fmt.Println("\n=== Example 4: Send SMS with JSON String Template Param ===") + req := &sms.SendRequest{ + PhoneNumbers: []string{"13800138000"}, + TemplateCode: smsConfig.TemplateCode, + TemplateParam: `{"code":"888888","expire":"15"}`, // 直接使用JSON字符串 + } + + resp4, err := smsClient.Send(req) + if err != nil { + log.Printf("Failed to send SMS: %v", err) + } else { + fmt.Printf("SMS sent successfully, RequestID: %s\n", resp4.RequestID) + } + + fmt.Println("\nNote: Make sure your Aliyun SMS service is configured correctly:") + fmt.Println("1. AccessKey ID and Secret are valid") + fmt.Println("2. Sign name is approved") + fmt.Println("3. Template code is approved") + fmt.Println("4. Template parameters match the template definition") +} + diff --git a/examples/storage_example.go b/examples/storage_example.go new file mode 100644 index 0000000..533161b --- /dev/null +++ b/examples/storage_example.go @@ -0,0 +1,71 @@ +package main + +import ( + "log" + "net/http" + + "github.com/go-common/config" + "github.com/go-common/middleware" + "github.com/go-common/storage" +) + +func main() { + // 加载配置 + cfg, err := config.LoadFromFile("./config/example.json") + if err != nil { + log.Fatal("Failed to load config:", err) + } + + // 创建存储实例(使用OSS) + // 注意:需要先实现OSS SDK集成 + ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg) + if err != nil { + log.Printf("Failed to create OSS storage: %v", err) + log.Println("Note: OSS SDK integration is required") + // 继续演示其他功能 + } else { + // 创建上传处理器 + uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ + Storage: ossStorage, + MaxFileSize: 10 * 1024 * 1024, // 10MB + AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}, + ObjectPrefix: "uploads/", + }) + + // 创建代理查看处理器 + proxyHandler := storage.NewProxyHandler(ossStorage) + + // 创建中间件链 + chain := middleware.NewChain( + middleware.CORS(cfg.GetCORS()), + middleware.Timezone, + ) + + // 注册路由 + mux := http.NewServeMux() + mux.Handle("/upload", chain.Then(uploadHandler)) + mux.Handle("/file", chain.Then(proxyHandler)) + + log.Println("Storage server started on :8080") + log.Println("Upload: POST /upload") + log.Println("View: GET /file?key=images/test.jpg") + log.Fatal(http.ListenAndServe(":8080", mux)) + } + + // 演示MinIO存储 + minioStorage, err := storage.NewStorage(storage.StorageTypeMinIO, cfg) + if err != nil { + log.Printf("Failed to create MinIO storage: %v", err) + log.Println("Note: MinIO SDK integration is required") + } else { + log.Printf("MinIO storage created: %v", minioStorage != nil) + } + + // 演示对象键生成 + objectKey1 := storage.GenerateObjectKey("images/", "test.jpg") + log.Printf("Object key 1: %s", objectKey1) + + objectKey2 := storage.GenerateObjectKeyWithDate("images", "test.jpg") + log.Printf("Object key 2: %s", objectKey2) +} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..39a4f3c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/go-common + +go 1.21 + +require ( + gorm.io/driver/mysql v1.5.2 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7a5c143 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= +gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/http/request.go b/http/request.go new file mode 100644 index 0000000..5044c3d --- /dev/null +++ b/http/request.go @@ -0,0 +1,207 @@ +package http + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + + "github.com/go-common/middleware" +) + +// ParseJSON 解析JSON请求体 +// r: HTTP请求 +// v: 目标结构体指针 +func ParseJSON(r *http.Request, v interface{}) error { + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + defer r.Body.Close() + + if len(body) == 0 { + return nil + } + + return json.Unmarshal(body, v) +} + +// GetQuery 获取查询参数 +// r: HTTP请求 +// key: 参数名 +// defaultValue: 默认值 +func GetQuery(r *http.Request, key, defaultValue string) string { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + return value +} + +// GetQueryInt 获取整数查询参数 +// r: HTTP请求 +// key: 参数名 +// defaultValue: 默认值 +func GetQueryInt(r *http.Request, key string, defaultValue int) int { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + + intValue, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + + return intValue +} + +// GetQueryInt64 获取int64查询参数 +func GetQueryInt64(r *http.Request, key string, defaultValue int64) int64 { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return defaultValue + } + + return intValue +} + +// GetQueryBool 获取布尔查询参数 +func GetQueryBool(r *http.Request, key string, defaultValue bool) bool { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + + boolValue, err := strconv.ParseBool(value) + if err != nil { + return defaultValue + } + + return boolValue +} + +// GetQueryFloat64 获取float64查询参数 +func GetQueryFloat64(r *http.Request, key string, defaultValue float64) float64 { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + + floatValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return defaultValue + } + + return floatValue +} + +// GetFormValue 获取表单值 +func GetFormValue(r *http.Request, key, defaultValue string) string { + value := r.FormValue(key) + if value == "" { + return defaultValue + } + return value +} + +// GetFormInt 获取表单整数 +func GetFormInt(r *http.Request, key string, defaultValue int) int { + value := r.FormValue(key) + if value == "" { + return defaultValue + } + + intValue, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + + return intValue +} + +// GetFormInt64 获取表单int64 +func GetFormInt64(r *http.Request, key string, defaultValue int64) int64 { + value := r.FormValue(key) + if value == "" { + return defaultValue + } + + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return defaultValue + } + + return intValue +} + +// GetFormBool 获取表单布尔值 +func GetFormBool(r *http.Request, key string, defaultValue bool) bool { + value := r.FormValue(key) + if value == "" { + return defaultValue + } + + boolValue, err := strconv.ParseBool(value) + if err != nil { + return defaultValue + } + + return boolValue +} + +// GetHeader 获取请求头 +func GetHeader(r *http.Request, key, defaultValue string) string { + value := r.Header.Get(key) + if value == "" { + return defaultValue + } + return value +} + +// GetPaginationParams 获取分页参数 +// 返回 page, pageSize +// 默认 page=1, pageSize=10 +func GetPaginationParams(r *http.Request) (page, pageSize int) { + page = GetQueryInt(r, "page", 1) + pageSize = GetQueryInt(r, "pageSize", 10) + + // 参数校验 + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 1000 { + pageSize = 1000 // 限制最大页面大小 + } + + return page, pageSize +} + +// GetOffset 根据页码和每页大小计算偏移量 +func GetOffset(page, pageSize int) int { + if page < 1 { + page = 1 + } + return (page - 1) * pageSize +} + +// GetTimezone 从请求的context中获取时区 +// 如果使用了middleware.Timezone中间件,可以从context中获取时区信息 +// 如果未设置,返回默认时区 AsiaShanghai +func GetTimezone(r *http.Request) string { + return middleware.GetTimezoneFromContext(r.Context()) +} + +// GetTimezoneFromContext 从context中获取时区 +func GetTimezoneFromContext(ctx context.Context) string { + return middleware.GetTimezoneFromContext(ctx) +} diff --git a/http/response.go b/http/response.go new file mode 100644 index 0000000..c9dca1b --- /dev/null +++ b/http/response.go @@ -0,0 +1,138 @@ +package http + +import ( + "encoding/json" + "net/http" + "time" +) + +// Response 标准响应结构 +type Response struct { + Code int `json:"code"` // 业务状态码,0表示成功 + Message string `json:"message"` // 响应消息 + Timestamp int64 `json:"timestamp"` // 时间戳 + Data interface{} `json:"data"` // 响应数据 +} + +// PageResponse 分页响应结构 +type PageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` + Data *PageData `json:"data"` +} + +// PageData 分页数据 +type PageData struct { + List interface{} `json:"list"` // 数据列表 + Total int64 `json:"total"` // 总记录数 + Page int `json:"page"` // 当前页码 + PageSize int `json:"pageSize"` // 每页大小 +} + +// Success 成功响应 +// data: 响应数据,可以为nil +func Success(w http.ResponseWriter, data interface{}) { + WriteJSON(w, http.StatusOK, 0, "success", data) +} + +// SuccessWithMessage 带消息的成功响应 +func SuccessWithMessage(w http.ResponseWriter, message string, data interface{}) { + WriteJSON(w, http.StatusOK, 0, message, data) +} + +// Error 错误响应 +// code: 业务错误码,非0表示业务错误 +// message: 错误消息 +func Error(w http.ResponseWriter, code int, message string) { + WriteJSON(w, http.StatusOK, code, message, nil) +} + +// SystemError 系统错误响应(返回HTTP 500) +// message: 错误消息 +func SystemError(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusInternalServerError, 500, message, nil) +} + +// BadRequest 请求错误响应(HTTP 400) +func BadRequest(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusBadRequest, 400, message, nil) +} + +// Unauthorized 未授权响应(HTTP 401) +func Unauthorized(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusUnauthorized, 401, message, nil) +} + +// Forbidden 禁止访问响应(HTTP 403) +func Forbidden(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusForbidden, 403, message, nil) +} + +// NotFound 未找到响应(HTTP 404) +func NotFound(w http.ResponseWriter, message string) { + WriteJSON(w, http.StatusNotFound, 404, message, nil) +} + +// WriteJSON 写入JSON响应 +// httpCode: HTTP状态码(200表示正常,500表示系统错误等) +// code: 业务状态码(0表示成功,非0表示业务错误) +// message: 响应消息 +// data: 响应数据 +func WriteJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(httpCode) + + response := Response{ + Code: code, + Message: message, + Timestamp: time.Now().Unix(), + Data: data, + } + + json.NewEncoder(w).Encode(response) +} + +// SuccessPage 分页成功响应 +// list: 数据列表 +// total: 总记录数 +// page: 当前页码 +// pageSize: 每页大小 +func SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + + response := PageResponse{ + Code: 0, + Message: "success", + Timestamp: time.Now().Unix(), + Data: &PageData{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }, + } + + json.NewEncoder(w).Encode(response) +} + +// SuccessPageWithMessage 带消息的分页成功响应 +func SuccessPageWithMessage(w http.ResponseWriter, message string, list interface{}, total int64, page, pageSize int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + + response := PageResponse{ + Code: 0, + Message: message, + Timestamp: time.Now().Unix(), + Data: &PageData{ + List: list, + Total: total, + Page: page, + PageSize: pageSize, + }, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/middleware/chain.go b/middleware/chain.go new file mode 100644 index 0000000..1810d96 --- /dev/null +++ b/middleware/chain.go @@ -0,0 +1,36 @@ +package middleware + +import "net/http" + +// Chain 中间件链 +type Chain struct { + middlewares []func(http.Handler) http.Handler +} + +// NewChain 创建新的中间件链 +func NewChain(middlewares ...func(http.Handler) http.Handler) *Chain { + return &Chain{ + middlewares: middlewares, + } +} + +// Then 将中间件链应用到处理器 +func (c *Chain) Then(handler http.Handler) http.Handler { + final := handler + for i := len(c.middlewares) - 1; i >= 0; i-- { + final = c.middlewares[i](final) + } + return final +} + +// ThenFunc 将中间件链应用到处理器函数 +func (c *Chain) ThenFunc(handler http.HandlerFunc) http.Handler { + return c.Then(handler) +} + +// Append 追加中间件 +func (c *Chain) Append(middlewares ...func(http.Handler) http.Handler) *Chain { + c.middlewares = append(c.middlewares, middlewares...) + return c +} + diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..4d56835 --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,207 @@ +package middleware + +import ( + "net/http" + "strconv" + "strings" +) + +// CORSConfig CORS配置 +type CORSConfig struct { + // AllowedOrigins 允许的源,支持通配符 "*" 表示允许所有源 + // 例如: []string{"*"} 或 []string{"https://example.com", "https://app.example.com"} + AllowedOrigins []string + + // AllowedMethods 允许的HTTP方法 + // 默认: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} + AllowedMethods []string + + // AllowedHeaders 允许的请求头 + // 默认: []string{"Content-Type", "Authorization", "X-Requested-With", "X-Timezone"} + AllowedHeaders []string + + // ExposedHeaders 暴露给客户端的响应头 + ExposedHeaders []string + + // AllowCredentials 是否允许发送凭证(cookies等) + AllowCredentials bool + + // MaxAge 预检请求的缓存时间(秒) + // 默认: 86400 (24小时) + MaxAge int +} + +// DefaultCORSConfig 返回默认的CORS配置 +func DefaultCORSConfig() *CORSConfig { + return &CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "X-Timezone"}, + ExposedHeaders: []string{}, + AllowCredentials: false, + MaxAge: 86400, + } +} + +// CORS CORS中间件 +func CORS(config ...*CORSConfig) func(http.Handler) http.Handler { + var cfg *CORSConfig + if len(config) > 0 && config[0] != nil { + cfg = config[0] + } else { + cfg = DefaultCORSConfig() + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // 处理预检请求 + if r.Method == http.MethodOptions { + handlePreflight(w, r, cfg, origin) + return + } + + // 处理实际请求 + handleCORSHeaders(w, r, cfg, origin) + next.ServeHTTP(w, r) + }) + } +} + +// handlePreflight 处理预检请求 +func handlePreflight(w http.ResponseWriter, r *http.Request, cfg *CORSConfig, origin string) { + // 检查源是否允许 + if !isOriginAllowed(origin, cfg.AllowedOrigins) { + w.WriteHeader(http.StatusForbidden) + return + } + + // 设置CORS响应头 + setCORSHeaders(w, cfg, origin) + + // 检查请求的方法是否允许 + requestMethod := r.Header.Get("Access-Control-Request-Method") + if requestMethod != "" && !isMethodAllowed(requestMethod, cfg.AllowedMethods) { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // 检查请求头是否允许 + requestHeaders := r.Header.Get("Access-Control-Request-Headers") + if requestHeaders != "" { + headers := strings.Split(strings.ToLower(requestHeaders), ",") + for _, header := range headers { + header = strings.TrimSpace(header) + if !isHeaderAllowed(header, cfg.AllowedHeaders) { + w.WriteHeader(http.StatusForbidden) + return + } + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// handleCORSHeaders 处理实际请求的CORS头 +func handleCORSHeaders(w http.ResponseWriter, r *http.Request, cfg *CORSConfig, origin string) { + // 检查源是否允许 + if !isOriginAllowed(origin, cfg.AllowedOrigins) { + return + } + + // 设置CORS响应头 + setCORSHeaders(w, cfg, origin) +} + +// setCORSHeaders 设置CORS响应头 +func setCORSHeaders(w http.ResponseWriter, cfg *CORSConfig, origin string) { + // Access-Control-Allow-Origin + if len(cfg.AllowedOrigins) == 1 && cfg.AllowedOrigins[0] == "*" { + if !cfg.AllowCredentials { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + // 如果允许凭证,不能使用 "*",必须返回具体的源 + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + } + } else { + if isOriginAllowed(origin, cfg.AllowedOrigins) { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + } + + // Access-Control-Allow-Methods + if len(cfg.AllowedMethods) > 0 { + w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", ")) + } + + // Access-Control-Allow-Headers + if len(cfg.AllowedHeaders) > 0 { + w.Header().Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", ")) + } + + // Access-Control-Expose-Headers + if len(cfg.ExposedHeaders) > 0 { + w.Header().Set("Access-Control-Expose-Headers", strings.Join(cfg.ExposedHeaders, ", ")) + } + + // Access-Control-Allow-Credentials + if cfg.AllowCredentials { + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + // Access-Control-Max-Age + if cfg.MaxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } +} + +// isOriginAllowed 检查源是否允许 +func isOriginAllowed(origin string, allowedOrigins []string) bool { + if origin == "" { + return false + } + + for _, allowed := range allowedOrigins { + if allowed == "*" { + return true + } + if allowed == origin { + return true + } + // 支持简单的通配符匹配(如 "*.example.com") + if strings.HasPrefix(allowed, "*.") { + domain := strings.TrimPrefix(allowed, "*.") + if strings.HasSuffix(origin, domain) { + return true + } + } + } + + return false +} + +// isMethodAllowed 检查方法是否允许 +func isMethodAllowed(method string, allowedMethods []string) bool { + method = strings.ToUpper(strings.TrimSpace(method)) + for _, allowed := range allowedMethods { + if strings.ToUpper(allowed) == method { + return true + } + } + return false +} + +// isHeaderAllowed 检查请求头是否允许 +func isHeaderAllowed(header string, allowedHeaders []string) bool { + header = strings.ToLower(strings.TrimSpace(header)) + for _, allowed := range allowedHeaders { + if strings.ToLower(allowed) == header { + return true + } + } + return false +} + diff --git a/middleware/timezone.go b/middleware/timezone.go new file mode 100644 index 0000000..24eb16a --- /dev/null +++ b/middleware/timezone.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/go-common/datetime" +) + +// TimezoneKey context中存储时区的key +type timezoneKey struct{} + +// TimezoneHeaderName 时区请求头名称 +const TimezoneHeaderName = "X-Timezone" + +// DefaultTimezone 默认时区 +const DefaultTimezone = datetime.AsiaShanghai + +// GetTimezoneFromContext 从context中获取时区 +func GetTimezoneFromContext(ctx context.Context) string { + if tz, ok := ctx.Value(timezoneKey{}).(string); ok && tz != "" { + return tz + } + return DefaultTimezone +} + +// Timezone 时区处理中间件 +// 从请求头 X-Timezone 读取时区信息,如果未传递则使用默认时区 AsiaShanghai +// 时区信息会存储到context中,可以通过 GetTimezoneFromContext 获取 +func Timezone(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 从请求头获取时区 + timezone := r.Header.Get(TimezoneHeaderName) + + // 如果未传递时区信息,使用默认时区 + if timezone == "" { + timezone = DefaultTimezone + } + + // 验证时区是否有效 + if _, err := datetime.GetLocation(timezone); err != nil { + // 如果时区无效,使用默认时区 + timezone = DefaultTimezone + } + + // 将时区存储到context中 + ctx := context.WithValue(r.Context(), timezoneKey{}, timezone) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// TimezoneWithDefault 时区处理中间件(可自定义默认时区) +// defaultTimezone: 默认时区,如果未指定则使用 AsiaShanghai +func TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler { + // 验证默认时区是否有效 + if _, err := datetime.GetLocation(defaultTimezone); err != nil { + defaultTimezone = DefaultTimezone + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 从请求头获取时区 + timezone := r.Header.Get(TimezoneHeaderName) + + // 如果未传递时区信息,使用指定的默认时区 + if timezone == "" { + timezone = defaultTimezone + } + + // 验证时区是否有效 + if _, err := datetime.GetLocation(timezone); err != nil { + // 如果时区无效,使用默认时区 + timezone = defaultTimezone + } + + // 将时区存储到context中 + ctx := context.WithValue(r.Context(), timezoneKey{}, timezone) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + diff --git a/migration/migration.go b/migration/migration.go new file mode 100644 index 0000000..befec3d --- /dev/null +++ b/migration/migration.go @@ -0,0 +1,373 @@ +package migration + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +// Migration 表示一个数据库迁移 +type Migration struct { + Version string + Description string + Up func(*gorm.DB) error + Down func(*gorm.DB) error +} + +// Migrator 数据库迁移器 +type Migrator struct { + db *gorm.DB + migrations []Migration + tableName string +} + +// NewMigrator 创建新的迁移器 +// db: GORM数据库连接 +// tableName: 存储迁移记录的表名,默认为 "schema_migrations" +func NewMigrator(db *gorm.DB, tableName ...string) *Migrator { + table := "schema_migrations" + if len(tableName) > 0 && tableName[0] != "" { + table = tableName[0] + } + + return &Migrator{ + db: db, + migrations: make([]Migration, 0), + tableName: table, + } +} + +// AddMigration 添加迁移 +func (m *Migrator) AddMigration(migration Migration) { + m.migrations = append(m.migrations, migration) +} + +// AddMigrations 批量添加迁移 +func (m *Migrator) AddMigrations(migrations ...Migration) { + m.migrations = append(m.migrations, migrations...) +} + +// initTable 初始化迁移记录表 +func (m *Migrator) initTable() error { + // 检查表是否存在 + var exists bool + err := m.db.Raw(fmt.Sprintf(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = CURRENT_SCHEMA() + AND table_name = '%s' + ) + `, m.tableName)).Scan(&exists).Error + + if err != nil { + // 如果查询失败,可能是SQLite或其他数据库,尝试直接创建 + exists = false + } + + if !exists { + // 创建迁移记录表 + err = m.db.Exec(fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + version VARCHAR(255) PRIMARY KEY, + description VARCHAR(255), + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `, m.tableName)).Error + if err != nil { + return fmt.Errorf("failed to create migration table: %w", err) + } + } + + return nil +} + +// getAppliedMigrations 获取已应用的迁移版本 +func (m *Migrator) getAppliedMigrations() (map[string]bool, error) { + if err := m.initTable(); err != nil { + return nil, err + } + + var versions []string + err := m.db.Table(m.tableName).Select("version").Pluck("version", &versions).Error + if err != nil { + return nil, fmt.Errorf("failed to get applied migrations: %w", err) + } + + applied := make(map[string]bool) + for _, v := range versions { + applied[v] = true + } + + return applied, nil +} + +// recordMigration 记录迁移 +func (m *Migrator) recordMigration(version, description string, isUp bool) error { + if err := m.initTable(); err != nil { + return err + } + + if isUp { + // 记录迁移 + err := m.db.Exec(fmt.Sprintf(` + INSERT INTO %s (version, description, applied_at) + VALUES (?, ?, ?) + `, m.tableName), version, description, time.Now()).Error + if err != nil { + return fmt.Errorf("failed to record migration: %w", err) + } + } else { + // 删除迁移记录 + err := m.db.Exec(fmt.Sprintf("DELETE FROM %s WHERE version = ?", m.tableName), version).Error + if err != nil { + return fmt.Errorf("failed to remove migration record: %w", err) + } + } + + return nil +} + +// Up 执行所有未应用的迁移 +func (m *Migrator) Up() error { + if err := m.initTable(); err != nil { + return err + } + + applied, err := m.getAppliedMigrations() + if err != nil { + return err + } + + // 排序迁移 + sort.Slice(m.migrations, func(i, j int) bool { + return m.migrations[i].Version < m.migrations[j].Version + }) + + // 执行未应用的迁移 + for _, migration := range m.migrations { + if applied[migration.Version] { + continue + } + + if migration.Up == nil { + return fmt.Errorf("migration %s has no Up function", migration.Version) + } + + // 开始事务 + tx := m.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + + // 执行迁移 + if err := migration.Up(tx); err != nil { + tx.Rollback() + return fmt.Errorf("failed to apply migration %s: %w", migration.Version, err) + } + + // 记录迁移 + if err := m.recordMigrationWithDB(tx, migration.Version, migration.Description, true); err != nil { + tx.Rollback() + return err + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit migration %s: %w", migration.Version, err) + } + + fmt.Printf("Applied migration: %s - %s\n", migration.Version, migration.Description) + } + + return nil +} + +// Down 回滚最后一个迁移 +func (m *Migrator) Down() error { + if err := m.initTable(); err != nil { + return err + } + + applied, err := m.getAppliedMigrations() + if err != nil { + return err + } + + // 排序迁移(倒序) + sort.Slice(m.migrations, func(i, j int) bool { + return m.migrations[i].Version > m.migrations[j].Version + }) + + // 找到最后一个已应用的迁移并回滚 + for _, migration := range m.migrations { + if !applied[migration.Version] { + continue + } + + if migration.Down == nil { + return fmt.Errorf("migration %s has no Down function", migration.Version) + } + + // 开始事务 + tx := m.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + + // 执行回滚 + if err := migration.Down(tx); err != nil { + tx.Rollback() + return fmt.Errorf("failed to rollback migration %s: %w", migration.Version, err) + } + + // 删除迁移记录 + if err := m.recordMigrationWithDB(tx, migration.Version, migration.Description, false); err != nil { + tx.Rollback() + return err + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit rollback %s: %w", migration.Version, err) + } + + fmt.Printf("Rolled back migration: %s - %s\n", migration.Version, migration.Description) + return nil + } + + return fmt.Errorf("no migrations to rollback") +} + +// recordMigrationWithDB 使用指定的数据库连接记录迁移 +func (m *Migrator) recordMigrationWithDB(db *gorm.DB, version, description string, isUp bool) error { + if isUp { + err := db.Exec(fmt.Sprintf(` + INSERT INTO %s (version, description, applied_at) + VALUES (?, ?, ?) + `, m.tableName), version, description, time.Now()).Error + if err != nil { + return fmt.Errorf("failed to record migration: %w", err) + } + } else { + err := db.Exec(fmt.Sprintf("DELETE FROM %s WHERE version = ?", m.tableName), version).Error + if err != nil { + return fmt.Errorf("failed to remove migration record: %w", err) + } + } + + return nil +} + +// Status 查看迁移状态 +func (m *Migrator) Status() ([]MigrationStatus, error) { + if err := m.initTable(); err != nil { + return nil, err + } + + applied, err := m.getAppliedMigrations() + if err != nil { + return nil, err + } + + // 排序迁移 + sort.Slice(m.migrations, func(i, j int) bool { + return m.migrations[i].Version < m.migrations[j].Version + }) + + status := make([]MigrationStatus, 0, len(m.migrations)) + for _, migration := range m.migrations { + status = append(status, MigrationStatus{ + Version: migration.Version, + Description: migration.Description, + Applied: applied[migration.Version], + }) + } + + return status, nil +} + +// MigrationStatus 迁移状态 +type MigrationStatus struct { + Version string + Description string + Applied bool +} + +// LoadMigrationsFromFiles 从文件系统加载迁移文件 +// dir: 迁移文件目录 +// pattern: 文件命名模式,例如 "*.sql" 或 "*.up.sql" +// 文件命名格式: {version}_{description}.sql 或 {version}_{description}.up.sql +func LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) { + files, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + return nil, fmt.Errorf("failed to glob migration files: %w", err) + } + + migrations := make([]Migration, 0) + for _, file := range files { + baseName := filepath.Base(file) + // 移除扩展名 + nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + // 移除 .up 后缀(如果存在) + nameWithoutExt = strings.TrimSuffix(nameWithoutExt, ".up") + + // 解析版本号和描述 + parts := strings.SplitN(nameWithoutExt, "_", 2) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid migration file name format: %s (expected: {version}_{description})", baseName) + } + + version := parts[0] + description := strings.Join(parts[1:], "_") + + // 读取文件内容 + content, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read migration file %s: %w", file, err) + } + + sqlContent := string(content) + + // 查找对应的 down 文件 + downFile := strings.Replace(file, ".up.sql", ".down.sql", 1) + downFile = strings.Replace(downFile, ".sql", ".down.sql", 1) + var downSQL string + if downContent, err := os.ReadFile(downFile); err == nil { + downSQL = string(downContent) + } + + migration := Migration{ + Version: version, + Description: description, + Up: func(db *gorm.DB) error { + return db.Exec(sqlContent).Error + }, + } + + if downSQL != "" { + migration.Down = func(db *gorm.DB) error { + return db.Exec(downSQL).Error + } + } + + migrations = append(migrations, migration) + } + + // 按版本号排序 + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].Version < migrations[j].Version + }) + + return migrations, nil +} + +// GenerateVersion 生成迁移版本号(基于时间戳) +func GenerateVersion() string { + return strconv.FormatInt(time.Now().Unix(), 10) +} diff --git a/sms/sms.go b/sms/sms.go new file mode 100644 index 0000000..c2fac94 --- /dev/null +++ b/sms/sms.go @@ -0,0 +1,290 @@ +package sms + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/go-common/config" +) + +// SMS 短信发送器 +type SMS struct { + config *config.SMSConfig +} + +// NewSMS 创建短信发送器 +func NewSMS(cfg *config.SMSConfig) (*SMS, error) { + if cfg == nil { + return nil, fmt.Errorf("SMS config is nil") + } + + if cfg.AccessKeyID == "" { + return nil, fmt.Errorf("AccessKeyID is required") + } + + if cfg.AccessKeySecret == "" { + return nil, fmt.Errorf("AccessKeySecret is required") + } + + if cfg.SignName == "" { + return nil, fmt.Errorf("SignName is required") + } + + // 设置默认值 + if cfg.Region == "" { + cfg.Region = "cn-hangzhou" + } + if cfg.Timeout == 0 { + cfg.Timeout = 10 + } + + return &SMS{ + config: cfg, + }, nil +} + +// SendRequest 发送短信请求 +type SendRequest struct { + // PhoneNumbers 手机号列表 + PhoneNumbers []string + + // TemplateCode 模板代码(如果为空,使用配置中的模板代码) + TemplateCode string + + // TemplateParam 模板参数(可以是map或JSON字符串) + // 如果是map,会自动转换为JSON字符串 + // 如果是string,直接使用(必须是有效的JSON字符串) + TemplateParam interface{} + + // SignName 签名(如果为空,使用配置中的签名) + SignName string +} + +// SendResponse 发送短信响应 +type SendResponse struct { + // RequestID 请求ID + RequestID string `json:"RequestId"` + + // Code 响应码 + Code string `json:"Code"` + + // Message 响应消息 + Message string `json:"Message"` + + // BizID 业务ID + BizID string `json:"BizId"` +} + +// SendRaw 发送原始请求(允许外部完全控制请求参数) +// params: 请求参数map,工具只负责添加必要的系统参数(如签名、时间戳等)并发送 +func (s *SMS) SendRaw(params map[string]string) (*SendResponse, error) { + if params == nil { + params = make(map[string]string) + } + + // 确保必要的系统参数存在 + if params["Action"] == "" { + params["Action"] = "SendSms" + } + if params["Version"] == "" { + params["Version"] = "2017-05-25" + } + if params["RegionId"] == "" { + params["RegionId"] = s.config.Region + } + if params["AccessKeyId"] == "" { + params["AccessKeyId"] = s.config.AccessKeyID + } + if params["Format"] == "" { + params["Format"] = "JSON" + } + if params["SignatureMethod"] == "" { + params["SignatureMethod"] = "HMAC-SHA1" + } + if params["SignatureVersion"] == "" { + params["SignatureVersion"] = "1.0" + } + if params["SignatureNonce"] == "" { + params["SignatureNonce"] = fmt.Sprint(time.Now().UnixNano()) + } + if params["Timestamp"] == "" { + params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z") + } + + // 计算签名 + signature := s.calculateSignature(params, "POST") + params["Signature"] = signature + + // 构建请求URL + endpoint := s.config.Endpoint + if endpoint == "" { + endpoint = "https://dysmsapi.aliyuncs.com" + } + + // 发送HTTP请求 + formData := url.Values{} + for k, v := range params { + formData.Set(k, v) + } + + httpReq, err := http.NewRequest("POST", endpoint, strings.NewReader(formData.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: time.Duration(s.config.Timeout) * time.Second, + } + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // 解析响应 + var sendResp SendResponse + if err := json.Unmarshal(body, &sendResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // 检查响应码 + if sendResp.Code != "OK" { + return &sendResp, fmt.Errorf("SMS send failed: Code=%s, Message=%s", sendResp.Code, sendResp.Message) + } + + return &sendResp, nil +} + +// Send 发送短信(使用SendRequest结构) +// 注意:如果需要完全控制请求参数,请使用SendRaw方法 +func (s *SMS) Send(req *SendRequest) (*SendResponse, error) { + if req == nil { + return nil, fmt.Errorf("request is nil") + } + + if len(req.PhoneNumbers) == 0 { + return nil, fmt.Errorf("phone numbers are required") + } + + // 使用配置中的模板代码和签名(如果请求中未指定) + templateCode := req.TemplateCode + if templateCode == "" { + templateCode = s.config.TemplateCode + } + if templateCode == "" { + return nil, fmt.Errorf("template code is required") + } + + signName := req.SignName + if signName == "" { + signName = s.config.SignName + } + + // 处理模板参数 + var templateParamJSON string + if req.TemplateParam != nil { + switch v := req.TemplateParam.(type) { + case string: + // 直接使用字符串(必须是有效的JSON) + templateParamJSON = v + case map[string]string: + // 转换为JSON字符串 + paramBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal template param: %w", err) + } + templateParamJSON = string(paramBytes) + default: + // 尝试JSON序列化 + paramBytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal template param: %w", err) + } + templateParamJSON = string(paramBytes) + } + } else { + templateParamJSON = "{}" + } + + // 构建请求参数 + params := make(map[string]string) + params["PhoneNumbers"] = strings.Join(req.PhoneNumbers, ",") + params["SignName"] = signName + params["TemplateCode"] = templateCode + params["TemplateParam"] = templateParamJSON + + // 使用SendRaw发送 + return s.SendRaw(params) +} + +// calculateSignature 计算签名 +func (s *SMS) calculateSignature(params map[string]string, method string) string { + // 对参数进行排序 + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + // 构建查询字符串 + var queryParts []string + for _, k := range keys { + v := params[k] + // URL编码 + encodedKey := url.QueryEscape(k) + encodedValue := url.QueryEscape(v) + queryParts = append(queryParts, encodedKey+"="+encodedValue) + } + queryString := strings.Join(queryParts, "&") + + // 构建待签名字符串 + stringToSign := method + "&" + url.QueryEscape("/") + "&" + url.QueryEscape(queryString) + + // 计算HMAC-SHA1签名 + mac := hmac.New(sha1.New, []byte(s.config.AccessKeySecret+"&")) + mac.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return signature +} + +// SendSimple 发送简单短信(便捷方法) +// phoneNumbers: 手机号列表 +// templateParam: 模板参数 +func (s *SMS) SendSimple(phoneNumbers []string, templateParam map[string]string) (*SendResponse, error) { + return s.Send(&SendRequest{ + PhoneNumbers: phoneNumbers, + TemplateParam: templateParam, + }) +} + +// SendWithTemplate 使用指定模板发送短信(便捷方法) +// phoneNumbers: 手机号列表 +// templateCode: 模板代码 +// templateParam: 模板参数 +func (s *SMS) SendWithTemplate(phoneNumbers []string, templateCode string, templateParam map[string]string) (*SendResponse, error) { + return s.Send(&SendRequest{ + PhoneNumbers: phoneNumbers, + TemplateCode: templateCode, + TemplateParam: templateParam, + }) +} diff --git a/storage/handler.go b/storage/handler.go new file mode 100644 index 0000000..ae6adbf --- /dev/null +++ b/storage/handler.go @@ -0,0 +1,212 @@ +package storage + +import ( + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + commonhttp "github.com/go-common/http" +) + +// UploadHandler 文件上传处理器 +type UploadHandler struct { + storage Storage + maxFileSize int64 // 最大文件大小(字节),0表示不限制 + allowedExts []string // 允许的文件扩展名,空表示不限制 + objectPrefix string // 对象键前缀 +} + +// UploadHandlerConfig 上传处理器配置 +type UploadHandlerConfig struct { + Storage Storage + MaxFileSize int64 // 最大文件大小(字节),0表示不限制 + AllowedExts []string // 允许的文件扩展名,空表示不限制 + ObjectPrefix string // 对象键前缀(如 "images/", "files/") +} + +// NewUploadHandler 创建文件上传处理器 +func NewUploadHandler(cfg UploadHandlerConfig) *UploadHandler { + return &UploadHandler{ + storage: cfg.Storage, + maxFileSize: cfg.MaxFileSize, + allowedExts: cfg.AllowedExts, + objectPrefix: cfg.ObjectPrefix, + } +} + +// ServeHTTP 处理文件上传请求 +// 请求方式: POST +// 表单字段: file (文件) +// 可选字段: prefix (对象键前缀,会覆盖配置中的前缀) +func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + commonhttp.NotFound(w, "Method not allowed") + return + } + + // 解析multipart表单 + err := r.ParseMultipartForm(h.maxFileSize) + if err != nil { + commonhttp.BadRequest(w, fmt.Sprintf("Failed to parse form: %v", err)) + return + } + + // 获取文件 + file, header, err := r.FormFile("file") + if err != nil { + commonhttp.BadRequest(w, fmt.Sprintf("Failed to get file: %v", err)) + return + } + defer file.Close() + + // 检查文件大小 + if h.maxFileSize > 0 && header.Size > h.maxFileSize { + commonhttp.Error(w, 1001, fmt.Sprintf("File size exceeds limit: %d bytes", h.maxFileSize)) + return + } + + // 检查文件扩展名 + if len(h.allowedExts) > 0 { + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowed := false + for _, allowedExt := range h.allowedExts { + if strings.ToLower(allowedExt) == ext { + allowed = true + break + } + } + if !allowed { + commonhttp.Error(w, 1002, fmt.Sprintf("File extension not allowed. Allowed: %v", h.allowedExts)) + return + } + } + + // 生成对象键 + prefix := h.objectPrefix + if r.FormValue("prefix") != "" { + prefix = r.FormValue("prefix") + } + + // 生成唯一文件名 + filename := generateUniqueFilename(header.Filename) + objectKey := GenerateObjectKey(prefix, filename) + + // 获取文件类型 + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = mime.TypeByExtension(filepath.Ext(header.Filename)) + if contentType == "" { + contentType = "application/octet-stream" + } + } + + // 上传文件 + ctx := r.Context() + err = h.storage.Upload(ctx, objectKey, file, contentType) + if err != nil { + commonhttp.SystemError(w, fmt.Sprintf("Failed to upload file: %v", err)) + return + } + + // 获取文件URL + fileURL, err := h.storage.GetURL(objectKey, 0) + if err != nil { + commonhttp.SystemError(w, fmt.Sprintf("Failed to get file URL: %v", err)) + return + } + + // 返回结果 + result := UploadResult{ + ObjectKey: objectKey, + URL: fileURL, + Size: header.Size, + ContentType: contentType, + UploadTime: time.Now(), + } + + commonhttp.SuccessWithMessage(w, "Upload successful", result) +} + +// generateUniqueFilename 生成唯一文件名 +func generateUniqueFilename(originalFilename string) string { + ext := filepath.Ext(originalFilename) + name := strings.TrimSuffix(originalFilename, ext) + timestamp := time.Now().UnixNano() + return fmt.Sprintf("%s_%d%s", name, timestamp, ext) +} + +// ProxyHandler 文件代理查看处理器 +type ProxyHandler struct { + storage Storage +} + +// NewProxyHandler 创建文件代理查看处理器 +func NewProxyHandler(storage Storage) *ProxyHandler { + return &ProxyHandler{ + storage: storage, + } +} + +// ServeHTTP 处理文件查看请求 +// URL参数: key (对象键) +func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + commonhttp.NotFound(w, "Method not allowed") + return + } + + // 获取对象键 + objectKey := r.URL.Query().Get("key") + if objectKey == "" { + commonhttp.BadRequest(w, "Missing parameter: key") + return + } + + // 检查文件是否存在 + ctx := r.Context() + exists, err := h.storage.Exists(ctx, objectKey) + if err != nil { + commonhttp.SystemError(w, fmt.Sprintf("Failed to check file existence: %v", err)) + return + } + + if !exists { + commonhttp.NotFound(w, "File not found") + return + } + + // 获取文件内容 + reader, err := h.storage.GetObject(ctx, objectKey) + if err != nil { + commonhttp.SystemError(w, fmt.Sprintf("Failed to get file: %v", err)) + return + } + defer reader.Close() + + // 设置响应头 + ext := filepath.Ext(objectKey) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + // 如果是图片,设置适当的Content-Type + if strings.HasPrefix(contentType, "image/") { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=31536000") // 缓存1年 + } else { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(objectKey))) + } + + // 复制文件内容到响应 + _, err = io.Copy(w, reader) + if err != nil { + commonhttp.SystemError(w, fmt.Sprintf("Failed to write response: %v", err)) + return + } +} diff --git a/storage/minio.go b/storage/minio.go new file mode 100644 index 0000000..e01c16b --- /dev/null +++ b/storage/minio.go @@ -0,0 +1,144 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/go-common/config" +) + +// MinIOStorage MinIO存储实现 +type MinIOStorage struct { + config *config.MinIOConfig + // client 存储MinIO客户端(实际使用时需要根据具体的MinIO SDK实现) + // 这里使用interface{},实际使用时需要替换为具体的客户端类型 + client interface{} +} + +// NewMinIOStorage 创建MinIO存储实例 +func NewMinIOStorage(cfg *config.MinIOConfig) (*MinIOStorage, error) { + if cfg == nil { + return nil, fmt.Errorf("MinIO config is nil") + } + + storage := &MinIOStorage{ + config: cfg, + } + + // 初始化MinIO客户端 + // 注意:这里需要根据实际的MinIO SDK实现 + // 例如使用MinIO Go SDK: + // client, err := minio.New(cfg.Endpoint, &minio.Options{ + // Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + // Secure: cfg.UseSSL, + // }) + // if err != nil { + // return nil, fmt.Errorf("failed to create MinIO client: %w", err) + // } + // storage.client = client + + return storage, nil +} + +// Upload 上传文件到MinIO +func (s *MinIOStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error { + // 实现MinIO上传逻辑 + // 注意:这里需要根据实际的MinIO SDK实现 + // 示例(使用MinIO Go SDK): + // ct := "application/octet-stream" + // if len(contentType) > 0 && contentType[0] != "" { + // ct = contentType[0] + // } + // + // _, err := s.client.PutObject(ctx, s.config.Bucket, objectKey, reader, -1, minio.PutObjectOptions{ + // ContentType: ct, + // }) + // if err != nil { + // return fmt.Errorf("failed to upload object: %w", err) + // } + + // 当前实现返回错误,提示需要实现具体的MinIO SDK + return fmt.Errorf("MinIO upload not implemented, please implement with actual MinIO SDK") +} + +// GetURL 获取MinIO文件访问URL +func (s *MinIOStorage) GetURL(objectKey string, expires int64) (string, error) { + if s.config.Domain != "" { + // 使用自定义域名 + if strings.HasSuffix(s.config.Domain, "/") { + return s.config.Domain + objectKey, nil + } + return s.config.Domain + "/" + objectKey, nil + } + + // 使用MinIO默认域名 + protocol := "http" + if s.config.UseSSL { + protocol = "https" + } + + // 构建MinIO URL + // 格式: http://endpoint/bucket/objectKey + url := fmt.Sprintf("%s://%s/%s/%s", protocol, s.config.Endpoint, s.config.Bucket, objectKey) + + // 如果设置了过期时间,需要生成预签名URL + // 注意:这里需要根据实际的MinIO SDK实现 + // 示例(使用MinIO Go SDK): + // if expires > 0 { + // expiry := time.Duration(expires) * time.Second + // presignedURL, err := s.client.PresignedGetObject(ctx, s.config.Bucket, objectKey, expiry, nil) + // if err != nil { + // return "", err + // } + // return presignedURL.String(), nil + // } + + return url, nil +} + +// Delete 删除MinIO文件 +func (s *MinIOStorage) Delete(ctx context.Context, objectKey string) error { + // 实现MinIO删除逻辑 + // 注意:这里需要根据实际的MinIO SDK实现 + // 示例(使用MinIO Go SDK): + // err := s.client.RemoveObject(ctx, s.config.Bucket, objectKey, minio.RemoveObjectOptions{}) + // if err != nil { + // return fmt.Errorf("failed to delete object: %w", err) + // } + + return fmt.Errorf("MinIO delete not implemented, please implement with actual MinIO SDK") +} + +// Exists 检查MinIO文件是否存在 +func (s *MinIOStorage) Exists(ctx context.Context, objectKey string) (bool, error) { + // 实现MinIO存在性检查逻辑 + // 注意:这里需要根据实际的MinIO SDK实现 + // 示例(使用MinIO Go SDK): + // _, err := s.client.StatObject(ctx, s.config.Bucket, objectKey, minio.StatObjectOptions{}) + // if err != nil { + // if minio.ToErrorResponse(err).Code == "NoSuchKey" { + // return false, nil + // } + // return false, fmt.Errorf("failed to check object existence: %w", err) + // } + // return true, nil + + return false, fmt.Errorf("MinIO exists check not implemented, please implement with actual MinIO SDK") +} + +// GetObject 获取MinIO文件内容 +func (s *MinIOStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + // 实现MinIO获取对象逻辑 + // 注意:这里需要根据实际的MinIO SDK实现 + // 示例(使用MinIO Go SDK): + // obj, err := s.client.GetObject(ctx, s.config.Bucket, objectKey, minio.GetObjectOptions{}) + // if err != nil { + // return nil, fmt.Errorf("failed to get object: %w", err) + // } + // return obj, nil + + return nil, fmt.Errorf("MinIO get object not implemented, please implement with actual MinIO SDK") +} + diff --git a/storage/oss.go b/storage/oss.go new file mode 100644 index 0000000..c2b9861 --- /dev/null +++ b/storage/oss.go @@ -0,0 +1,155 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/go-common/config" +) + +// OSSStorage OSS存储实现 +type OSSStorage struct { + config *config.OSSConfig + // client 存储OSS客户端(实际使用时需要根据具体的OSS SDK实现) + // 这里使用interface{},实际使用时需要替换为具体的客户端类型 + client interface{} +} + +// NewOSSStorage 创建OSS存储实例 +func NewOSSStorage(cfg *config.OSSConfig) (*OSSStorage, error) { + if cfg == nil { + return nil, fmt.Errorf("OSS config is nil") + } + + storage := &OSSStorage{ + config: cfg, + } + + // 初始化OSS客户端 + // 注意:这里需要根据实际的OSS SDK实现 + // 例如使用阿里云OSS SDK: + // client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret) + // if err != nil { + // return nil, fmt.Errorf("failed to create OSS client: %w", err) + // } + // storage.client = client + + return storage, nil +} + +// Upload 上传文件到OSS +func (s *OSSStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error { + // 实现OSS上传逻辑 + // 注意:这里需要根据实际的OSS SDK实现 + // 示例(使用阿里云OSS SDK): + // bucket, err := s.client.Bucket(s.config.Bucket) + // if err != nil { + // return fmt.Errorf("failed to get bucket: %w", err) + // } + // + // options := []oss.Option{} + // if len(contentType) > 0 && contentType[0] != "" { + // options = append(options, oss.ContentType(contentType[0])) + // } + // + // err = bucket.PutObject(objectKey, reader, options...) + // if err != nil { + // return fmt.Errorf("failed to upload object: %w", err) + // } + + // 当前实现返回错误,提示需要实现具体的OSS SDK + return fmt.Errorf("OSS upload not implemented, please implement with actual OSS SDK") +} + +// GetURL 获取OSS文件访问URL +func (s *OSSStorage) GetURL(objectKey string, expires int64) (string, error) { + if s.config.Domain != "" { + // 使用自定义域名 + if strings.HasSuffix(s.config.Domain, "/") { + return s.config.Domain + objectKey, nil + } + return s.config.Domain + "/" + objectKey, nil + } + + // 使用OSS默认域名 + protocol := "http" + if s.config.UseSSL { + protocol = "https" + } + + // 构建OSS URL + // 格式: https://bucket.endpoint/objectKey + url := fmt.Sprintf("%s://%s.%s/%s", protocol, s.config.Bucket, s.config.Endpoint, objectKey) + + // 如果设置了过期时间,需要生成签名URL + // 注意:这里需要根据实际的OSS SDK实现 + // 示例(使用阿里云OSS SDK): + // if expires > 0 { + // bucket, err := s.client.Bucket(s.config.Bucket) + // if err != nil { + // return "", err + // } + // signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, expires) + // if err != nil { + // return "", err + // } + // return signedURL, nil + // } + + return url, nil +} + +// Delete 删除OSS文件 +func (s *OSSStorage) Delete(ctx context.Context, objectKey string) error { + // 实现OSS删除逻辑 + // 注意:这里需要根据实际的OSS SDK实现 + // 示例(使用阿里云OSS SDK): + // bucket, err := s.client.Bucket(s.config.Bucket) + // if err != nil { + // return fmt.Errorf("failed to get bucket: %w", err) + // } + // err = bucket.DeleteObject(objectKey) + // if err != nil { + // return fmt.Errorf("failed to delete object: %w", err) + // } + + return fmt.Errorf("OSS delete not implemented, please implement with actual OSS SDK") +} + +// Exists 检查OSS文件是否存在 +func (s *OSSStorage) Exists(ctx context.Context, objectKey string) (bool, error) { + // 实现OSS存在性检查逻辑 + // 注意:这里需要根据实际的OSS SDK实现 + // 示例(使用阿里云OSS SDK): + // bucket, err := s.client.Bucket(s.config.Bucket) + // if err != nil { + // return false, fmt.Errorf("failed to get bucket: %w", err) + // } + // exists, err := bucket.IsObjectExist(objectKey) + // if err != nil { + // return false, fmt.Errorf("failed to check object existence: %w", err) + // } + // return exists, nil + + return false, fmt.Errorf("OSS exists check not implemented, please implement with actual OSS SDK") +} + +// GetObject 获取OSS文件内容 +func (s *OSSStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + // 实现OSS获取对象逻辑 + // 注意:这里需要根据实际的OSS SDK实现 + // 示例(使用阿里云OSS SDK): + // bucket, err := s.client.Bucket(s.config.Bucket) + // if err != nil { + // return nil, fmt.Errorf("failed to get bucket: %w", err) + // } + // body, err := bucket.GetObject(objectKey) + // if err != nil { + // return nil, fmt.Errorf("failed to get object: %w", err) + // } + // return body, nil + + return nil, fmt.Errorf("OSS get object not implemented, please implement with actual OSS SDK") +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..1f105e5 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,105 @@ +package storage + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/go-common/config" +) + +// Storage 存储接口 +type Storage interface { + // Upload 上传文件 + // ctx: 上下文 + // objectKey: 对象键(文件路径) + // reader: 文件内容 + // contentType: 文件类型(可选) + Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error + + // GetURL 获取文件访问URL + // objectKey: 对象键 + // expires: 过期时间(秒),0表示永久有效 + GetURL(objectKey string, expires int64) (string, error) + + // Delete 删除文件 + Delete(ctx context.Context, objectKey string) error + + // Exists 检查文件是否存在 + Exists(ctx context.Context, objectKey string) (bool, error) + + // GetObject 获取文件内容 + GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) +} + +// StorageType 存储类型 +type StorageType string + +const ( + StorageTypeOSS StorageType = "oss" + StorageTypeMinIO StorageType = "minio" +) + +// NewStorage 创建存储实例 +// storageType: 存储类型(oss或minio) +// cfg: 配置对象 +func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) { + switch storageType { + case StorageTypeOSS: + ossConfig := cfg.GetOSS() + if ossConfig == nil { + return nil, fmt.Errorf("OSS config is nil") + } + return NewOSSStorage(ossConfig) + case StorageTypeMinIO: + minioConfig := cfg.GetMinIO() + if minioConfig == nil { + return nil, fmt.Errorf("MinIO config is nil") + } + return NewMinIOStorage(minioConfig) + default: + return nil, fmt.Errorf("unsupported storage type: %s", storageType) + } +} + +// UploadResult 上传结果 +type UploadResult struct { + // ObjectKey 对象键(文件路径) + ObjectKey string `json:"objectKey"` + + // URL 文件访问URL + URL string `json:"url"` + + // Size 文件大小(字节) + Size int64 `json:"size"` + + // ContentType 文件类型 + ContentType string `json:"contentType"` + + // UploadTime 上传时间 + UploadTime time.Time `json:"uploadTime"` +} + +// GenerateObjectKey 生成对象键 +// prefix: 前缀(如 "images/", "files/") +// filename: 文件名 +func GenerateObjectKey(prefix, filename string) string { + if prefix == "" { + return filename + } + if prefix[len(prefix)-1] != '/' { + prefix += "/" + } + return prefix + filename +} + +// GenerateObjectKeyWithDate 生成带日期的对象键 +// prefix: 前缀 +// filename: 文件名 +// 格式: prefix/YYYY/MM/DD/filename +func GenerateObjectKeyWithDate(prefix, filename string) string { + now := time.Now() + datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) + return GenerateObjectKey(prefix+"/"+datePath, filename) +}