├── internal ├── types │ ├── errno │ │ ├── README.md │ │ └── system.go │ └── consts │ │ ├── i18n.go │ │ └── http.go ├── model │ ├── get_all_models.go │ ├── user │ │ └── user.go │ └── base │ │ └── base.go ├── utils │ ├── errorx │ │ ├── code │ │ │ └── register.go │ │ ├── internal │ │ │ ├── register.go │ │ │ ├── msg.go │ │ │ ├── stack.go │ │ │ └── status.go │ │ └── error_utils.go │ ├── validator │ │ └── validator_utils.go │ ├── i18n │ │ └── i18n_utils.go │ ├── sse │ │ └── stream_handler_utils.go │ ├── snowflake │ │ └── snowflake_utils.go │ ├── rate │ │ └── rate_utils.go │ ├── template │ │ └── template_utils.go │ ├── file │ │ └── json_utils.go │ ├── email │ │ └── email_utils.go │ └── vo │ │ ├── vo_utils.go │ │ └── vo_utils_test.go ├── i18n │ ├── i18n.go │ └── internal │ │ └── manager.go ├── db │ ├── internal │ │ ├── migration.go │ │ ├── manager.go │ │ ├── connection.go │ │ └── setup.go │ └── db.go ├── redis │ ├── redis.go │ └── internal │ │ └── manager.go ├── ai │ ├── internal │ │ ├── manager.go │ │ ├── prompt │ │ │ ├── types.go │ │ │ ├── manager.go │ │ │ └── manager_test.go │ │ └── provider │ │ │ ├── types.go │ │ │ ├── provider.go │ │ │ ├── openai.go │ │ │ ├── gemini.go │ │ │ └── provider_test.go │ └── ai.go ├── logger │ ├── logger.go │ └── internal │ │ └── manager.go ├── sse │ ├── sse.go │ └── internal │ │ └── manager.go ├── middleware │ ├── middleware.go │ └── cors │ │ └── cors.go └── queue │ ├── queue.go │ └── internal │ ├── producer.go │ └── consumer.go ├── main.go ├── configs ├── i18n │ ├── zh-CN.json │ └── en-US.json ├── prompts │ └── example.json ├── config.local.yml ├── config.prod.yml ├── config.local.example.yml └── config.prod.example.yml ├── .gitignore ├── pkg ├── router │ ├── router.go │ └── routes │ │ └── test_routes.go ├── serve │ ├── controller │ │ ├── dto │ │ │ └── test_dto.go │ │ └── test_controller.go │ └── service │ │ ├── test_service.go │ │ └── impl │ │ └── test_service_impl.go ├── wire │ ├── wire.go │ ├── providers.go │ └── wire_gen.go └── vo │ └── test_vo.go ├── README.md ├── cmd └── gin_server.go ├── docs ├── api-reference.zh-CN.md ├── api-reference.en-US.md ├── prompt-guide.zh-CN.md ├── prompt-guide.en-US.md ├── coding-standards.zh-CN.md └── coding-standards.en-US.md ├── go.mod ├── CLAUDE.md └── LICENSE /internal/types/errno/README.md: -------------------------------------------------------------------------------- 1 | # Error Code Allocation 2 | 3 | | Range | Module | Used | Next Available | 4 | | ----------- | ------ | ----------- | -------------- | 5 | | 10000-19999 | System | 10001-10008 | 10009 | 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the main entry point for the application 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package main 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/cmd" 8 | ) 9 | 10 | func main() { 11 | cmd.Start() 12 | } 13 | -------------------------------------------------------------------------------- /configs/i18n/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "10001": "服务器内部错误:{{.msg}}", 3 | "10002": "参数错误:{{.msg}}", 4 | "10003": "未授权访问:{{.msg}}", 5 | "10004": "权限不足:{{.resource}}", 6 | "10005": "{{.resource}}未找到:{{.id}}", 7 | "10006": "{{.resource}}已存在:{{.id}}", 8 | "10007": "请求过于频繁:{{.limit}}次每{{.period}}", 9 | "10008": "服务不可用:{{.service}}" 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Claude 2 | .claude/ 3 | .history 4 | 5 | # Env 6 | .env 7 | 8 | # Project 9 | .logs/ 10 | tmp/ 11 | /database/ 12 | configs/configs.yaml 13 | 14 | # IDE 15 | .vscode/ 16 | .idea/ 17 | 18 | # Go 19 | *.exe 20 | *.test 21 | *.out 22 | *.so 23 | *.dylib 24 | *.a 25 | vendor/ 26 | go.work 27 | go.work.sum 28 | __debug_bin 29 | 30 | # OS 31 | .DS_Store 32 | Thumbs.db -------------------------------------------------------------------------------- /internal/model/get_all_models.go: -------------------------------------------------------------------------------- 1 | // Package model provides database model definitions and management 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package model 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/internal/model/user" 8 | ) 9 | 10 | // GetAllModels gets and registers all models for database migration 11 | func GetAllModels() []any { 12 | return []any{ 13 | &user.User{}, // User model 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /configs/i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "10001": "internal server error: {{.msg}}", 3 | "10002": "invalid parameter: {{.msg}}", 4 | "10003": "unauthorized access: {{.msg}}", 5 | "10004": "permission denied: {{.resource}}", 6 | "10005": "{{.resource}} not found: {{.id}}", 7 | "10006": "{{.resource}} already exists: {{.id}}", 8 | "10007": "too many requests: {{.limit}} per {{.period}}", 9 | "10008": "service unavailable: {{.service}}" 10 | } 11 | -------------------------------------------------------------------------------- /internal/types/consts/i18n.go: -------------------------------------------------------------------------------- 1 | // Package consts provides i18n related constants 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package consts 5 | 6 | // Supported locales 7 | const ( 8 | LocaleZhCN = "zh-CN" 9 | LocaleEnUS = "en-US" 10 | LocaleDefault = LocaleZhCN 11 | ) 12 | 13 | // Context keys 14 | const ( 15 | LocalizerContextKey = "i18n.localizer" 16 | ) 17 | 18 | // File paths 19 | const ( 20 | I18nConfigPath = "configs/i18n" 21 | ) 22 | -------------------------------------------------------------------------------- /configs/prompts/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "description": "演示变量替换,system 和 user 角色都传递变量的问候示例。", 4 | "variables": { 5 | "user_name": "用户姓名", 6 | "greet_time": "问候时间", 7 | "user_message": "用户输入内容" 8 | }, 9 | "messages": [ 10 | { 11 | "role": "system", 12 | "content": "你好,{{.user_name}}!现在是{{.greet_time}},欢迎来到AI助手。" 13 | }, 14 | { 15 | "role": "user", 16 | "content": "{{.user_message}}" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /internal/utils/errorx/code/register.go: -------------------------------------------------------------------------------- 1 | // Package code provides error code registration adapter 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package code 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/internal/utils/errorx" 8 | ) 9 | 10 | // RegisterOptionFn registration option function type 11 | type RegisterOptionFn = errorx.RegisterOption 12 | 13 | // Register registers predefined error code information 14 | func Register(code int32, msg string, opts ...RegisterOptionFn) { 15 | errorx.Register(code, msg, opts...) 16 | } 17 | -------------------------------------------------------------------------------- /internal/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | // Package i18n provides internationalization management functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package i18n 5 | 6 | import ( 7 | "github.com/nicksnyder/go-i18n/v2/i18n" 8 | 9 | "github.com/Done-0/gin-scaffold/internal/i18n/internal" 10 | ) 11 | 12 | // I18nManager defines the interface for i18n management 13 | type I18nManager interface { 14 | Bundle() *i18n.Bundle 15 | Initialize() error 16 | Close() error 17 | } 18 | 19 | // New creates a new i18n manager instance 20 | func New() I18nManager { 21 | return internal.NewManager() 22 | } 23 | -------------------------------------------------------------------------------- /internal/db/internal/migration.go: -------------------------------------------------------------------------------- 1 | // Package internal provides database migration functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | 10 | "github.com/Done-0/gin-scaffold/internal/model" 11 | ) 12 | 13 | // migrate performs database auto migration 14 | func (m *Manager) migrate() error { 15 | err := m.db.AutoMigrate( 16 | model.GetAllModels()..., 17 | ) 18 | if err != nil { 19 | return fmt.Errorf("failed to auto migrate database: %w", err) 20 | } 21 | 22 | log.Println("Database auto migration succeeded") 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/db/db.go: -------------------------------------------------------------------------------- 1 | // Package db provides database management functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package db 5 | 6 | import ( 7 | "gorm.io/gorm" 8 | 9 | "github.com/Done-0/gin-scaffold/configs" 10 | "github.com/Done-0/gin-scaffold/internal/db/internal" 11 | ) 12 | 13 | // DatabaseManager defines the interface for database management operations 14 | type DatabaseManager interface { 15 | DB() *gorm.DB 16 | Initialize() error 17 | Close() error 18 | } 19 | 20 | // New creates a new database manager instance 21 | func New(config *configs.Config) DatabaseManager { 22 | return internal.NewManager(config) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/router/router.go: -------------------------------------------------------------------------------- 1 | // Package router provides application route registration functionality 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package router 5 | 6 | import ( 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/Done-0/gin-scaffold/pkg/router/routes" 10 | "github.com/Done-0/gin-scaffold/pkg/wire" 11 | ) 12 | 13 | // New registers application routes 14 | func New(r *gin.Engine, container *wire.Container) { 15 | // Create API v1 route group 16 | v1 := r.Group("/api/v1") 17 | 18 | // Create API v2 route group 19 | v2 := r.Group("/api/v2") 20 | 21 | // Register routes by modules 22 | routes.RegisterTestRoutes(container, v1, v2) 23 | } 24 | -------------------------------------------------------------------------------- /internal/redis/redis.go: -------------------------------------------------------------------------------- 1 | // Package redis provides Redis connection and management functionality 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package redis 5 | 6 | import ( 7 | "github.com/redis/go-redis/v9" 8 | 9 | "github.com/Done-0/gin-scaffold/configs" 10 | "github.com/Done-0/gin-scaffold/internal/redis/internal" 11 | ) 12 | 13 | // RedisManager defines the interface for Redis management operations 14 | type RedisManager interface { 15 | Client() *redis.Client 16 | Initialize() error 17 | Close() error 18 | } 19 | 20 | // New creates a new Redis manager instance 21 | func New(config *configs.Config) (RedisManager, error) { 22 | return internal.NewManager(config) 23 | } 24 | -------------------------------------------------------------------------------- /internal/ai/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides AI service internal implementation 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package internal 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/configs" 8 | "github.com/Done-0/gin-scaffold/internal/ai/internal/prompt" 9 | "github.com/Done-0/gin-scaffold/internal/ai/internal/provider" 10 | ) 11 | 12 | type Manager struct { 13 | provider.Provider 14 | prompt.Manager 15 | } 16 | 17 | // New creates a new AI provider manager with dynamic prompt loading 18 | func New(config *configs.Config) (*Manager, error) { 19 | return &Manager{ 20 | Provider: provider.New(), 21 | Manager: prompt.New(), 22 | }, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger provides application logging functionality initialization and configuration 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package logger 5 | 6 | import ( 7 | "github.com/sirupsen/logrus" 8 | 9 | "github.com/Done-0/gin-scaffold/configs" 10 | "github.com/Done-0/gin-scaffold/internal/logger/internal" 11 | ) 12 | 13 | // LoggerManager defines the interface for logger management operations 14 | type LoggerManager interface { 15 | Logger() *logrus.Logger 16 | Initialize() error 17 | Close() error 18 | } 19 | 20 | // New creates a new logger manager instance 21 | func New(config *configs.Config) (LoggerManager, error) { 22 | return internal.NewManager(config) 23 | } 24 | -------------------------------------------------------------------------------- /internal/sse/sse.go: -------------------------------------------------------------------------------- 1 | // Package sse provides Server-Sent Events functionality 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package sse 5 | 6 | import ( 7 | "github.com/gin-contrib/sse" 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/Done-0/gin-scaffold/configs" 11 | "github.com/Done-0/gin-scaffold/internal/sse/internal" 12 | ) 13 | 14 | // SSEManager defines SSE operations 15 | type SSEManager interface { 16 | StreamToClient(c *gin.Context, events <-chan *Event) error 17 | } 18 | 19 | // Event represents a Server-Sent Event 20 | type ( 21 | Event = sse.Event 22 | ) 23 | 24 | // New creates SSE manager 25 | func New(config *configs.Config) SSEManager { 26 | return internal.NewManager(config) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/serve/controller/dto/test_dto.go: -------------------------------------------------------------------------------- 1 | // Package dto provides test-related data transfer object definitions 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package dto 5 | 6 | // TestRedisRequest redis test request 7 | type TestRedisRequest struct { 8 | Key string `json:"key" validate:"required"` 9 | Value string `json:"value" validate:"required"` 10 | TTL int `json:"ttl" validate:"omitempty,min=1"` 11 | } 12 | 13 | // TestLongRequest long request test 14 | type TestLongRequest struct { 15 | Duration int `json:"duration" validate:"omitempty,min=1,max=10"` 16 | } 17 | 18 | // TestStreamRequest SSE 流式测试请求体 19 | type TestStreamRequest struct { 20 | Name string `json:"name" validate:"required"` 21 | } 22 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | // Package middleware provides common middleware functionality 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package middleware 5 | 6 | import ( 7 | "github.com/gin-contrib/requestid" 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/Done-0/gin-scaffold/configs" 11 | "github.com/Done-0/gin-scaffold/internal/middleware/cors" 12 | ) 13 | 14 | // New registers all middleware to the Gin engine 15 | func New(r *gin.Engine, config *configs.Config) { 16 | // Recovery middleware (should be first) 17 | r.Use(gin.Recovery()) 18 | 19 | // Request ID middleware 20 | r.Use(requestid.New()) 21 | 22 | // CORS middleware 23 | r.Use(cors.New(config)) 24 | 25 | // Logger middleware 26 | r.Use(gin.Logger()) 27 | } 28 | -------------------------------------------------------------------------------- /internal/sse/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides SSE manager implementation 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package internal 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/gin-contrib/sse" 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | type Manager struct{} 16 | 17 | func NewManager(*configs.Config) *Manager { 18 | return &Manager{} 19 | } 20 | 21 | func (m *Manager) StreamToClient(c *gin.Context, events <-chan *sse.Event) error { 22 | c.Status(http.StatusOK) 23 | c.Header("Content-Type", "text/event-stream") 24 | c.Header("Cache-Control", "no-cache") 25 | c.Header("Connection", "keep-alive") 26 | c.Header("X-Accel-Buffering", "no") 27 | 28 | for event := range events { 29 | c.Render(-1, *event) 30 | c.Writer.Flush() 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/model/user/user.go: -------------------------------------------------------------------------------- 1 | // Package user provides user data model definitions 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package user 5 | 6 | import "github.com/Done-0/gin-scaffold/internal/model/base" 7 | 8 | // User represents user model 9 | type User struct { 10 | base.Base 11 | Email string `gorm:"type:varchar(64);unique;not null" json:"email"` // Email, primary login method 12 | Password string `gorm:"type:varchar(255);not null" json:"password"` // Encrypted password 13 | Nickname string `gorm:"type:varchar(64);unique;not null" json:"nickname"` // User nickname 14 | Avatar string `gorm:"type:varchar(255);default:null" json:"avatar"` // User avatar 15 | Role string `gorm:"type:varchar(32);default:'user'" json:"role"` // User role 16 | } 17 | 18 | // TableName specifies table name 19 | func (User) TableName() string { 20 | return "users" 21 | } 22 | -------------------------------------------------------------------------------- /internal/middleware/cors/cors.go: -------------------------------------------------------------------------------- 1 | // Package cors provides Gin middleware for CORS management 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package cors 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/gin-contrib/cors" 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | // New creates a Gin middleware for CORS management 16 | func New(config *configs.Config) gin.HandlerFunc { 17 | return cors.New(cors.Config{ 18 | AllowOrigins: config.AppConfig.CORSConfig.AllowOrigins, 19 | AllowMethods: config.AppConfig.CORSConfig.AllowMethods, 20 | AllowHeaders: config.AppConfig.CORSConfig.AllowHeaders, 21 | ExposeHeaders: config.AppConfig.CORSConfig.ExposeHeaders, 22 | AllowCredentials: config.AppConfig.CORSConfig.AllowCredentials, 23 | MaxAge: time.Duration(config.AppConfig.CORSConfig.MaxAge) * time.Hour, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /internal/ai/ai.go: -------------------------------------------------------------------------------- 1 | // Package ai provides AI service functionality for multiple providers 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package ai 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/configs" 8 | "github.com/Done-0/gin-scaffold/internal/ai/internal" 9 | "github.com/Done-0/gin-scaffold/internal/ai/internal/provider" 10 | ) 11 | 12 | type ( 13 | AIManager = internal.Manager 14 | ChatRequest = provider.ChatRequest 15 | ChatResponse = provider.ChatResponse 16 | ChatStreamResponse = provider.ChatStreamResponse 17 | Choice = provider.Choice 18 | Message = provider.Message 19 | MessageDelta = provider.MessageDelta 20 | Provider = provider.Provider 21 | StreamChoice = provider.StreamChoice 22 | Usage = provider.Usage 23 | ) 24 | 25 | // New creates a new AI manager instance 26 | func New(config *configs.Config) (*AIManager, error) { 27 | return internal.New(config) 28 | } 29 | -------------------------------------------------------------------------------- /internal/ai/internal/prompt/types.go: -------------------------------------------------------------------------------- 1 | // Package prompt provides dynamic prompt loading and management 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package prompt 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/Done-0/gin-scaffold/internal/utils/template" 10 | ) 11 | 12 | type Manager interface { 13 | GetTemplate(ctx context.Context, name string, vars *map[string]any) (*Template, error) 14 | ListTemplates(ctx context.Context) ([]string, error) 15 | CreateTemplate(ctx context.Context, template *Template) error 16 | UpdateTemplate(ctx context.Context, name string, template *Template) error 17 | DeleteTemplate(ctx context.Context, name string) error 18 | } 19 | 20 | type Template struct { 21 | Name string `json:"name"` 22 | Description string `json:"description,omitempty"` 23 | Variables map[string]string `json:"variables,omitempty"` 24 | Messages []Message `json:"messages"` 25 | } 26 | 27 | type Message = template.Message 28 | -------------------------------------------------------------------------------- /internal/utils/errorx/internal/register.go: -------------------------------------------------------------------------------- 1 | // Package internal provides error code registration internal implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | const ( 7 | DefaultErrorMsg = "Service Internal Error" // Default error message 8 | ) 9 | 10 | var ( 11 | CodeDefinitions = make(map[int32]*CodeDefinition) // Error code definition mapping 12 | ) 13 | 14 | // CodeDefinition error code definition 15 | type CodeDefinition struct { 16 | Code int32 // Error code 17 | Message string // Error message template 18 | } 19 | 20 | // RegisterOption registration option function 21 | type RegisterOption func(definition *CodeDefinition) 22 | 23 | // Register registers error code definition 24 | func Register(code int32, msg string, opts ...RegisterOption) { 25 | definition := &CodeDefinition{ 26 | Code: code, 27 | Message: msg, 28 | } 29 | 30 | for _, opt := range opts { 31 | opt(definition) 32 | } 33 | 34 | CodeDefinitions[code] = definition 35 | } 36 | -------------------------------------------------------------------------------- /internal/utils/validator/validator_utils.go: -------------------------------------------------------------------------------- 1 | // Package validator provides parameter validation utilities 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package validator 5 | 6 | import "github.com/go-playground/validator/v10" 7 | 8 | // ValidErrRes validation error result struct 9 | type ValidErrRes struct { 10 | Error bool // Whether error exists 11 | Field string // Error field name 12 | Tag string // Error tag 13 | Value any // Error value 14 | } 15 | 16 | // NewValidator global validator instance 17 | var NewValidator = validator.New() 18 | 19 | // Validate parameter validator 20 | func Validate(data any) []ValidErrRes { 21 | var Errors []ValidErrRes 22 | errs := NewValidator.Struct(data) 23 | if errs != nil { 24 | for _, err := range errs.(validator.ValidationErrors) { 25 | var el ValidErrRes 26 | el.Error = true 27 | el.Field = err.Field() 28 | el.Tag = err.Tag() 29 | el.Value = err.Value() 30 | 31 | Errors = append(Errors, el) 32 | } 33 | } 34 | return Errors 35 | } 36 | -------------------------------------------------------------------------------- /internal/utils/i18n/i18n_utils.go: -------------------------------------------------------------------------------- 1 | // Package i18n provides internationalization utility functions 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package i18n 5 | 6 | import ( 7 | "github.com/gin-gonic/gin" 8 | "github.com/nicksnyder/go-i18n/v2/i18n" 9 | 10 | "github.com/Done-0/gin-scaffold/internal/types/consts" 11 | ) 12 | 13 | // T translates a message key with template parameters 14 | func T(c *gin.Context, key string, params ...string) string { 15 | localizer, exists := c.Get(consts.LocalizerContextKey) 16 | if !exists { 17 | return key 18 | } 19 | 20 | loc, ok := localizer.(*i18n.Localizer) 21 | if !ok { 22 | return key 23 | } 24 | 25 | config := &i18n.LocalizeConfig{MessageID: key} 26 | 27 | if len(params) > 0 { 28 | templateData := make(map[string]any) 29 | for i := 0; i < len(params)-1; i += 2 { 30 | templateData[params[i]] = params[i+1] 31 | } 32 | config.TemplateData = templateData 33 | } 34 | 35 | if msg, err := loc.Localize(config); err == nil { 36 | return msg 37 | } 38 | return key 39 | } 40 | -------------------------------------------------------------------------------- /internal/utils/sse/stream_handler_utils.go: -------------------------------------------------------------------------------- 1 | // Package sse provides Server-Sent Events streaming utilities 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package sse 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/Done-0/gin-scaffold/internal/sse" 13 | ) 14 | 15 | type Handler func(ctx context.Context, ch chan<- *sse.Event) 16 | 17 | // Stream processes data using a custom handler function 18 | func Stream(c *gin.Context, handler Handler, manager sse.SSEManager) error { 19 | ch := make(chan *sse.Event, 100) 20 | 21 | go func() { 22 | defer close(ch) 23 | handler(context.Background(), ch) 24 | }() 25 | 26 | return manager.StreamToClient(c, ch) 27 | } 28 | 29 | // Send is a helper to emit a single event 30 | func Send(ch chan<- *sse.Event, eventType string, data any) error { 31 | payload, err := json.Marshal(data) 32 | if err != nil { 33 | return err 34 | } 35 | ch <- &sse.Event{ 36 | Event: eventType, 37 | Data: string(payload), 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/serve/service/test_service.go: -------------------------------------------------------------------------------- 1 | // Package service provides test service interfaces 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package service 5 | 6 | import ( 7 | "github.com/gin-contrib/sse" 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/Done-0/gin-scaffold/pkg/serve/controller/dto" 11 | "github.com/Done-0/gin-scaffold/pkg/vo" 12 | ) 13 | 14 | // TestService test service interface 15 | type TestService interface { 16 | TestPing(c *gin.Context) (*vo.TestPingResponse, error) 17 | TestHello(c *gin.Context) (*vo.TestHelloResponse, error) 18 | TestLogger(c *gin.Context) (*vo.TestLoggerResponse, error) 19 | TestRedis(c *gin.Context, req *dto.TestRedisRequest) (*vo.TestRedisResponse, error) 20 | TestSuccess(c *gin.Context) (*vo.TestSuccessResponse, error) 21 | TestError(c *gin.Context) (*vo.TestErrorResponse, error) 22 | TestLong(c *gin.Context, req *dto.TestLongRequest) (*vo.TestLongResponse, error) 23 | TestI18n(c *gin.Context) (*vo.TestI18nResponse, error) 24 | TestStream(c *gin.Context, req *dto.TestStreamRequest) (<-chan *sse.Event, error) 25 | } 26 | -------------------------------------------------------------------------------- /internal/utils/snowflake/snowflake_utils.go: -------------------------------------------------------------------------------- 1 | // Package snowflake provides snowflake algorithm ID generation utilities 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package snowflake 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | "time" 10 | 11 | "github.com/bwmarrin/snowflake" 12 | ) 13 | 14 | var ( 15 | node *snowflake.Node 16 | once sync.Once 17 | ) 18 | 19 | // GenerateID generates snowflake algorithm ID 20 | func GenerateID() (int64, error) { 21 | once.Do(func() { 22 | var err error 23 | node, err = snowflake.NewNode(1) 24 | if err != nil { 25 | fmt.Printf("failed to initialize snowflake node: %v", err) 26 | } 27 | }) 28 | 29 | switch { 30 | case node != nil: 31 | return node.Generate().Int64(), nil 32 | 33 | default: 34 | // Snowflake format: 41-bit timestamp + 10-bit node ID + 12-bit sequence number 35 | // Standard snowflake epoch, node ID 1, sequence number uses lower 12 bits of current nanoseconds 36 | ts := time.Now().UnixMilli() - 1288834974657 37 | nodeID := int64(1) 38 | seq := time.Now().UnixNano() & 0xFFF 39 | 40 | return (ts << 22) | (nodeID << 12) | seq, nil 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/utils/rate/rate_utils.go: -------------------------------------------------------------------------------- 1 | // Package rate provides rate limiting utilities 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package rate 5 | 6 | import ( 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | // ParseLimit parses rate limit string like "60/min", "1/s" 16 | func ParseLimit(s string) (rate.Limit, int, error) { 17 | parts := strings.Split(s, "/") 18 | if len(parts) != 2 { 19 | return 0, 0, fmt.Errorf("invalid format: %s", s) 20 | } 21 | 22 | requests, err := strconv.Atoi(parts[0]) 23 | if err != nil { 24 | return 0, 0, fmt.Errorf("invalid requests: %s", parts[0]) 25 | } 26 | 27 | var duration time.Duration 28 | switch parts[1] { 29 | case "s", "sec", "second": 30 | duration = time.Second 31 | case "m", "min", "minute": 32 | duration = time.Minute 33 | case "h", "hour": 34 | duration = time.Hour 35 | default: 36 | duration, err = time.ParseDuration(parts[1]) 37 | if err != nil { 38 | return 0, 0, fmt.Errorf("invalid duration: %s", parts[1]) 39 | } 40 | } 41 | 42 | return rate.Every(duration / time.Duration(requests)), requests, nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/utils/errorx/internal/msg.go: -------------------------------------------------------------------------------- 1 | // Package internal provides error message wrapping internal implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | // withMessage error wrapper with message 11 | type withMessage struct { 12 | cause error // Cause error 13 | msg string // Message 14 | } 15 | 16 | // Unwrap returns the wrapped original error 17 | func (w *withMessage) Unwrap() error { 18 | return w.cause 19 | } 20 | 21 | // Error error string representation 22 | func (w *withMessage) Error() string { 23 | return fmt.Sprintf("%s\ncause=%s", w.msg, w.cause.Error()) 24 | } 25 | 26 | // wrapf wraps error with formatted message (internal function) 27 | func wrapf(err error, format string, args ...any) error { 28 | if err == nil { 29 | return nil 30 | } 31 | err = &withMessage{ 32 | cause: err, 33 | msg: fmt.Sprintf(format, args...), 34 | } 35 | 36 | return err 37 | } 38 | 39 | // Wrapf wraps error with formatted message and adds stack information 40 | func Wrapf(err error, format string, args ...any) error { 41 | return withStackTraceIfNotExists(wrapf(err, format, args...)) 42 | } -------------------------------------------------------------------------------- /pkg/router/routes/test_routes.go: -------------------------------------------------------------------------------- 1 | // Package routes provides route registration functionality 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package routes 5 | 6 | import ( 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/Done-0/gin-scaffold/pkg/wire" 10 | ) 11 | 12 | // RegisterTestRoutes registers test module routes 13 | func RegisterTestRoutes(container *wire.Container, v1, v2 *gin.RouterGroup) { 14 | // V1 routes 15 | test := v1.Group("/test") 16 | { 17 | test.GET("/testPing", container.TestController.TestPing) 18 | test.GET("/testHello", container.TestController.TestHello) 19 | test.GET("/testLogger", container.TestController.TestLogger) 20 | test.POST("/testRedis", container.TestController.TestRedis) 21 | test.GET("/testSuccessRes", container.TestController.TestSuccess) 22 | test.GET("/testErrRes", container.TestController.TestError) 23 | test.GET("/testErrorMiddleware", container.TestController.TestErrorMiddleware) 24 | test.GET("/testI18n", container.TestController.TestI18n) 25 | test.POST("/testStream", container.TestController.TestStream) 26 | } 27 | 28 | // V2 routes 29 | v2Test := v2.Group("/test") 30 | { 31 | v2Test.POST("/testLongReq", container.TestController.TestLong) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/utils/errorx/error_utils.go: -------------------------------------------------------------------------------- 1 | // Package errorx provides error handling utilities with status codes and stack traces 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package errorx 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/internal/utils/errorx/internal" 8 | ) 9 | 10 | // StatusError error interface with status code 11 | type StatusError interface { 12 | error 13 | Code() int32 // Get error code 14 | Msg() string // Get error message 15 | Extra() map[string]string // Get extra information 16 | Params() map[string]any // Get template parameters 17 | } 18 | 19 | // Option StatusError configuration option 20 | type Option = internal.Option 21 | 22 | // KV creates key-value parameter option 23 | func KV(k, v string) Option { 24 | return internal.Param(k, v) 25 | } 26 | 27 | // New creates new error based on error code 28 | func New(code int32, options ...Option) error { 29 | return internal.NewByCode(code, options...) 30 | } 31 | 32 | // Register registers error code definition 33 | func Register(code int32, msg string, opts ...internal.RegisterOption) { 34 | internal.Register(code, msg, opts...) 35 | } 36 | 37 | // RegisterOption registration option type 38 | type RegisterOption = internal.RegisterOption 39 | -------------------------------------------------------------------------------- /internal/utils/template/template_utils.go: -------------------------------------------------------------------------------- 1 | // Package template provides template variable substitution utilities 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package template 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "text/template" 10 | "time" 11 | ) 12 | 13 | // Message represents a single message in a conversation 14 | type Message struct { 15 | Role string `json:"role"` 16 | Content string `json:"content"` 17 | } 18 | 19 | // Replace parses and executes a Go template with the given variables and custom functions. 20 | func Replace(text string, vars map[string]any) (string, error) { 21 | if text == "" { 22 | return "", nil 23 | } 24 | 25 | funcMap := template.FuncMap{ 26 | "add": func(a, b int) int { 27 | return a + b 28 | }, 29 | "unixToTime": func(unixTime int64) string { 30 | return time.Unix(unixTime, 0).Format("2006年01月02日 15时04分") 31 | }, 32 | } 33 | 34 | tmpl, err := template.New("prompt").Funcs(funcMap).Parse(text) 35 | if err != nil { 36 | return "", fmt.Errorf("failed to parse template: %w", err) 37 | } 38 | 39 | var buf bytes.Buffer 40 | if err := tmpl.Execute(&buf, vars); err != nil { 41 | return "", fmt.Errorf("failed to execute template: %w", err) 42 | } 43 | 44 | return buf.String(), nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/queue/queue.go: -------------------------------------------------------------------------------- 1 | // Package queue provides Kafka message queue functionality 2 | // Author: Done-0 3 | // Created: 2025-08-25 4 | package queue 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/IBM/sarama" 10 | 11 | "github.com/Done-0/gin-scaffold/configs" 12 | "github.com/Done-0/gin-scaffold/internal/queue/internal" 13 | ) 14 | 15 | // Producer defines the interface for Kafka message production 16 | type Producer interface { 17 | Send(ctx context.Context, topic string, key, value []byte) (partition int32, offset int64, err error) 18 | Close() error 19 | } 20 | 21 | // Consumer defines the interface for Kafka message consumption 22 | type Consumer interface { 23 | Subscribe(topics []string) error 24 | Close() error 25 | } 26 | 27 | // Handler defines the interface for message processing 28 | type Handler interface { 29 | Handle(ctx context.Context, msg *sarama.ConsumerMessage) error 30 | } 31 | 32 | // NewProducer creates a new Kafka producer 33 | func NewProducer(config *configs.Config) (Producer, error) { 34 | return internal.NewProducer(config) 35 | } 36 | 37 | // NewConsumer creates a new Kafka consumer 38 | func NewConsumer(config *configs.Config, handler Handler) (Consumer, error) { 39 | return internal.NewConsumer(config, handler) 40 | } 41 | -------------------------------------------------------------------------------- /internal/types/consts/http.go: -------------------------------------------------------------------------------- 1 | // Package consts provides application constant definitions 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package consts 5 | 6 | // HTTP method constants 7 | const ( 8 | MethodGET = "GET" // GET method 9 | MethodPOST = "POST" // POST method 10 | MethodPUT = "PUT" // PUT method 11 | MethodDELETE = "DELETE" // DELETE method 12 | MethodOPTIONS = "OPTIONS" // OPTIONS method 13 | MethodPATCH = "PATCH" // PATCH method 14 | ) 15 | 16 | // HTTP header constants 17 | const ( 18 | // CORS related headers 19 | HeaderOrigin = "Origin" // Request origin 20 | HeaderContentType = "Content-Type" // Content type 21 | HeaderAccept = "Accept" // Accept type 22 | HeaderAcceptLanguage = "Accept-Language" // Accept language 23 | HeaderAuthorization = "Authorization" // Authorization header 24 | HeaderXRequestedWith = "X-Requested-With" // AJAX request identifier 25 | HeaderContentLength = "Content-Length" // Content length 26 | 27 | // Network related headers 28 | HeaderRequestID = "X-Request-ID" // Request ID header 29 | HeaderXForwardedFor = "X-Forwarded-For" // Original client IP forwarded by proxy 30 | HeaderXRealIP = "X-Real-IP" // Real client IP 31 | HeaderXClientIP = "X-Client-IP" // Client IP (used by some proxies) 32 | HeaderUserAgent = "User-Agent" // User agent string 33 | ) 34 | -------------------------------------------------------------------------------- /internal/queue/internal/producer.go: -------------------------------------------------------------------------------- 1 | // Package internal provides Kafka producer implementation 2 | // Author: Done-0 3 | // Created: 2025-08-25 4 | package internal 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/IBM/sarama" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | // Producer handles Kafka message production 16 | type Producer struct { 17 | producer sarama.SyncProducer 18 | } 19 | 20 | // NewProducer creates a new Kafka producer instance 21 | func NewProducer(config *configs.Config) (*Producer, error) { 22 | kafkaConfig := sarama.NewConfig() 23 | kafkaConfig.Producer.RequiredAcks = sarama.WaitForAll 24 | kafkaConfig.Producer.Retry.Max = 3 25 | 26 | producer, err := sarama.NewSyncProducer(config.KafkaConfig.Brokers, kafkaConfig) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to create producer: %w", err) 29 | } 30 | 31 | return &Producer{producer: producer}, nil 32 | } 33 | 34 | // Send sends a message to the specified topic 35 | func (p *Producer) Send(ctx context.Context, topic string, key, value []byte) (int32, int64, error) { 36 | msg := &sarama.ProducerMessage{ 37 | Topic: topic, 38 | Value: sarama.ByteEncoder(value), 39 | } 40 | 41 | if key != nil { 42 | msg.Key = sarama.ByteEncoder(key) 43 | } 44 | 45 | return p.producer.SendMessage(msg) 46 | } 47 | 48 | // Close closes the producer connection 49 | func (p *Producer) Close() error { 50 | return p.producer.Close() 51 | } 52 | -------------------------------------------------------------------------------- /internal/utils/file/json_utils.go: -------------------------------------------------------------------------------- 1 | // Package file provides file loading and saving utilities 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package file 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | // LoadJSONFile loads and validates a JSON file 15 | func LoadJSONFile(filePath string, target any) error { 16 | data, err := os.ReadFile(filePath) 17 | if err != nil { 18 | return fmt.Errorf("failed to read file: %w", err) 19 | } 20 | 21 | if err := json.Unmarshal(data, target); err != nil { 22 | return fmt.Errorf("invalid JSON format: %w", err) 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // SaveJSONFile saves data to a JSON file with proper formatting 29 | func SaveJSONFile(filePath string, data any) error { 30 | dir := filepath.Dir(filePath) 31 | if err := os.MkdirAll(dir, 0755); err != nil { 32 | return fmt.Errorf("failed to create directory: %w", err) 33 | } 34 | 35 | jsonData, err := json.MarshalIndent(data, "", " ") 36 | if err != nil { 37 | return fmt.Errorf("failed to marshal JSON: %w", err) 38 | } 39 | 40 | if err := os.WriteFile(filePath, jsonData, 0644); err != nil { 41 | return fmt.Errorf("failed to write file: %w", err) 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // GetFileNameWithoutExt returns filename without extension 48 | func GetFileNameWithoutExt(filePath string) string { 49 | return strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) 50 | } 51 | -------------------------------------------------------------------------------- /internal/types/errno/system.go: -------------------------------------------------------------------------------- 1 | // Package errno provides system-level error code definitions 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package errno 5 | 6 | import ( 7 | "github.com/Done-0/gin-scaffold/internal/utils/errorx/code" 8 | ) 9 | 10 | // System-level error codes: 10000 ~ 19999 11 | // Used: 10001-10008 12 | // Next available: 10009 13 | const ( 14 | ErrInternalServer = 10001 // Internal server error 15 | ErrInvalidParams = 10002 // Parameter validation failed 16 | ErrUnauthorized = 10003 // Authentication failed 17 | ErrForbidden = 10004 // Insufficient permissions 18 | ErrResourceNotFound = 10005 // Resource not found 19 | ErrResourceConflict = 10006 // Resource conflict 20 | ErrTooManyRequests = 10007 // Request rate limit exceeded 21 | ErrServiceUnavailable = 10008 // Service unavailable 22 | ) 23 | 24 | func init() { 25 | code.Register(ErrInternalServer, "internal server error: {{.msg}}") 26 | code.Register(ErrInvalidParams, "invalid parameter: {{.msg}}") 27 | code.Register(ErrUnauthorized, "unauthorized access: {{.msg}}") 28 | code.Register(ErrForbidden, "permission denied: {{.resource}}") 29 | code.Register(ErrResourceNotFound, "{{.resource}} not found: {{.id}}") 30 | code.Register(ErrResourceConflict, "{{.resource}} already exists: {{.id}}") 31 | code.Register(ErrTooManyRequests, "too many requests: {{.limit}} per {{.period}}") 32 | code.Register(ErrServiceUnavailable, "service unavailable: {{.service}}") 33 | } 34 | -------------------------------------------------------------------------------- /pkg/wire/wire.go: -------------------------------------------------------------------------------- 1 | //go:build wireinject 2 | 3 | // Package wire provides Wire dependency injection definitions 4 | // Author: Done-0 5 | // Created: 2025-09-25 6 | package wire 7 | 8 | import ( 9 | "github.com/google/wire" 10 | 11 | "github.com/Done-0/gin-scaffold/configs" 12 | "github.com/Done-0/gin-scaffold/internal/ai" 13 | "github.com/Done-0/gin-scaffold/internal/db" 14 | "github.com/Done-0/gin-scaffold/internal/i18n" 15 | "github.com/Done-0/gin-scaffold/internal/logger" 16 | "github.com/Done-0/gin-scaffold/internal/sse" 17 | 18 | // "github.com/Done-0/gin-scaffold/internal/queue" 19 | 20 | "github.com/Done-0/gin-scaffold/internal/redis" 21 | "github.com/Done-0/gin-scaffold/pkg/serve/controller" 22 | ) 23 | 24 | // Container holds all application dependencies 25 | type Container struct { 26 | Config *configs.Config 27 | 28 | // Infrastructure 29 | AIManager *ai.AIManager 30 | DatabaseManager db.DatabaseManager 31 | RedisManager redis.RedisManager 32 | LoggerManager logger.LoggerManager 33 | I18nManager i18n.I18nManager 34 | SSEManager sse.SSEManager 35 | // QueueProducer queue.Producer 36 | 37 | // Controllers 38 | TestController *controller.TestController 39 | 40 | // Services 41 | 42 | // mappers 43 | } 44 | 45 | // NewContainer initializes the complete application container using Wire 46 | func NewContainer(config *configs.Config) (*Container, error) { 47 | panic(wire.Build( 48 | AllProviders, 49 | wire.Struct(new(Container), "*"), 50 | )) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/vo/test_vo.go: -------------------------------------------------------------------------------- 1 | // Package vo provides test-related value object definitions 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package vo 5 | 6 | // TestPingResponse ping test response 7 | type TestPingResponse struct { 8 | Time string `json:"time"` 9 | Message string `json:"message"` 10 | } 11 | 12 | // TestHelloResponse hello test response 13 | type TestHelloResponse struct { 14 | Version string `json:"version"` 15 | Message string `json:"message"` 16 | } 17 | 18 | // TestLoggerResponse logger test response 19 | type TestLoggerResponse struct { 20 | Level string `json:"level"` 21 | Message string `json:"message"` 22 | } 23 | 24 | // TestRedisResponse redis test response 25 | type TestRedisResponse struct { 26 | Key string `json:"key"` 27 | Value string `json:"value"` 28 | TTL int `json:"ttl"` 29 | Message string `json:"message"` 30 | } 31 | 32 | // TestSuccessResponse success test response 33 | type TestSuccessResponse struct { 34 | Status string `json:"status"` 35 | Message string `json:"message"` 36 | } 37 | 38 | // TestErrorResponse error test response 39 | type TestErrorResponse struct { 40 | Code int `json:"code"` 41 | Message string `json:"message"` 42 | } 43 | 44 | // TestLongResponse long request test response 45 | type TestLongResponse struct { 46 | Duration int `json:"duration"` 47 | Message string `json:"message"` 48 | } 49 | 50 | // TestI18nResponse i18n test response 51 | type TestI18nResponse struct { 52 | Message string `json:"message"` 53 | } 54 | -------------------------------------------------------------------------------- /pkg/wire/providers.go: -------------------------------------------------------------------------------- 1 | // Package wire provides dependency injection configuration using Google Wire 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package wire 5 | 6 | import ( 7 | "github.com/google/wire" 8 | 9 | "github.com/Done-0/gin-scaffold/internal/ai" 10 | "github.com/Done-0/gin-scaffold/internal/db" 11 | "github.com/Done-0/gin-scaffold/internal/i18n" 12 | "github.com/Done-0/gin-scaffold/internal/logger" 13 | "github.com/Done-0/gin-scaffold/internal/sse" 14 | 15 | // "github.com/Done-0/gin-scaffold/internal/queue" 16 | 17 | "github.com/Done-0/gin-scaffold/internal/redis" 18 | "github.com/Done-0/gin-scaffold/pkg/serve/controller" 19 | "github.com/Done-0/gin-scaffold/pkg/serve/service/impl" 20 | ) 21 | 22 | // InfrastructureProviders provides infrastructure layer dependencies 23 | var InfrastructureProviders = wire.NewSet( 24 | ai.New, 25 | db.New, 26 | logger.New, 27 | i18n.New, 28 | // queue.NewProducer, 29 | redis.New, 30 | sse.New, 31 | ) 32 | 33 | // MapperProviders provides data access layer dependencies 34 | var MapperProviders = wire.NewSet() 35 | 36 | // ServiceProviders provides business logic layer dependencies 37 | var ServiceProviders = wire.NewSet( 38 | impl.NewTestService, 39 | ) 40 | 41 | // ControllerProviders provides controller layer dependencies 42 | var ControllerProviders = wire.NewSet( 43 | controller.NewTestController, 44 | ) 45 | 46 | // AllProviders combines all provider sets in dependency order 47 | var AllProviders = wire.NewSet( 48 | InfrastructureProviders, 49 | MapperProviders, 50 | ServiceProviders, 51 | ControllerProviders, 52 | ) 53 | -------------------------------------------------------------------------------- /internal/i18n/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides i18n management functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package internal 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/nicksnyder/go-i18n/v2/i18n" 15 | "golang.org/x/text/language" 16 | 17 | "github.com/Done-0/gin-scaffold/internal/types/consts" 18 | ) 19 | 20 | // Manager provides i18n functionality 21 | type Manager struct { 22 | bundle *i18n.Bundle 23 | } 24 | 25 | // NewManager creates a new i18n manager instance 26 | func NewManager() *Manager { 27 | return &Manager{} 28 | } 29 | 30 | // Bundle returns the i18n bundle instance 31 | func (m *Manager) Bundle() *i18n.Bundle { 32 | return m.bundle 33 | } 34 | 35 | // Initialize sets up i18n system 36 | func (m *Manager) Initialize() error { 37 | m.bundle = i18n.NewBundle(language.Chinese) 38 | m.bundle.RegisterUnmarshalFunc("json", json.Unmarshal) 39 | 40 | entries, err := os.ReadDir(consts.I18nConfigPath) 41 | if err != nil { 42 | return fmt.Errorf("failed to read i18n directory: %w", err) 43 | } 44 | 45 | for _, entry := range entries { 46 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { 47 | continue 48 | } 49 | if _, err := m.bundle.LoadMessageFile(filepath.Join(consts.I18nConfigPath, entry.Name())); err != nil { 50 | return fmt.Errorf("failed to load %s: %w", entry.Name(), err) 51 | } 52 | } 53 | 54 | log.Println("i18n system initialized successfully") 55 | return nil 56 | } 57 | 58 | // Close closes the i18n system 59 | func (m *Manager) Close() error { 60 | m.bundle = nil 61 | log.Println("i18n closed successfully") 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/db/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides database management functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | 10 | "gorm.io/gorm" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | // Database dialect constants 16 | const ( 17 | DialectPostgres = "postgres" // PostgreSQL database 18 | DialectSQLite = "sqlite" // SQLite database 19 | DialectMySQL = "mysql" // MySQL database 20 | ) 21 | 22 | // Manager represents a database manager with dependency injection 23 | type Manager struct { 24 | config *configs.Config 25 | db *gorm.DB 26 | } 27 | 28 | // NewManager creates a new database manager instance 29 | func NewManager(config *configs.Config) *Manager { 30 | return &Manager{ 31 | config: config, 32 | } 33 | } 34 | 35 | // DB returns the database instance 36 | func (m *Manager) DB() *gorm.DB { 37 | return m.db 38 | } 39 | 40 | // Initialize sets up the database connection and performs migrations 41 | func (m *Manager) Initialize() error { 42 | if err := m.setupDatabase(); err != nil { 43 | return fmt.Errorf("failed to setup database: %w", err) 44 | } 45 | 46 | if err := m.migrate(); err != nil { 47 | return fmt.Errorf("failed to migrate database: %w", err) 48 | } 49 | 50 | log.Println("Database initialized successfully") 51 | return nil 52 | } 53 | 54 | // Close closes the database connection 55 | func (m *Manager) Close() error { 56 | if m.db == nil { 57 | return nil 58 | } 59 | 60 | sqlDB, err := m.db.DB() 61 | if err != nil { 62 | return fmt.Errorf("failed to get SQL database instance: %w", err) 63 | } 64 | 65 | if err := sqlDB.Close(); err != nil { 66 | return fmt.Errorf("failed to close database connection: %w", err) 67 | } 68 | 69 | log.Println("Database closed successfully") 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/model/base/base.go: -------------------------------------------------------------------------------- 1 | // Package base provides base model definitions and common database operations 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package base 5 | 6 | import ( 7 | "database/sql/driver" 8 | "encoding/json" 9 | "errors" 10 | "time" 11 | 12 | "gorm.io/gorm" 13 | 14 | "github.com/Done-0/gin-scaffold/internal/utils/snowflake" 15 | ) 16 | 17 | // Base contains common model fields 18 | type Base struct { 19 | ID int64 `gorm:"primaryKey;type:bigint" json:"id"` // Primary key (snowflake) 20 | CreatedAt int64 `gorm:"type:bigint" json:"created_at"` // Creation timestamp 21 | UpdatedAt int64 `gorm:"type:bigint" json:"updated_at"` // Update timestamp 22 | Ext JSONMap `gorm:"type:json" json:"ext"` // Extension fields 23 | Deleted bool `gorm:"type:boolean;default:false" json:"deleted"` // Soft delete flag 24 | } 25 | 26 | // JSONMap handles JSON type fields 27 | type JSONMap map[string]any 28 | 29 | // Scan implements sql.Scanner interface 30 | func (j *JSONMap) Scan(value any) error { 31 | switch v := value.(type) { 32 | case nil: 33 | *j = make(JSONMap) 34 | case []byte: 35 | return json.Unmarshal(v, j) 36 | default: 37 | return errors.New("cannot scan into JSONMap") 38 | } 39 | return nil 40 | } 41 | 42 | // Value implements driver.Valuer interface 43 | func (j JSONMap) Value() (driver.Value, error) { 44 | return json.Marshal(j) 45 | } 46 | 47 | // BeforeCreate implements GORM hook 48 | func (m *Base) BeforeCreate(db *gorm.DB) error { 49 | if m.ID != 0 { 50 | return nil 51 | } 52 | 53 | now := time.Now().Unix() 54 | m.CreatedAt = now 55 | m.UpdatedAt = now 56 | 57 | var err error 58 | m.ID, err = snowflake.GenerateID() 59 | return err 60 | } 61 | 62 | // BeforeUpdate implements GORM hook 63 | func (m *Base) BeforeUpdate(db *gorm.DB) error { 64 | m.UpdatedAt = time.Now().Unix() 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/utils/errorx/internal/stack.go: -------------------------------------------------------------------------------- 1 | // Package internal provides error stack trace handling internal implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | // StackTracer stack trace interface 14 | type StackTracer interface { 15 | StackTrace() string // Get stack trace information 16 | } 17 | 18 | // withStack error wrapper with stack information 19 | type withStack struct { 20 | cause error // Cause error 21 | stack string // Stack information 22 | } 23 | 24 | // Unwrap returns the wrapped original error 25 | func (w *withStack) Unwrap() error { 26 | return w.cause 27 | } 28 | 29 | // StackTrace gets stack trace information 30 | func (w *withStack) StackTrace() string { 31 | return w.stack 32 | } 33 | 34 | // Error error string representation 35 | func (w *withStack) Error() string { 36 | return fmt.Sprintf("%s\nstack=%s", w.cause.Error(), w.stack) 37 | } 38 | 39 | // stack gets current call stack information 40 | func stack() string { 41 | const depth = 32 42 | var pcs [depth]uintptr 43 | n := runtime.Callers(2, pcs[:]) 44 | 45 | b := strings.Builder{} 46 | for i := 0; i < n; i++ { 47 | fn := runtime.FuncForPC(pcs[i]) 48 | 49 | file, line := fn.FileLine(pcs[i]) 50 | name := trimPathPrefix(fn.Name()) 51 | b.WriteString(fmt.Sprintf("%s:%d %s\n", file, line, name)) 52 | } 53 | 54 | return b.String() 55 | } 56 | 57 | // trimPathPrefix removes path prefix, keeping only function name 58 | func trimPathPrefix(s string) string { 59 | i := strings.LastIndex(s, "/") 60 | s = s[i+1:] 61 | i = strings.Index(s, ".") 62 | return s[i+1:] 63 | } 64 | 65 | // withStackTraceIfNotExists adds stack information if error doesn't have it 66 | func withStackTraceIfNotExists(err error) error { 67 | if err == nil { 68 | return nil 69 | } 70 | 71 | // skip if stack has already exist 72 | var stackTracer StackTracer 73 | if errors.As(err, &stackTracer) { 74 | return err 75 | } 76 | 77 | return &withStack{ 78 | err, 79 | stack(), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/utils/email/email_utils.go: -------------------------------------------------------------------------------- 1 | // Package email provides email operation related utilities 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package email 5 | 6 | import ( 7 | "crypto/tls" 8 | "fmt" 9 | "math/rand" 10 | "time" 11 | 12 | "gopkg.in/gomail.v2" 13 | 14 | "github.com/Done-0/gin-scaffold/configs" 15 | ) 16 | 17 | // Email server configuration 18 | var emailServers = map[string]struct { 19 | Server string 20 | Port int 21 | SSL bool 22 | }{ 23 | "qq": {"smtp.qq.com", 465, true}, // QQ email uses SSL encryption 24 | "gmail": {"smtp.gmail.com", 465, true}, // Gmail uses SSL encryption 25 | "outlook": {"smtp.office365.com", 587, false}, // Outlook uses TLS encryption 26 | } 27 | 28 | // SendEmail sends email to specified email addresses with specified content type 29 | func SendEmail(subject, content string, toEmails []string, contentType string) (bool, error) { 30 | cfgs, err := configs.GetConfig() 31 | if err != nil { 32 | return false, fmt.Errorf("failed to load email config: %v", err) 33 | } 34 | 35 | // Get SMTP configuration 36 | emailType := cfgs.AppConfig.Email.EmailType 37 | serverConfig := emailServers[emailType] 38 | 39 | // Create email 40 | m := gomail.NewMessage() 41 | m.SetHeader("From", cfgs.AppConfig.Email.FromEmail) 42 | m.SetHeader("To", toEmails...) 43 | m.SetHeader("Subject", subject) 44 | m.SetBody(contentType, content) 45 | 46 | // Configure sender 47 | d := gomail.NewDialer( 48 | serverConfig.Server, 49 | serverConfig.Port, 50 | cfgs.AppConfig.Email.FromEmail, 51 | cfgs.AppConfig.Email.EmailSmtp, 52 | ) 53 | 54 | // Configure security options based on port 55 | if serverConfig.SSL { 56 | d.SSL = true 57 | } else { 58 | d.TLSConfig = &tls.Config{ 59 | ServerName: serverConfig.Server, 60 | MinVersion: tls.VersionTLS12, 61 | } 62 | } 63 | 64 | if err := d.DialAndSend(m); err != nil { 65 | return false, fmt.Errorf("failed to send email: %v", err) 66 | } 67 | 68 | return true, nil 69 | } 70 | 71 | // NewRand generates six-digit random verification code 72 | func NewRand() int { 73 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 74 | return r.Intn(900000) + 100000 75 | } 76 | -------------------------------------------------------------------------------- /internal/utils/vo/vo_utils.go: -------------------------------------------------------------------------------- 1 | // Package vo provides common value objects 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package vo 5 | 6 | import ( 7 | "fmt" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/gin-contrib/requestid" 12 | "github.com/gin-gonic/gin" 13 | 14 | "github.com/Done-0/gin-scaffold/internal/types/errno" 15 | "github.com/Done-0/gin-scaffold/internal/utils/errorx" 16 | "github.com/Done-0/gin-scaffold/internal/utils/i18n" 17 | ) 18 | 19 | // Error information 20 | type Error struct { 21 | Code string `json:"code"` 22 | Message string `json:"message"` 23 | } 24 | 25 | // Result common API response structure 26 | type Result struct { 27 | Error *Error `json:"error,omitempty"` 28 | Data any `json:"data,omitempty"` 29 | RequestId string `json:"requestId"` 30 | TimeStamp int64 `json:"timeStamp"` 31 | } 32 | 33 | // Success successful response 34 | func Success(c *gin.Context, data any) Result { 35 | if errData, ok := data.(error); ok { 36 | data = errData.Error() 37 | } 38 | return Result{ 39 | Data: data, 40 | RequestId: requestid.Get(c), 41 | TimeStamp: time.Now().Unix(), 42 | } 43 | } 44 | 45 | // Fail creates error response 46 | func Fail(c *gin.Context, data any, err error) Result { 47 | if errData, ok := data.(error); ok { 48 | data = errData.Error() 49 | } 50 | 51 | var code, message string 52 | 53 | switch e := err.(type) { 54 | case errorx.StatusError: 55 | code = strconv.Itoa(int(e.Code())) 56 | params := e.Params() 57 | 58 | if len(params) == 0 { 59 | message = i18n.T(c, code) 60 | } else { 61 | args := make([]string, len(params)*2) 62 | i := 0 63 | for k, v := range params { 64 | args[i] = k 65 | if s, ok := v.(string); ok { 66 | args[i+1] = s 67 | } else { 68 | args[i+1] = fmt.Sprintf("%v", v) 69 | } 70 | i += 2 71 | } 72 | message = i18n.T(c, code, args...) 73 | } 74 | default: 75 | code = strconv.Itoa(errno.ErrInternalServer) 76 | message = i18n.T(c, code, "msg", err.Error()) 77 | } 78 | 79 | return Result{ 80 | Error: &Error{Code: code, Message: message}, 81 | Data: data, 82 | RequestId: requestid.Get(c), 83 | TimeStamp: time.Now().Unix(), 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pkg/wire/wire_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by Wire. DO NOT EDIT. 2 | 3 | //go:generate go run -mod=mod github.com/google/wire/cmd/wire 4 | //go:build !wireinject 5 | // +build !wireinject 6 | 7 | package wire 8 | 9 | import ( 10 | "github.com/Done-0/gin-scaffold/configs" 11 | "github.com/Done-0/gin-scaffold/internal/ai" 12 | "github.com/Done-0/gin-scaffold/internal/db" 13 | "github.com/Done-0/gin-scaffold/internal/i18n" 14 | "github.com/Done-0/gin-scaffold/internal/logger" 15 | "github.com/Done-0/gin-scaffold/internal/redis" 16 | "github.com/Done-0/gin-scaffold/internal/sse" 17 | "github.com/Done-0/gin-scaffold/pkg/serve/controller" 18 | "github.com/Done-0/gin-scaffold/pkg/serve/service/impl" 19 | ) 20 | 21 | // Injectors from wire.go: 22 | 23 | // NewContainer initializes the complete application container using Wire 24 | func NewContainer(config *configs.Config) (*Container, error) { 25 | manager, err := ai.New(config) 26 | if err != nil { 27 | return nil, err 28 | } 29 | databaseManager := db.New(config) 30 | redisManager, err := redis.New(config) 31 | if err != nil { 32 | return nil, err 33 | } 34 | loggerManager, err := logger.New(config) 35 | if err != nil { 36 | return nil, err 37 | } 38 | i18nManager := i18n.New() 39 | sseManager := sse.New(config) 40 | testService := impl.NewTestService(loggerManager, redisManager, manager) 41 | testController := controller.NewTestController(testService, sseManager) 42 | container := &Container{ 43 | Config: config, 44 | AIManager: manager, 45 | DatabaseManager: databaseManager, 46 | RedisManager: redisManager, 47 | LoggerManager: loggerManager, 48 | I18nManager: i18nManager, 49 | SSEManager: sseManager, 50 | TestController: testController, 51 | } 52 | return container, nil 53 | } 54 | 55 | // wire.go: 56 | 57 | // Container holds all application dependencies 58 | type Container struct { 59 | Config *configs.Config 60 | 61 | // Infrastructure 62 | AIManager *ai.AIManager 63 | DatabaseManager db.DatabaseManager 64 | RedisManager redis.RedisManager 65 | LoggerManager logger.LoggerManager 66 | I18nManager i18n.I18nManager 67 | SSEManager sse.SSEManager 68 | 69 | // Controllers 70 | TestController *controller.TestController 71 | } 72 | -------------------------------------------------------------------------------- /internal/redis/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides Redis manager implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/redis/go-redis/v9" 14 | 15 | "github.com/Done-0/gin-scaffold/configs" 16 | ) 17 | 18 | // Manager represents a Redis manager with dependency injection 19 | type Manager struct { 20 | config *configs.Config 21 | client *redis.Client 22 | } 23 | 24 | // NewManager creates a new Redis manager instance 25 | func NewManager(config *configs.Config) (*Manager, error) { 26 | return &Manager{ 27 | config: config, 28 | }, nil 29 | } 30 | 31 | // Client returns the Redis client instance 32 | func (m *Manager) Client() *redis.Client { 33 | return m.client 34 | } 35 | 36 | // Initialize sets up the Redis connection 37 | func (m *Manager) Initialize() error { 38 | db, _ := strconv.Atoi(m.config.RedisConfig.RedisDB) 39 | m.client = redis.NewClient(&redis.Options{ 40 | Addr: fmt.Sprintf("%s:%s", m.config.RedisConfig.RedisHost, m.config.RedisConfig.RedisPort), 41 | Password: m.config.RedisConfig.RedisPassword, // Database password, default empty string 42 | DB: db, // Database index 43 | DialTimeout: time.Duration(m.config.RedisConfig.DialTimeout) * time.Second, // Connection timeout 44 | ReadTimeout: time.Duration(m.config.RedisConfig.ReadTimeout) * time.Second, // Read timeout 45 | WriteTimeout: time.Duration(m.config.RedisConfig.WriteTimeout) * time.Second, // Write timeout 46 | PoolSize: m.config.RedisConfig.PoolSize, // Maximum connection pool size 47 | MinIdleConns: m.config.RedisConfig.MinIdleConns, // Minimum idle connections 48 | }) 49 | 50 | if err := m.client.Ping(context.Background()).Err(); err != nil { 51 | return fmt.Errorf("failed to ping Redis: %w", err) 52 | } 53 | 54 | log.Println("Redis connected successfully") 55 | return nil 56 | } 57 | 58 | // Close closes the Redis connection 59 | func (m *Manager) Close() error { 60 | if m.client == nil { 61 | return nil 62 | } 63 | 64 | if err := m.client.Close(); err != nil { 65 | return fmt.Errorf("failed to close Redis connection: %w", err) 66 | } 67 | 68 | log.Println("Redis connection closed successfully") 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/queue/internal/consumer.go: -------------------------------------------------------------------------------- 1 | // Package internal provides Kafka consumer implementation 2 | // Author: Done-0 3 | // Created: 2025-08-25 4 | package internal 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | 10 | "github.com/IBM/sarama" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | // Handler defines the interface for processing consumed messages 16 | type Handler interface { 17 | Handle(ctx context.Context, msg *sarama.ConsumerMessage) error 18 | } 19 | 20 | // Consumer handles Kafka message consumption 21 | type Consumer struct { 22 | consumer sarama.ConsumerGroup 23 | handler Handler 24 | ctx context.Context 25 | cancel context.CancelFunc 26 | } 27 | 28 | // NewConsumer creates a new Kafka consumer instance 29 | func NewConsumer(config *configs.Config, handler Handler) (*Consumer, error) { 30 | kafkaConfig := sarama.NewConfig() 31 | kafkaConfig.Consumer.Group.Rebalance.Strategy = sarama.NewBalanceStrategyRoundRobin() 32 | kafkaConfig.Consumer.Offsets.Initial = sarama.OffsetNewest 33 | 34 | consumerGroup, err := sarama.NewConsumerGroup(config.KafkaConfig.Brokers, config.KafkaConfig.ConsumerGroup, kafkaConfig) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to create consumer group: %w", err) 37 | } 38 | 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | return &Consumer{ 41 | consumer: consumerGroup, 42 | handler: handler, 43 | ctx: ctx, 44 | cancel: cancel, 45 | }, nil 46 | } 47 | 48 | // Subscribe starts consuming messages from the specified topics 49 | func (c *Consumer) Subscribe(topics []string) error { 50 | go func() { 51 | for { 52 | if err := c.consumer.Consume(c.ctx, topics, c); err != nil { 53 | return 54 | } 55 | if c.ctx.Err() != nil { 56 | return 57 | } 58 | } 59 | }() 60 | return nil 61 | } 62 | 63 | // Close gracefully shuts down the consumer 64 | func (c *Consumer) Close() error { 65 | c.cancel() 66 | return c.consumer.Close() 67 | } 68 | 69 | // Setup implements sarama.ConsumerGroupHandler 70 | func (c *Consumer) Setup(sarama.ConsumerGroupSession) error { return nil } 71 | 72 | // Cleanup implements sarama.ConsumerGroupHandler 73 | func (c *Consumer) Cleanup(sarama.ConsumerGroupSession) error { return nil } 74 | 75 | // ConsumeClaim implements sarama.ConsumerGroupHandler 76 | func (c *Consumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { 77 | for message := range claim.Messages() { 78 | c.handler.Handle(session.Context(), message) 79 | session.MarkMessage(message, "") 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/ai/internal/provider/types.go: -------------------------------------------------------------------------------- 1 | // Package provider implements AI provider interfaces 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package provider 5 | 6 | import "context" 7 | 8 | type Provider interface { 9 | Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) 10 | ChatStream(ctx context.Context, req *ChatRequest) (<-chan *ChatStreamResponse, error) 11 | } 12 | 13 | // Request types 14 | type ChatRequest struct { 15 | Model string `json:"model"` 16 | Messages []Message `json:"messages"` 17 | MaxTokens int `json:"max_tokens,omitempty"` 18 | Temperature float64 `json:"temperature,omitempty"` 19 | } 20 | 21 | type Message struct { 22 | Role string `json:"role"` 23 | Content string `json:"content"` 24 | ReasoningContent string `json:"reasoning_content,omitempty"` 25 | } 26 | 27 | // Response types 28 | type ChatResponse struct { 29 | ID string `json:"id"` 30 | Object string `json:"object"` 31 | Created int64 `json:"created"` 32 | Model string `json:"model"` 33 | Choices []Choice `json:"choices"` 34 | Usage Usage `json:"usage"` 35 | SystemFingerprint string `json:"system_fingerprint,omitempty"` 36 | Provider string `json:"provider"` 37 | } 38 | 39 | type Choice struct { 40 | Index int `json:"index"` 41 | Message Message `json:"message"` 42 | FinishReason string `json:"finish_reason"` 43 | } 44 | 45 | type Usage struct { 46 | PromptTokens int `json:"prompt_tokens"` 47 | CompletionTokens int `json:"completion_tokens"` 48 | TotalTokens int `json:"total_tokens"` 49 | } 50 | 51 | // Stream response types 52 | type ChatStreamResponse struct { 53 | ID string `json:"id"` 54 | Object string `json:"object"` 55 | Created int64 `json:"created"` 56 | Model string `json:"model"` 57 | Choices []StreamChoice `json:"choices"` 58 | SystemFingerprint string `json:"system_fingerprint,omitempty"` 59 | Usage *Usage `json:"usage,omitempty"` 60 | Provider string `json:"provider"` 61 | } 62 | 63 | type StreamChoice struct { 64 | Index int `json:"index"` 65 | Delta MessageDelta `json:"delta"` 66 | FinishReason string `json:"finish_reason"` 67 | } 68 | 69 | type MessageDelta struct { 70 | Role string `json:"role,omitempty"` 71 | Content string `json:"content,omitempty"` 72 | ReasoningContent string `json:"reasoning_content,omitempty"` 73 | } 74 | -------------------------------------------------------------------------------- /internal/ai/internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | // Package provider implements AI provider interfaces 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package provider 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "sync/atomic" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | type provider struct { 16 | instanceCounter uint64 // Round Robin selection 17 | keyCounters map[string]*uint64 // key: "provider:instance", value: counter pointer 18 | modelCounters map[string]*uint64 // key: "provider:instance", value: counter pointer 19 | } 20 | 21 | func New() Provider { 22 | return &provider{ 23 | keyCounters: make(map[string]*uint64), 24 | modelCounters: make(map[string]*uint64), 25 | } 26 | } 27 | 28 | func (p *provider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { 29 | client, err := p.getProvider() 30 | if err != nil { 31 | return nil, err 32 | } 33 | return client.Chat(ctx, req) 34 | } 35 | 36 | func (p *provider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *ChatStreamResponse, error) { 37 | client, err := p.getProvider() 38 | if err != nil { 39 | return nil, err 40 | } 41 | return client.ChatStream(ctx, req) 42 | } 43 | 44 | func (p *provider) getProvider() (Provider, error) { 45 | cfg, err := configs.GetConfig() 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to get config: %w", err) 48 | } 49 | 50 | type providerInstance struct { 51 | name string 52 | instance configs.ProviderInstanceConfig 53 | } 54 | 55 | var instances []providerInstance 56 | for name, prov := range cfg.AI.Providers { 57 | if !prov.Enabled { 58 | continue 59 | } 60 | 61 | for _, inst := range prov.Instances { 62 | if inst.Enabled { 63 | instances = append(instances, providerInstance{ 64 | name: name, 65 | instance: inst, 66 | }) 67 | } 68 | } 69 | } 70 | 71 | // Round Robin selection 72 | selected := instances[atomic.AddUint64(&p.instanceCounter, 1)%uint64(len(instances))] 73 | 74 | log.Printf("Using %s provider, instance: %s", selected.name, selected.instance.Name) 75 | 76 | counterKey := fmt.Sprintf("%s:%s", selected.name, selected.instance.Name) 77 | 78 | keyCounter, exists := p.keyCounters[counterKey] 79 | if !exists { 80 | keyCounter = new(uint64) 81 | p.keyCounters[counterKey] = keyCounter 82 | } 83 | 84 | modelCounter, exists := p.modelCounters[counterKey] 85 | if !exists { 86 | modelCounter = new(uint64) 87 | p.modelCounters[counterKey] = modelCounter 88 | } 89 | 90 | switch selected.name { 91 | case "openai": 92 | return NewOpenAI(&selected.instance, keyCounter, modelCounter) 93 | case "gemini": 94 | return NewGemini(&selected.instance, keyCounter, modelCounter) 95 | } 96 | 97 | return nil, fmt.Errorf("unsupported provider: %s", selected.name) 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gin-scaffold 脚手架 2 | 3 | ## 简介 4 | 5 | gin-scaffold 是一个现代化的 Go Web 服务端脚手架,基于 Gin 框架,集成主流企业级能力,并在 docs 目录下附带了完善的 Go 语言开发规范,助力团队高效、规范地推进中大型项目开发。 6 | 7 | ## 技术栈 8 | 9 | **Go 1.25.1** 10 | **Gin**:Web 框架,路由与中间件支持。 11 | **GORM**:ORM 框架,支持 PostgreSQL、MySQL、SQLite,自动迁移。 12 | **go-redis/v9**:Redis 客户端,连接池与健康检查。 13 | **Logrus** + **file-rotatelogs/lfshook**:结构化日志,分级与轮转。 14 | **Viper** + **fsnotify**:配置管理,支持多环境与热加载。 15 | **Wire**:依赖注入。 16 | **Kafka (sarama)**:消息队列,生产者/消费者。 17 | **SSE (gin-contrib/sse)**:服务端推送。 18 | **go-playground/validator/v10**:参数校验。 19 | **go-i18n/v2**:国际化。 20 | **bwmarrin/snowflake**:雪花 ID。 21 | **中间件**:RequestID、CORS、Gzip、Secure、Recovery、事务、验证码等。 22 | **工具库**:文件处理、模板渲染、速率限制、分布式队列、错误处理、VO、AI(OpenAI/Gemini)、API 示例等。 23 | 24 | ## 快速开始 25 | 26 | 1. 克隆本仓库并安装依赖 `git clone https://github.com/Done-0/gin-scaffold.git` 27 | 2. 配置 `configs/configs.local.yaml` 或 `configs/configs.prod.yaml` 28 | 3. 启动服务 `go run main.go` 29 | 30 | ## 适用场景 31 | 32 | - 企业级后端系统开发 33 | - 微服务架构与分布式服务 34 | - RESTful API 服务 35 | - 高并发与高可用业务场景 36 | - 快速原型及功能验证 37 | - 追求高可维护性、易扩展的 Go 项目 38 | 39 | ## 主要模块说明 40 | 41 | **数据库模块**:支持 PostgreSQL、MySQL、SQLite,自动建库与迁移,灵活参数配置,统一管理入口,基于 GORM 实现。 42 | **缓存模块**:全局 Redis 客户端,连接池、超时、健康检查,统一管理,适用于缓存和分布式场景。 43 | **日志模块**:统一接口,结构化日志,分级、自动轮转,便于生产环境分析与追踪。 44 | **中间件与工具**:集成请求 ID、CORS、安全、恢复、Gzip、事务、验证码、参数校验、雪花 ID等常用功能,提升安全性与开发效率。 45 | **优雅启动与关闭**:支持信号优雅关闭,自动释放数据库和缓存资源,保障服务稳定性。 46 | **API与测试示例**:内置多组接口和测试用例,便于快速验证各模块功能和业务逻辑。 47 | 48 | ## 架构推荐 49 | 50 | ### 经典三层架构 51 | 52 | ```bash 53 | ./pkg 54 | │ ├── ./pkg/router 55 | │ │ ├── ./pkg/router/routes # 路由组 56 | │ │ │ ├── ./pkg/router/routes/test.go 57 | │ │ │ └── ./pkg/router/routes/user.go 58 | │ │ └── ./pkg/router/router.go 59 | │ ├── ./pkg/serve 60 | │ │ ├── ./pkg/serve/controller # controller 控制层 61 | │ │ │ ├── ./pkg/serve/controller/test 62 | │ │ │ │ └── ./pkg/serve/controller/test/test.go 63 | │ │ │ └── ./pkg/serve/controller/user 64 | │ │ │ ├── ./pkg/serve/controller/user/dto # dto 65 | │ │ │ │ └── ./pkg/serve/controller/user/dto/user.go 66 | │ │ │ └── ./pkg/serve/controller/user/user.go 67 | │ │ ├── ./pkg/serve/mapper # mapper 层 68 | │ │ │ └── ./pkg/serve/mapper/user 69 | │ │ │ ├── ./pkg/serve/mapper/user/impl 70 | │ │ │ │ └── ./pkg/serve/mapper/user/impl/user.go 71 | │ │ │ └── ./pkg/serve/mapper/user/user.go 72 | │ │ └── ./pkg/serve/service 73 | │ │ └── ./pkg/serve/service/user # service 服务层 74 | │ │ ├── ./pkg/serve/service/user/impl 75 | │ │ │ └── ./pkg/serve/service/user/impl/user.go 76 | │ │ └── ./pkg/serve/service/user/user.go 77 | │ └── ./pkg/vo 78 | │ ├── ./pkg/vo/user # vo 79 | │ │ └── ./pkg/vo/user/user.go 80 | │ └── ./pkg/vo/result.go 81 | ``` 82 | 83 | ## 贡献 84 | 85 | 欢迎 issue 和 PR! 86 | -------------------------------------------------------------------------------- /internal/db/internal/connection.go: -------------------------------------------------------------------------------- 1 | // Package internal provides database connection functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "gorm.io/driver/mysql" 13 | "gorm.io/driver/postgres" 14 | "gorm.io/driver/sqlite" 15 | "gorm.io/gorm" 16 | ) 17 | 18 | // connectToDB establishes a connection to the specified database 19 | func (m *Manager) connectToDB(dbName string) (*gorm.DB, error) { 20 | dialector, err := m.getDialector(dbName) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to get database dialector: %w", err) 23 | } 24 | 25 | db, err := gorm.Open(dialector, &gorm.Config{}) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to connect to database: %w", err) 28 | } 29 | 30 | sqlDB, err := db.DB() 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to get SQL database instance: %w", err) 33 | } 34 | 35 | sqlDB.SetMaxOpenConns(50) // Maximum number of open connections 36 | sqlDB.SetMaxIdleConns(20) // Maximum number of idle connections 37 | sqlDB.SetConnMaxLifetime(5 * time.Minute) // Maximum connection lifetime 38 | sqlDB.SetConnMaxIdleTime(1 * time.Minute) // Maximum idle time before closing 39 | 40 | return db, nil 41 | } 42 | 43 | // getDialector returns the appropriate database dialector based on configuration 44 | func (m *Manager) getDialector(dbName string) (gorm.Dialector, error) { 45 | switch m.config.DBConfig.DBDialect { 46 | case DialectPostgres: 47 | return m.getPostgresDialector(dbName), nil 48 | case DialectMySQL: 49 | return m.getMySQLDialector(dbName), nil 50 | case DialectSQLite: 51 | return m.getSQLiteDialector(dbName) 52 | default: 53 | return nil, fmt.Errorf("unsupported database dialect: %s", m.config.DBConfig.DBDialect) 54 | } 55 | } 56 | 57 | // getSQLiteDialector returns SQLite dialector and ensures directory exists 58 | func (m *Manager) getSQLiteDialector(dbName string) (gorm.Dialector, error) { 59 | if err := os.MkdirAll(m.config.DBConfig.DBPath, os.ModePerm); err != nil { 60 | return nil, fmt.Errorf("failed to create SQLite database directory: %w", err) 61 | } 62 | 63 | dbPath := filepath.Join(m.config.DBConfig.DBPath, dbName+".db") 64 | return sqlite.Open(dbPath), nil 65 | } 66 | 67 | // getPostgresDialector returns PostgreSQL dialector 68 | func (m *Manager) getPostgresDialector(dbName string) gorm.Dialector { 69 | dsn := fmt.Sprintf( 70 | "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai client_encoding=UTF8", 71 | m.config.DBConfig.DBHost, m.config.DBConfig.DBUser, m.config.DBConfig.DBPassword, dbName, m.config.DBConfig.DBPort, 72 | ) 73 | return postgres.Open(dsn) 74 | } 75 | 76 | // getMySQLDialector returns MySQL dialector 77 | func (m *Manager) getMySQLDialector(dbName string) gorm.Dialector { 78 | dsn := fmt.Sprintf( 79 | "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", 80 | m.config.DBConfig.DBUser, m.config.DBConfig.DBPassword, m.config.DBConfig.DBHost, m.config.DBConfig.DBPort, dbName, 81 | ) 82 | return mysql.Open(dsn) 83 | } 84 | -------------------------------------------------------------------------------- /cmd/gin_server.go: -------------------------------------------------------------------------------- 1 | // Package cmd provides application startup and runtime entry point 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package cmd 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/gin-gonic/gin" 17 | 18 | "github.com/Done-0/gin-scaffold/configs" 19 | "github.com/Done-0/gin-scaffold/internal/middleware" 20 | "github.com/Done-0/gin-scaffold/pkg/router" 21 | "github.com/Done-0/gin-scaffold/pkg/wire" 22 | ) 23 | 24 | // Start starts the HTTP server with graceful shutdown handling 25 | func Start() { 26 | if err := configs.New(); err != nil { 27 | log.Fatalf("Failed to initialize config: %v", err) 28 | } 29 | 30 | cfgs, err := configs.GetConfig() 31 | if err != nil { 32 | log.Fatalf("Failed to get config: %v", err) 33 | } 34 | 35 | container, err := wire.NewContainer(cfgs) 36 | if err != nil { 37 | log.Fatalf("Failed to initialize container: %v", err) 38 | } 39 | 40 | // Initialize managers 41 | if err := container.LoggerManager.Initialize(); err != nil { 42 | log.Fatalf("Failed to initialize logger: %v", err) 43 | } 44 | defer container.LoggerManager.Close() 45 | 46 | if err := container.DatabaseManager.Initialize(); err != nil { 47 | log.Fatalf("Failed to initialize database: %v", err) 48 | } 49 | defer container.DatabaseManager.Close() 50 | 51 | if err := container.RedisManager.Initialize(); err != nil { 52 | log.Fatalf("Failed to initialize Redis: %v", err) 53 | } 54 | defer container.RedisManager.Close() 55 | 56 | // MQ consumers 57 | go func() { 58 | // TODO: Add specific consumer startup logic here 59 | }() 60 | 61 | // Set Gin mode based on environment 62 | env := os.Getenv("ENV") 63 | switch env { 64 | case "prod", "production": 65 | gin.SetMode(gin.ReleaseMode) 66 | case "dev", "development": 67 | gin.SetMode(gin.DebugMode) 68 | default: 69 | gin.SetMode(gin.DebugMode) 70 | } 71 | 72 | // Create Gin engine 73 | r := gin.New() 74 | middleware.New(r, cfgs) 75 | router.New(r, container) 76 | 77 | // Create HTTP server 78 | serverAddr := fmt.Sprintf("%s:%s", cfgs.AppConfig.AppHost, cfgs.AppConfig.AppPort) 79 | srv := &http.Server{ 80 | Addr: serverAddr, 81 | Handler: r, 82 | } 83 | 84 | // Start server in goroutine 85 | go func() { 86 | log.Printf("⇨ Gin server starting on %s", serverAddr) 87 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 88 | log.Fatalf("Failed to start server: %v", err) 89 | } 90 | }() 91 | 92 | // Wait for interrupt signal to gracefully shutdown the server 93 | quit := make(chan os.Signal, 1) 94 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 95 | <-quit 96 | log.Println("Shutting down server...") 97 | 98 | // Graceful shutdown with timeout 99 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 100 | defer cancel() 101 | 102 | if err := srv.Shutdown(ctx); err != nil { 103 | log.Fatalf("Server forced to shutdown: %v", err) 104 | } 105 | 106 | log.Println("Server exited") 107 | } 108 | -------------------------------------------------------------------------------- /docs/api-reference.zh-CN.md: -------------------------------------------------------------------------------- 1 | # 接口文档 2 | 3 | ## 统一响应格式: 4 | 5 | - 正确响应: 6 | 7 | ```json 8 | { 9 | "data": any, 10 | "requestId": string, 11 | "timeStamp": number 12 | } 13 | ``` 14 | 15 | - 错误响应: 16 | 17 | ```json 18 | { 19 | "code": number, 20 | "msg": string, 21 | "data": any, 22 | "requestId": string, 23 | "timeStamp": number 24 | } 25 | ``` 26 | 27 | ## test 测试模块 28 | 29 | 1. **testPing** 测试接口 30 | - 请求方式:GET 31 | - 请求路径:/api/v1/test/testPing 32 | - 请求参数:无 33 | - 响应示例: 34 | ```json 35 | { 36 | "data": { 37 | "time": "2025-09-26T01:46:57+08:00", 38 | "message": "Pong successfully!" 39 | }, 40 | "requestId": "01d01617-cb23-46ec-85f1-777eeba3377c", 41 | "timeStamp": 1758822417 42 | } 43 | ``` 44 | 2. **testHello** 测试接口 45 | - 请求方式:GET 46 | - 请求路径:/api/v1/test/testHello 47 | - 请求参数:无 48 | - 响应示例: 49 | ```json 50 | { 51 | "data": { 52 | "version": "1.0.0", 53 | "message": "Hello, gin-scaffold! 🎉!" 54 | }, 55 | "requestId": "b42eb8af-b48d-48cd-8c15-f3cd52860d11", 56 | "timeStamp": 1758822421 57 | } 58 | ``` 59 | 3. **testLogger** 测试接口 60 | - 请求方式:GET 61 | - 请求路径:/api/v1/test/testLogger 62 | - 请求参数:无 63 | - 响应示例: 64 | ```json 65 | { 66 | "data": { 67 | "level": "info", 68 | "message": "Log test succeeded!" 69 | }, 70 | "requestId": "a74cfa1d-c313-45c4-bc1d-0a0c998d3e60", 71 | "timeStamp": 1758822424 72 | } 73 | ``` 74 | 4. **testRedis** 测试接口 75 | - 请求方式:POST 76 | - 请求路径:/api/v1/test/testRedis 77 | - 请求参数: 78 | ```json 79 | { 80 | "key": "test", 81 | "value": "hello", 82 | "ttl": 60 83 | } 84 | ``` 85 | - 响应示例: 86 | ```json 87 | { 88 | "data": { 89 | "key": "test", 90 | "value": "hello", 91 | "ttl": 60, 92 | "message": "Cache functionality test completed!" 93 | }, 94 | "requestId": "XtZvqFlDtpgzwEAesJpFMGgJQRbQDXyM", 95 | "timeStamp": 1740118491 96 | } 97 | ``` 98 | 5. **testSuccessRes** 测试接口 99 | - 请求方式:GET 100 | - 请求路径:/api/v1/test/testSuccessRes 101 | - 请求参数:无 102 | - 响应示例: 103 | ```json 104 | { 105 | "data": { 106 | "status": "success", 107 | "message": "Successful response validation passed!" 108 | }, 109 | "requestId": "7f114931-51bc-47d5-922f-208ca9d86445", 110 | "timeStamp": 1758822431 111 | } 112 | ``` 113 | 6. **testErrRes** 测试接口 114 | - 请求方式:GET 115 | - 请求路径:/api/v1/test/testErrRes 116 | - 请求参数:无 117 | - 响应示例: 118 | ```json 119 | { 120 | "data": { 121 | "code": 10001, 122 | "message": "Server exception" 123 | }, 124 | "requestId": "79768196-75cc-4b9e-8286-998a4bd4218b", 125 | "timeStamp": 1758822435 126 | } 127 | ``` 128 | 7. **testErrorMiddleware** 测试接口 129 | 130 | - 请求方式:GET 131 | - 请求路径:/api/v1/test/testErrorMiddleware 132 | - 请求参数:无 133 | - 响应示例:Recovery 中间件处理 panic 并返回空响应 134 | 135 | 8. **testLongReq** 测试接口 136 | - 请求方式:POST 137 | - 请求路径:/api/v2/test/testLongReq 138 | - 请求参数: 139 | ```json 140 | { 141 | "duration": 3 142 | } 143 | ``` 144 | - 响应示例: 145 | ```json 146 | { 147 | "data": { 148 | "duration": 3, 149 | "message": "Simulated long-running request completed!" 150 | }, 151 | "requestId": "caecc92a-0e04-4b4a-ac9e-cdbba2cc34ad", 152 | "timeStamp": 1758822445 153 | } 154 | ``` 155 | -------------------------------------------------------------------------------- /internal/logger/internal/manager.go: -------------------------------------------------------------------------------- 1 | // Package internal provides logger management functionality 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "path" 12 | "time" 13 | 14 | "github.com/rifflock/lfshook" 15 | "github.com/sirupsen/logrus" 16 | 17 | "github.com/Done-0/gin-scaffold/configs" 18 | 19 | rotateLogs "github.com/lestrrat-go/file-rotatelogs" 20 | ) 21 | 22 | // Manager represents a logger manager with dependency injection 23 | type Manager struct { 24 | config *configs.Config 25 | logger *logrus.Logger 26 | logFile io.Closer 27 | } 28 | 29 | // NewManager creates a new logger manager instance 30 | func NewManager(config *configs.Config) (*Manager, error) { 31 | return &Manager{ 32 | config: config, 33 | }, nil 34 | } 35 | 36 | // Logger returns the logger instance 37 | func (m *Manager) Logger() *logrus.Logger { 38 | return m.logger 39 | } 40 | 41 | // Initialize sets up the logger system 42 | func (m *Manager) Initialize() error { 43 | logFilePath := m.config.LogConfig.LogFilePath 44 | logFileName := m.config.LogConfig.LogFileName 45 | fileName := path.Join(logFilePath, logFileName) 46 | _ = os.MkdirAll(logFilePath, 0755) 47 | 48 | // Initialize logger 49 | formatter := &logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"} 50 | m.logger = logrus.New() 51 | m.logger.SetFormatter(formatter) 52 | 53 | // Set log level 54 | if logLevel, err := logrus.ParseLevel(m.config.LogConfig.LogLevel); err == nil { 55 | m.logger.SetLevel(logLevel) 56 | } else { 57 | m.logger.SetLevel(logrus.InfoLevel) 58 | } 59 | 60 | // Configure log rotation 61 | writer, err := rotateLogs.New( 62 | path.Join(logFilePath, "%Y%m%d.log"), 63 | rotateLogs.WithLinkName(fileName), 64 | rotateLogs.WithMaxAge(time.Duration(m.config.LogConfig.LogMaxAge)*24*time.Hour), 65 | rotateLogs.WithRotationTime(24*time.Hour), 66 | ) 67 | 68 | if err != nil { 69 | log.Printf("Failed to initialize log file rotation: %v, using standard output", err) 70 | fileHandle, fileErr := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0755) 71 | 72 | if fileErr != nil { 73 | log.Printf("Failed to create log file: %v, using standard output", fileErr) 74 | m.logger.SetOutput(os.Stdout) 75 | m.logFile = nil 76 | } else { 77 | m.logger.SetOutput(io.MultiWriter(os.Stdout, fileHandle)) 78 | m.logFile = fileHandle 79 | } 80 | } else { 81 | allLevels := []logrus.Level{ 82 | logrus.PanicLevel, 83 | logrus.FatalLevel, 84 | logrus.ErrorLevel, 85 | logrus.WarnLevel, 86 | logrus.InfoLevel, 87 | logrus.DebugLevel, 88 | logrus.TraceLevel, 89 | } 90 | 91 | writeMap := make(lfshook.WriterMap, len(allLevels)) 92 | for _, level := range allLevels { 93 | writeMap[level] = writer 94 | } 95 | 96 | m.logger.AddHook(lfshook.NewHook(writeMap, formatter)) 97 | m.logger.SetOutput(os.Stdout) 98 | m.logFile = writer 99 | } 100 | 101 | log.Println("Logger system initialized successfully") 102 | return nil 103 | } 104 | 105 | // Close closes the logger system 106 | func (m *Manager) Close() error { 107 | if m.logger == nil { 108 | return nil 109 | } 110 | 111 | m.logger.ReplaceHooks(make(logrus.LevelHooks)) 112 | 113 | if m.logFile != nil { 114 | if err := m.logFile.Close(); err != nil { 115 | return fmt.Errorf("failed to close log file: %w", err) 116 | } 117 | } 118 | 119 | m.logger = nil 120 | m.logFile = nil 121 | 122 | log.Println("Logger system closed successfully") 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/utils/vo/vo_utils_test.go: -------------------------------------------------------------------------------- 1 | // Package vo provides common value objects test 2 | // Author: Done-0 3 | // Created: 2025-11-27 4 | package vo 5 | 6 | import ( 7 | "errors" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gin-contrib/requestid" 12 | "github.com/gin-gonic/gin" 13 | "github.com/stretchr/testify/assert" 14 | 15 | "github.com/Done-0/gin-scaffold/internal/types/errno" 16 | "github.com/Done-0/gin-scaffold/internal/utils/errorx" 17 | ) 18 | 19 | func init() { 20 | gin.SetMode(gin.TestMode) 21 | errorx.Register(int32(errno.ErrInvalidParams), "invalid parameter: {{msg}}") 22 | errorx.Register(int32(errno.ErrInternalServer), "internal server error: {{msg}}") 23 | } 24 | 25 | func setupTestContext() *gin.Context { 26 | w := httptest.NewRecorder() 27 | c, router := gin.CreateTestContext(w) 28 | router.Use(requestid.New()) 29 | c.Request = httptest.NewRequest("GET", "/test", nil) 30 | requestid.New()(c) 31 | return c 32 | } 33 | 34 | func TestSuccess(t *testing.T) { 35 | tests := []struct { 36 | name string 37 | data any 38 | wantData any 39 | }{ 40 | {"string data", "test data", "test data"}, 41 | {"map data", map[string]string{"key": "value"}, map[string]string{"key": "value"}}, 42 | {"error data", errors.New("error message"), "error message"}, 43 | {"nil data", nil, nil}, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | c := setupTestContext() 49 | result := Success(c, tt.data) 50 | 51 | assert.Equal(t, tt.wantData, result.Data) 52 | assert.Nil(t, result.Error) 53 | assert.NotEmpty(t, result.RequestId) 54 | assert.Greater(t, result.TimeStamp, int64(0)) 55 | }) 56 | } 57 | } 58 | 59 | func TestFail(t *testing.T) { 60 | tests := []struct { 61 | name string 62 | data any 63 | err error 64 | wantCode string 65 | }{ 66 | { 67 | name: "StatusError without params", 68 | data: nil, 69 | err: errorx.New(int32(errno.ErrInvalidParams)), 70 | wantCode: "10002", 71 | }, 72 | { 73 | name: "StatusError with params", 74 | data: nil, 75 | err: errorx.New(int32(errno.ErrInvalidParams), errorx.KV("msg", "username required")), 76 | wantCode: "10002", 77 | }, 78 | { 79 | name: "regular error", 80 | data: nil, 81 | err: errors.New("something went wrong"), 82 | wantCode: "10001", 83 | }, 84 | { 85 | name: "error data with regular error", 86 | data: errors.New("data error"), 87 | err: errors.New("system error"), 88 | wantCode: "10001", 89 | }, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | c := setupTestContext() 95 | result := Fail(c, tt.data, tt.err) 96 | 97 | assert.NotNil(t, result.Error) 98 | assert.Equal(t, tt.wantCode, result.Error.Code) 99 | assert.NotEmpty(t, result.Error.Message) 100 | assert.NotEmpty(t, result.RequestId) 101 | assert.Greater(t, result.TimeStamp, int64(0)) 102 | }) 103 | } 104 | } 105 | 106 | func TestResultStructure(t *testing.T) { 107 | c := setupTestContext() 108 | 109 | t.Run("Success structure", func(t *testing.T) { 110 | result := Success(c, "test") 111 | 112 | assert.NotEmpty(t, result.RequestId) 113 | assert.Greater(t, result.TimeStamp, int64(0)) 114 | assert.Nil(t, result.Error) 115 | assert.Equal(t, "test", result.Data) 116 | }) 117 | 118 | t.Run("Fail structure", func(t *testing.T) { 119 | result := Fail(c, nil, errors.New("test error")) 120 | 121 | assert.NotEmpty(t, result.RequestId) 122 | assert.Greater(t, result.TimeStamp, int64(0)) 123 | assert.NotNil(t, result.Error) 124 | assert.NotEmpty(t, result.Error.Code) 125 | assert.NotEmpty(t, result.Error.Message) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /docs/api-reference.en-US.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ## Unified Response Format 4 | 5 | - Successful Response: 6 | 7 | ```json 8 | { 9 | "data": any, 10 | "requestId": string, 11 | "timeStamp": number 12 | } 13 | ``` 14 | 15 | - Error Response: 16 | 17 | ```json 18 | { 19 | "code": number, 20 | "msg": string, 21 | "data": any, 22 | "requestId": string, 23 | "timeStamp": number 24 | } 25 | ``` 26 | 27 | ## test Module 28 | 29 | 1. **testPing** Test Endpoint 30 | - HTTP Method: GET 31 | - Request Path: /api/v1/test/testPing 32 | - Request Parameters: None 33 | - Response Example: 34 | ```json 35 | { 36 | "data": { 37 | "time": "2025-09-26T01:46:57+08:00", 38 | "message": "Pong successfully!" 39 | }, 40 | "requestId": "01d01617-cb23-46ec-85f1-777eeba3377c", 41 | "timeStamp": 1758822417 42 | } 43 | ``` 44 | 2. **testHello** Test Endpoint 45 | - HTTP Method: GET 46 | - Request Path: /api/v1/test/testHello 47 | - Request Parameters: None 48 | - Response Example: 49 | ```json 50 | { 51 | "data": { 52 | "version": "1.0.0", 53 | "message": "Hello, gin-scaffold! 🎉!" 54 | }, 55 | "requestId": "b42eb8af-b48d-48cd-8c15-f3cd52860d11", 56 | "timeStamp": 1758822421 57 | } 58 | ``` 59 | 3. **testLogger** Test Endpoint 60 | - HTTP Method: GET 61 | - Request Path: /api/v1/test/testLogger 62 | - Request Parameters: None 63 | - Response Example: 64 | ```json 65 | { 66 | "data": { 67 | "level": "info", 68 | "message": "Log test succeeded!" 69 | }, 70 | "requestId": "a74cfa1d-c313-45c4-bc1d-0a0c998d3e60", 71 | "timeStamp": 1758822424 72 | } 73 | ``` 74 | 4. **testRedis** Test Endpoint 75 | - HTTP Method: POST 76 | - Request Path: /api/v1/test/testRedis 77 | - Request Parameters: 78 | ```json 79 | { 80 | "key": "test", 81 | "value": "hello", 82 | "ttl": 60 83 | } 84 | ``` 85 | - Response Example: 86 | ```json 87 | { 88 | "data": { 89 | "key": "test", 90 | "value": "hello", 91 | "ttl": 60, 92 | "message": "Cache functionality test completed!" 93 | }, 94 | "requestId": "XtZvqFlDtpgzwEAesJpFMGgJQRbQDXyM", 95 | "timeStamp": 1740118491 96 | } 97 | ``` 98 | 5. **testSuccessRes** Test Endpoint 99 | - HTTP Method: GET 100 | - Request Path: /api/v1/test/testSuccessRes 101 | - Request Parameters: None 102 | - Response Example: 103 | ```json 104 | { 105 | "data": { 106 | "status": "success", 107 | "message": "Successful response validation passed!" 108 | }, 109 | "requestId": "7f114931-51bc-47d5-922f-208ca9d86445", 110 | "timeStamp": 1758822431 111 | } 112 | ``` 113 | 6. **testErrRes** Test Endpoint 114 | - HTTP Method: GET 115 | - Request Path: /api/v1/test/testErrRes 116 | - Request Parameters: None 117 | - Response Example: 118 | ```json 119 | { 120 | "data": { 121 | "code": 10001, 122 | "message": "Server exception" 123 | }, 124 | "requestId": "79768196-75cc-4b9e-8286-998a4bd4218b", 125 | "timeStamp": 1758822435 126 | } 127 | ``` 128 | 7. **testErrorMiddleware** Test Endpoint 129 | 130 | - HTTP Method: GET 131 | - Request Path: /api/v1/test/testErrorMiddleware 132 | - Request Parameters: None 133 | - Response Example: Recovery middleware handles panic and returns empty response 134 | 135 | 8. **testLongReq** Test Endpoint 136 | - HTTP Method: POST 137 | - Request Path: /api/v2/test/testLongReq 138 | - Request Parameters: 139 | ```json 140 | { 141 | "duration": 3 142 | } 143 | ``` 144 | - Response Example: 145 | ```json 146 | { 147 | "data": { 148 | "duration": 3, 149 | "message": "Simulated long-running request completed!" 150 | }, 151 | "requestId": "caecc92a-0e04-4b4a-ac9e-cdbba2cc34ad", 152 | "timeStamp": 1758822445 153 | } 154 | ``` 155 | -------------------------------------------------------------------------------- /configs/config.local.yml: -------------------------------------------------------------------------------- 1 | # 应用相关 2 | APP: 3 | APP_NAME: "gin-scaffold" 4 | APP_HOST: "127.0.0.1" # 如果使用 docker,则改为"0.0.0.0" 5 | APP_PORT: "8080" 6 | # CORS 跨域相关 7 | CORS: 8 | ALLOW_ORIGINS: ["*"] # 允许的源,生产环境应指定具体域名 9 | ALLOW_METHODS: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] # 允许的HTTP方法 10 | ALLOW_HEADERS: ["Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"] # 允许的请求头 11 | EXPOSE_HEADERS: ["Content-Length", "Authorization"] # 暴露的响应头 12 | ALLOW_CREDENTIALS: true # 是否允许携带凭证 13 | MAX_AGE: 12 # 预检请求缓存时间(小时) 14 | # 邮箱相关 15 | EMAIL: 16 | EMAIL_TYPE: "qq" # 支持的邮箱类型: qq, gmail, outlook 17 | FROM_EMAIL: "" # 发件人邮箱 18 | EMAIL_SMTP: "" # SMTP 授权码 19 | # JWT 认证相关 20 | JWT: 21 | SECRET: "gin-scaffold-jwt-secret-key" # JWT 签名密钥 22 | EXPIRE_TIME: 2 # Token 有效期(小时) 23 | REFRESH_EXPIRE: 48 # 刷新Token有效期(小时) 24 | # 用户角色相关 25 | USER: 26 | # 管理员账户配置 27 | SUPER_ADMIN_EMAIL: "" # 管理员邮箱 28 | SUPER_ADMIN_PASSWORD: "123456" # 管理员密码 29 | SUPER_ADMIN_NICKNAME: "超级管理员" # 管理员昵称 30 | 31 | # 数据库相关 32 | DATABASE: 33 | DB_DIALECT: "postgres" # 数据库类型, 可选值: postgres, mysql, sqlite 34 | DB_NAME: "gin-scaffold_db" 35 | DB_HOST: "127.0.0.1" # Docker 服务名 36 | DB_PORT: "5432" 37 | DB_USER: "root" 38 | DB_PSW: "123456" 39 | DB_PATH: "./database" # SQLite 数据库文件路径 40 | 41 | # Redis 相关 42 | REDIS: 43 | REDIS_HOST: "127.0.0.1" # 如果使用 docker,则改为"redis" 44 | REDIS_PORT: "6379" 45 | REDIS_PSW: "" 46 | REDIS_DB: "0" 47 | # 连接池配置(重启时生效) 48 | POOL_SIZE: 10 # 最大连接池大小 49 | MIN_IDLE_CONNS: 5 # 最小空闲连接数 50 | DIAL_TIMEOUT: 10 # 连接超时(秒) 51 | READ_TIMEOUT: 1 # 读取超时(秒) 52 | WRITE_TIMEOUT: 2 # 写入超时(秒) 53 | 54 | # 日志相关 55 | LOG: 56 | LOG_FILE_PATH: ".logs/" 57 | LOG_FILE_NAME: "app.log" 58 | LOG_TIMESTAMP_FMT: "2006-01-02 15:04:05" 59 | LOG_MAX_AGE: "72" 60 | LOG_ROTATION_TIME: "24" 61 | LOG_LEVEL: "INFO" 62 | 63 | # Kafka 消息队列相关 64 | KAFKA: 65 | BROKERS: ["localhost:9092"] # Kafka 集群地址 66 | CONSUMER_GROUP: "trading-robot-dev" # 消费者组名称 67 | 68 | # AI 服务相关 69 | AI: 70 | PROMPT: 71 | DIR: "./configs/prompts" # 模板文件目录 72 | PROVIDERS: 73 | openai: 74 | ENABLED: true # 是否启用该提供商 75 | INSTANCES: # 多个OpenAI兼容实例 76 | # 阿里云百炼 DeepSeek 77 | - NAME: "dashscope" 78 | ENABLED: false 79 | BASE_URL: "https://dashscope.aliyuncs.com/compatible-mode/v1" 80 | KEYS: 81 | - "YOUR_API_KEY" 82 | MODELS: 83 | - "deepseek-r1-distill-llama-70b" 84 | MAX_TOKENS: 16384 # 最大输出token数量 85 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 86 | TOP_P: 0.90 # 核采样 (0.0-1.0) 87 | TIMEOUT: 720 88 | MAX_RETRIES: 1 89 | RATE_LIMIT: "60/min" 90 | 91 | # 硅基流动 DeepSeek 92 | - NAME: "siliconflow" 93 | ENABLED: true 94 | BASE_URL: "https://api.siliconflow.cn/v1" 95 | KEYS: 96 | - "YOUR_API_KEY" 97 | MODELS: 98 | - "deepseek-ai/DeepSeek-V3.2-Exp" 99 | MAX_TOKENS: 32768 # 最大输出token数量 100 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 101 | TOP_P: 0.90 # 核采样 (0.0-1.0) 102 | TIMEOUT: 720 # AI响应超时时间(秒) 103 | MAX_RETRIES: 1 104 | RATE_LIMIT: "60/min" 105 | 106 | gemini: 107 | ENABLED: false # 是否启用该提供商 108 | INSTANCES: 109 | # Google Gemini 110 | - NAME: "official" 111 | ENABLED: true # 是否启用此实例 112 | BASE_URL: "https://generativelanguage.googleapis.com" 113 | KEYS: 114 | - "YOUR_API_KEY" 115 | - "YOUR_API_KEY" 116 | - "YOUR_API_KEY" 117 | MODELS: 118 | - "gemini-2.5-pro" 119 | MAX_TOKENS: 32768 # 最大输出 token 数量 120 | TEMPERATURE: 0.40 # 采样温度 (0.0-2.0) 121 | TOP_P: 0.90 # 核采样 (0.0-1.0) 122 | TOP_K: 40 # 限制候选词数量 123 | TIMEOUT: 1080 124 | MAX_RETRIES: 1 125 | RATE_LIMIT: "60/min" 126 | -------------------------------------------------------------------------------- /configs/config.prod.yml: -------------------------------------------------------------------------------- 1 | # 应用相关 2 | APP: 3 | APP_NAME: "gin-scaffold" 4 | APP_HOST: "127.0.0.1" # 如果使用 docker,则改为"0.0.0.0" 5 | APP_PORT: "8080" 6 | # CORS 跨域相关 7 | CORS: 8 | ALLOW_ORIGINS: ["*"] # 允许的源,生产环境应指定具体域名 9 | ALLOW_METHODS: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] # 允许的HTTP方法 10 | ALLOW_HEADERS: ["Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"] # 允许的请求头 11 | EXPOSE_HEADERS: ["Content-Length", "Authorization"] # 暴露的响应头 12 | ALLOW_CREDENTIALS: true # 是否允许携带凭证 13 | MAX_AGE: 12 # 预检请求缓存时间(小时) 14 | # 邮箱相关 15 | EMAIL: 16 | EMAIL_TYPE: "qq" # 支持的邮箱类型: qq, gmail, outlook 17 | FROM_EMAIL: "" # 发件人邮箱 18 | EMAIL_SMTP: "" # SMTP 授权码 19 | # JWT 认证相关 20 | JWT: 21 | SECRET: "gin-scaffold-jwt-secret-key" # JWT 签名密钥 22 | EXPIRE_TIME: 2 # Token 有效期(小时) 23 | REFRESH_EXPIRE: 48 # 刷新Token有效期(小时) 24 | # 用户角色相关 25 | USER: 26 | # 管理员账户配置 27 | SUPER_ADMIN_EMAIL: "" # 管理员邮箱 28 | SUPER_ADMIN_PASSWORD: "123456" # 管理员密码 29 | SUPER_ADMIN_NICKNAME: "超级管理员" # 管理员昵称 30 | 31 | # 数据库相关 32 | DATABASE: 33 | DB_DIALECT: "postgres" # 数据库类型, 可选值: postgres, mysql, sqlite 34 | DB_NAME: "gin-scaffold_db" 35 | DB_HOST: "postgres" # Docker 服务名 36 | DB_PORT: "5432" 37 | DB_USER: "root" 38 | DB_PSW: "123456" 39 | DB_PATH: "./database" # SQLite 数据库文件路径 40 | 41 | # Redis 相关 42 | REDIS: 43 | REDIS_HOST: "127.0.0.1" # 如果使用 docker,则改为"redis" 44 | REDIS_PORT: "6379" 45 | REDIS_PSW: "" 46 | REDIS_DB: "0" 47 | # 连接池配置(重启时生效) 48 | POOL_SIZE: 10 # 最大连接池大小 49 | MIN_IDLE_CONNS: 5 # 最小空闲连接数 50 | DIAL_TIMEOUT: 10 # 连接超时(秒) 51 | READ_TIMEOUT: 1 # 读取超时(秒) 52 | WRITE_TIMEOUT: 2 # 写入超时(秒) 53 | 54 | # 日志相关 55 | LOG: 56 | LOG_FILE_PATH: ".logs/" 57 | LOG_FILE_NAME: "app.log" 58 | LOG_TIMESTAMP_FMT: "2006-01-02 15:04:05" 59 | LOG_MAX_AGE: "72" 60 | LOG_ROTATION_TIME: "24" 61 | LOG_LEVEL: "INFO" 62 | 63 | # Kafka 消息队列相关 64 | KAFKA: 65 | BROKERS: ["localhost:9092"] # Kafka 集群地址 66 | CONSUMER_GROUP: "trading-robot-dev" # 消费者组名称 67 | 68 | # AI 服务相关 69 | AI: 70 | PROMPT: 71 | DIR: "./configs/prompts" # 模板文件目录 72 | PROVIDERS: 73 | openai: 74 | ENABLED: true # 是否启用该提供商 75 | INSTANCES: # 多个OpenAI兼容实例 76 | # 阿里云百炼 DeepSeek 77 | - NAME: "dashscope" 78 | ENABLED: false 79 | BASE_URL: "https://dashscope.aliyuncs.com/compatible-mode/v1" 80 | KEYS: 81 | - "YOUR_API_KEY" 82 | MODELS: 83 | - "deepseek-r1-distill-llama-70b" 84 | MAX_TOKENS: 16384 # 最大输出token数量 85 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 86 | TOP_P: 0.90 # 核采样 (0.0-1.0) 87 | TIMEOUT: 720 88 | MAX_RETRIES: 1 89 | RATE_LIMIT: "60/min" 90 | 91 | # 硅基流动 DeepSeek 92 | - NAME: "siliconflow" 93 | ENABLED: true 94 | BASE_URL: "https://api.siliconflow.cn/v1" 95 | KEYS: 96 | - "YOUR_API_KEY" 97 | MODELS: 98 | - "deepseek-ai/DeepSeek-V3.2-Exp" 99 | MAX_TOKENS: 32768 # 最大输出token数量 100 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 101 | TOP_P: 0.90 # 核采样 (0.0-1.0) 102 | TIMEOUT: 720 # AI响应超时时间(秒) 103 | MAX_RETRIES: 1 104 | RATE_LIMIT: "60/min" 105 | 106 | gemini: 107 | ENABLED: false # 是否启用该提供商 108 | INSTANCES: 109 | # Google Gemini 110 | - NAME: "official" 111 | ENABLED: true # 是否启用此实例 112 | BASE_URL: "https://generativelanguage.googleapis.com" 113 | KEYS: 114 | - "YOUR_API_KEY" 115 | - "YOUR_API_KEY" 116 | - "YOUR_API_KEY" 117 | MODELS: 118 | - "gemini-2.5-pro" 119 | - "gemini-2.5-flash" 120 | MAX_TOKENS: 32768 # 最大输出 token 数量 121 | TEMPERATURE: 0.40 # 采样温度 (0.0-2.0) 122 | TOP_P: 0.90 # 核采样 (0.0-1.0) 123 | TOP_K: 40 # 限制候选词数量 124 | TIMEOUT: 1080 125 | MAX_RETRIES: 1 126 | RATE_LIMIT: "60/min" 127 | -------------------------------------------------------------------------------- /configs/config.local.example.yml: -------------------------------------------------------------------------------- 1 | # 应用相关 2 | APP: 3 | APP_NAME: "gin-scaffold" 4 | APP_HOST: "127.0.0.1" # 如果使用 docker,则改为"0.0.0.0" 5 | APP_PORT: "8080" 6 | # CORS 跨域相关 7 | CORS: 8 | ALLOW_ORIGINS: ["*"] # 允许的源,生产环境应指定具体域名 9 | ALLOW_METHODS: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] # 允许的HTTP方法 10 | ALLOW_HEADERS: ["Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"] # 允许的请求头 11 | EXPOSE_HEADERS: ["Content-Length", "Authorization"] # 暴露的响应头 12 | ALLOW_CREDENTIALS: true # 是否允许携带凭证 13 | MAX_AGE: 12 # 预检请求缓存时间(小时) 14 | # 邮箱相关 15 | EMAIL: 16 | EMAIL_TYPE: "qq" # 支持的邮箱类型: qq, gmail, outlook 17 | FROM_EMAIL: "" # 发件人邮箱 18 | EMAIL_SMTP: "" # SMTP 授权码 19 | # JWT 认证相关 20 | JWT: 21 | SECRET: "gin-scaffold-jwt-secret-key" # JWT 签名密钥 22 | EXPIRE_TIME: 2 # Token 有效期(小时) 23 | REFRESH_EXPIRE: 48 # 刷新Token有效期(小时) 24 | # 用户角色相关 25 | USER: 26 | # 管理员账户配置 27 | SUPER_ADMIN_EMAIL: "" # 管理员邮箱 28 | SUPER_ADMIN_PASSWORD: "123456" # 管理员密码 29 | SUPER_ADMIN_NICKNAME: "超级管理员" # 管理员昵称 30 | 31 | # 数据库相关 32 | DATABASE: 33 | DB_DIALECT: "postgres" # 数据库类型, 可选值: postgres, mysql, sqlite 34 | DB_NAME: "gin-scaffold_db" 35 | DB_HOST: "127.0.0.1" # Docker 服务名 36 | DB_PORT: "5432" 37 | DB_USER: "root" 38 | DB_PSW: "123456" 39 | DB_PATH: "./database" # SQLite 数据库文件路径 40 | 41 | # Redis 相关 42 | REDIS: 43 | REDIS_HOST: "127.0.0.1" # 如果使用 docker,则改为"redis" 44 | REDIS_PORT: "6379" 45 | REDIS_PSW: "" 46 | REDIS_DB: "0" 47 | # 连接池配置(重启时生效) 48 | POOL_SIZE: 10 # 最大连接池大小 49 | MIN_IDLE_CONNS: 5 # 最小空闲连接数 50 | DIAL_TIMEOUT: 10 # 连接超时(秒) 51 | READ_TIMEOUT: 1 # 读取超时(秒) 52 | WRITE_TIMEOUT: 2 # 写入超时(秒) 53 | 54 | # 日志相关 55 | LOG: 56 | LOG_FILE_PATH: ".logs/" 57 | LOG_FILE_NAME: "app.log" 58 | LOG_TIMESTAMP_FMT: "2006-01-02 15:04:05" 59 | LOG_MAX_AGE: "72" 60 | LOG_ROTATION_TIME: "24" 61 | LOG_LEVEL: "INFO" 62 | 63 | # Kafka 消息队列相关 64 | KAFKA: 65 | BROKERS: ["localhost:9092"] # Kafka 集群地址 66 | CONSUMER_GROUP: "trading-robot-dev" # 消费者组名称 67 | 68 | # AI 服务相关 69 | AI: 70 | PROMPT: 71 | DIR: "./configs/prompts" # 模板文件目录 72 | PROVIDERS: 73 | openai: 74 | ENABLED: true # 是否启用该提供商 75 | INSTANCES: # 多个OpenAI兼容实例 76 | # 阿里云百炼 DeepSeek 77 | - NAME: "dashscope" 78 | ENABLED: false 79 | BASE_URL: "https://dashscope.aliyuncs.com/compatible-mode/v1" 80 | KEYS: 81 | - "YOUR_API_KEY" 82 | - "YOUR_API_KEY" 83 | MODELS: 84 | - "deepseek-r1-distill-llama-70b" 85 | MAX_TOKENS: 16384 # 最大输出token数量 86 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 87 | TOP_P: 0.90 # 核采样 (0.0-1.0) 88 | TIMEOUT: 720 89 | MAX_RETRIES: 1 90 | RATE_LIMIT: "60/min" 91 | 92 | # 硅基流动 DeepSeek 93 | - NAME: "siliconflow" 94 | ENABLED: true 95 | BASE_URL: "https://api.siliconflow.cn/v1" 96 | KEYS: 97 | - "YOUR_API_KEY" 98 | MODELS: 99 | - "deepseek-ai/DeepSeek-V3.2-Exp" 100 | MAX_TOKENS: 32768 # 最大输出token数量 101 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 102 | TOP_P: 0.90 # 核采样 (0.0-1.0) 103 | TIMEOUT: 720 # AI响应超时时间(秒) 104 | MAX_RETRIES: 1 105 | RATE_LIMIT: "60/min" 106 | 107 | gemini: 108 | ENABLED: false # 是否启用该提供商 109 | INSTANCES: 110 | # Google Gemini 111 | - NAME: "official" 112 | ENABLED: true # 是否启用此实例 113 | BASE_URL: "https://generativelanguage.googleapis.com" 114 | KEYS: 115 | - "YOUR_API_KEY" 116 | - "YOUR_API_KEY" 117 | - "YOUR_API_KEY" 118 | MODELS: 119 | - "gemini-2.5-pro" 120 | MAX_TOKENS: 32768 # 最大输出 token 数量 121 | TEMPERATURE: 0.40 # 采样温度 (0.0-2.0) 122 | TOP_P: 0.90 # 核采样 (0.0-1.0) 123 | TOP_K: 40 # 限制候选词数量 124 | TIMEOUT: 1080 125 | MAX_RETRIES: 1 126 | RATE_LIMIT: "60/min" 127 | -------------------------------------------------------------------------------- /configs/config.prod.example.yml: -------------------------------------------------------------------------------- 1 | # 应用相关 2 | APP: 3 | APP_NAME: "gin-scaffold" 4 | APP_HOST: "127.0.0.1" # 如果使用 docker,则改为"0.0.0.0" 5 | APP_PORT: "8080" 6 | # CORS 跨域相关 7 | CORS: 8 | ALLOW_ORIGINS: ["*"] # 允许的源,生产环境应指定具体域名 9 | ALLOW_METHODS: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] # 允许的HTTP方法 10 | ALLOW_HEADERS: ["Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"] # 允许的请求头 11 | EXPOSE_HEADERS: ["Content-Length", "Authorization"] # 暴露的响应头 12 | ALLOW_CREDENTIALS: true # 是否允许携带凭证 13 | MAX_AGE: 12 # 预检请求缓存时间(小时) 14 | # 邮箱相关 15 | EMAIL: 16 | EMAIL_TYPE: "qq" # 支持的邮箱类型: qq, gmail, outlook 17 | FROM_EMAIL: "" # 发件人邮箱 18 | EMAIL_SMTP: "" # SMTP 授权码 19 | # JWT 认证相关 20 | JWT: 21 | SECRET: "gin-scaffold-jwt-secret-key" # JWT 签名密钥 22 | EXPIRE_TIME: 2 # Token 有效期(小时) 23 | REFRESH_EXPIRE: 48 # 刷新Token有效期(小时) 24 | # 用户角色相关 25 | USER: 26 | # 管理员账户配置 27 | SUPER_ADMIN_EMAIL: "" # 管理员邮箱 28 | SUPER_ADMIN_PASSWORD: "123456" # 管理员密码 29 | SUPER_ADMIN_NICKNAME: "超级管理员" # 管理员昵称 30 | 31 | # 数据库相关 32 | DATABASE: 33 | DB_DIALECT: "postgres" # 数据库类型, 可选值: postgres, mysql, sqlite 34 | DB_NAME: "gin-scaffold_db" 35 | DB_HOST: "postgres" # Docker 服务名 36 | DB_PORT: "5432" 37 | DB_USER: "root" 38 | DB_PSW: "123456" 39 | DB_PATH: "./database" # SQLite 数据库文件路径 40 | 41 | # Redis 相关 42 | REDIS: 43 | REDIS_HOST: "127.0.0.1" # 如果使用 docker,则改为"redis" 44 | REDIS_PORT: "6379" 45 | REDIS_PSW: "" 46 | REDIS_DB: "0" 47 | # 连接池配置(重启时生效) 48 | POOL_SIZE: 10 # 最大连接池大小 49 | MIN_IDLE_CONNS: 5 # 最小空闲连接数 50 | DIAL_TIMEOUT: 10 # 连接超时(秒) 51 | READ_TIMEOUT: 1 # 读取超时(秒) 52 | WRITE_TIMEOUT: 2 # 写入超时(秒) 53 | 54 | # 日志相关 55 | LOG: 56 | LOG_FILE_PATH: ".logs/" 57 | LOG_FILE_NAME: "app.log" 58 | LOG_TIMESTAMP_FMT: "2006-01-02 15:04:05" 59 | LOG_MAX_AGE: "72" 60 | LOG_ROTATION_TIME: "24" 61 | LOG_LEVEL: "INFO" 62 | 63 | # Kafka 消息队列相关 64 | KAFKA: 65 | BROKERS: ["localhost:9092"] # Kafka 集群地址 66 | CONSUMER_GROUP: "trading-robot-dev" # 消费者组名称 67 | 68 | # AI 服务相关 69 | AI: 70 | PROMPT: 71 | DIR: "./configs/prompts" # 模板文件目录 72 | PROVIDERS: 73 | openai: 74 | ENABLED: true # 是否启用该提供商 75 | INSTANCES: # 多个OpenAI兼容实例 76 | # 阿里云百炼 DeepSeek 77 | - NAME: "dashscope" 78 | ENABLED: false 79 | BASE_URL: "https://dashscope.aliyuncs.com/compatible-mode/v1" 80 | KEYS: 81 | - "YOUR_API_KEY" 82 | MODELS: 83 | - "deepseek-r1-distill-llama-70b" 84 | MAX_TOKENS: 16384 # 最大输出token数量 85 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 86 | TOP_P: 0.90 # 核采样 (0.0-1.0) 87 | TIMEOUT: 720 88 | MAX_RETRIES: 1 89 | RATE_LIMIT: "60/min" 90 | 91 | # 硅基流动 DeepSeek 92 | - NAME: "siliconflow" 93 | ENABLED: true 94 | BASE_URL: "https://api.siliconflow.cn/v1" 95 | KEYS: 96 | - "YOUR_API_KEY" 97 | MODELS: 98 | - "deepseek-ai/DeepSeek-V3.2-Exp" 99 | MAX_TOKENS: 32768 # 最大输出token数量 100 | TEMPERATURE: 0.45 # 采样温度 (0.0-2.0) 101 | TOP_P: 0.90 # 核采样 (0.0-1.0) 102 | TIMEOUT: 720 # AI响应超时时间(秒) 103 | MAX_RETRIES: 1 104 | RATE_LIMIT: "60/min" 105 | 106 | gemini: 107 | ENABLED: false # 是否启用该提供商 108 | INSTANCES: 109 | # Google Gemini 110 | - NAME: "official" 111 | ENABLED: true # 是否启用此实例 112 | BASE_URL: "https://generativelanguage.googleapis.com" 113 | KEYS: 114 | - "YOUR_API_KEY" 115 | - "YOUR_API_KEY" 116 | - "YOUR_API_KEY" 117 | MODELS: 118 | - "gemini-2.5-pro" 119 | - "gemini-2.5-flash" 120 | MAX_TOKENS: 32768 # 最大输出 token 数量 121 | TEMPERATURE: 0.40 # 采样温度 (0.0-2.0) 122 | TOP_P: 0.90 # 核采样 (0.0-1.0) 123 | TOP_K: 40 # 限制候选词数量 124 | TIMEOUT: 1080 125 | MAX_RETRIES: 1 126 | RATE_LIMIT: "60/min" 127 | -------------------------------------------------------------------------------- /internal/ai/internal/prompt/manager.go: -------------------------------------------------------------------------------- 1 | // Package prompt provides dynamic prompt loading and management 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package prompt 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | "github.com/Done-0/gin-scaffold/internal/utils/file" 14 | "github.com/Done-0/gin-scaffold/internal/utils/template" 15 | ) 16 | 17 | type manager struct{} 18 | 19 | // New creates a new prompt manager 20 | func New() Manager { 21 | return &manager{} 22 | } 23 | 24 | func (m *manager) GetTemplate(ctx context.Context, name string, vars *map[string]any) (*Template, error) { 25 | cfgs, err := configs.GetConfig() 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to get config: %w", err) 28 | } 29 | 30 | filePath := filepath.Join(cfgs.AI.Prompt.Dir, name+".json") 31 | var tmpl Template 32 | if err := file.LoadJSONFile(filePath, &tmpl); err != nil { 33 | return nil, fmt.Errorf("failed to load template '%s': %w", name, err) 34 | } 35 | 36 | if vars == nil { 37 | return &tmpl, nil 38 | } 39 | 40 | result := &Template{ 41 | Name: tmpl.Name, 42 | Description: tmpl.Description, 43 | Variables: tmpl.Variables, 44 | Messages: make([]Message, len(tmpl.Messages)), 45 | } 46 | 47 | for i, msg := range tmpl.Messages { 48 | content, err := template.Replace(msg.Content, *vars) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to replace variables in message %d: %w", i, err) 51 | } 52 | result.Messages[i] = Message{ 53 | Role: msg.Role, 54 | Content: content, 55 | } 56 | } 57 | 58 | return result, nil 59 | } 60 | 61 | func (m *manager) ListTemplates(ctx context.Context) ([]string, error) { 62 | cfgs, err := configs.GetConfig() 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to get config: %w", err) 65 | } 66 | 67 | files, err := filepath.Glob(filepath.Join(cfgs.AI.Prompt.Dir, "*.json")) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to list templates: %w", err) 70 | } 71 | 72 | var names []string 73 | for _, filePath := range files { 74 | names = append(names, file.GetFileNameWithoutExt(filePath)) 75 | } 76 | return names, nil 77 | } 78 | 79 | func (m *manager) CreateTemplate(ctx context.Context, template *Template) error { 80 | cfgs, err := configs.GetConfig() 81 | if err != nil { 82 | return fmt.Errorf("failed to get config: %w", err) 83 | } 84 | 85 | if template.Name == "" { 86 | return fmt.Errorf("template name cannot be empty") 87 | } 88 | 89 | if len(template.Messages) == 0 { 90 | return fmt.Errorf("template must have at least one message") 91 | } 92 | 93 | filePath := filepath.Join(cfgs.AI.Prompt.Dir, template.Name+".json") 94 | if _, err := os.Stat(filePath); err == nil { 95 | return fmt.Errorf("template '%s' already exists", template.Name) 96 | } 97 | return file.SaveJSONFile(filePath, template) 98 | } 99 | 100 | func (m *manager) UpdateTemplate(ctx context.Context, name string, template *Template) error { 101 | cfgs, err := configs.GetConfig() 102 | if err != nil { 103 | return fmt.Errorf("failed to get config: %w", err) 104 | } 105 | 106 | if template.Name == "" { 107 | return fmt.Errorf("template name cannot be empty") 108 | } 109 | 110 | oldFilePath := filepath.Join(cfgs.AI.Prompt.Dir, name+".json") 111 | if _, err := os.Stat(oldFilePath); os.IsNotExist(err) { 112 | return fmt.Errorf("template '%s' does not exist", name) 113 | } 114 | 115 | if template.Name != name { 116 | newFilePath := filepath.Join(cfgs.AI.Prompt.Dir, template.Name+".json") 117 | if _, err := os.Stat(newFilePath); err == nil { 118 | return fmt.Errorf("template '%s' already exists", template.Name) 119 | } 120 | 121 | if err := file.SaveJSONFile(newFilePath, template); err != nil { 122 | return fmt.Errorf("failed to save renamed template: %w", err) 123 | } 124 | 125 | if err := os.Remove(oldFilePath); err != nil { 126 | os.Remove(newFilePath) 127 | return fmt.Errorf("failed to remove old template file: %w", err) 128 | } 129 | 130 | return nil 131 | } 132 | 133 | return file.SaveJSONFile(oldFilePath, template) 134 | } 135 | 136 | func (m *manager) DeleteTemplate(ctx context.Context, name string) error { 137 | cfgs, err := configs.GetConfig() 138 | if err != nil { 139 | return fmt.Errorf("failed to get config: %w", err) 140 | } 141 | return os.Remove(filepath.Join(cfgs.AI.Prompt.Dir, name+".json")) 142 | } 143 | -------------------------------------------------------------------------------- /docs/prompt-guide.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Prompt 书写规范 2 | 3 | > 基于 `internal/ai/internal/prompt` 和 `internal/utils/template` 的实际实现 4 | 5 | --- 6 | 7 | ## 一、JSON 结构 8 | 9 | ```json 10 | { 11 | "name": "string", 12 | "description": "string (可选)", 13 | "variables": { 14 | "key": "说明文本" 15 | }, 16 | "messages": [ 17 | { 18 | "role": "system|user|assistant", 19 | "content": "文本内容,支持模板语法" 20 | } 21 | ] 22 | } 23 | ``` 24 | 25 | --- 26 | 27 | ## 二、类型定义 28 | 29 | ```go 30 | // internal/ai/internal/prompt/types.go 31 | type Template struct { 32 | Name string `json:"name"` 33 | Description string `json:"description,omitempty"` 34 | Variables map[string]string `json:"variables,omitempty"` 35 | Messages []Message `json:"messages"` 36 | } 37 | 38 | type Message = template.Message 39 | 40 | // internal/utils/template/template.go 41 | type Message struct { 42 | Role string `json:"role"` 43 | Content string `json:"content"` 44 | } 45 | ``` 46 | 47 | --- 48 | 49 | ## 三、字段约束 50 | 51 | | 字段 | 类型 | 必填 | 约束 | 52 | |-----|------|-----|------| 53 | | `name` | string | 是 | 不能为空(manager.go:85) | 54 | | `description` | string | 否 | - | 55 | | `variables` | map[string]string | 否 | - | 56 | | `messages` | []Message | 是 | 至少一条(manager.go:89) | 57 | 58 | --- 59 | 60 | ## 四、模板语法 61 | 62 | ### 1. 变量 63 | 64 | ```go 65 | {{.variable}} 66 | ``` 67 | 68 | ### 2. 条件 69 | 70 | ```go 71 | {{if .condition}}...{{else}}...{{end}} 72 | {{if gt .value 10}}...{{end}} 73 | {{if eq .status "active"}}...{{end}} 74 | ``` 75 | 76 | ### 3. 循环 77 | 78 | ```go 79 | {{range $index, $item := .list}} 80 | {{$index}} - {{$item.field}} 81 | {{end}} 82 | ``` 83 | 84 | ### 4. 内置函数 85 | 86 | ```go 87 | {{add 1 2}} // 返回 3 88 | {{unixToTime 1706140800}} // 返回 "2025年01月24日 15时30分" 89 | ``` 90 | 91 | 定义位置:`template.go:25-32` 92 | 93 | --- 94 | 95 | ## 五、Manager 接口 96 | 97 | ```go 98 | type Manager interface { 99 | GetTemplate(ctx, name, vars) (*Template, error) 100 | ListTemplates(ctx) ([]string, error) 101 | CreateTemplate(ctx, template) error 102 | UpdateTemplate(ctx, name, template) error 103 | DeleteTemplate(ctx, name) error 104 | } 105 | ``` 106 | 107 | ### 1. GetTemplate 108 | 109 | ```go 110 | func (m *manager) GetTemplate(ctx context.Context, name string, vars *map[string]any) (*Template, error) 111 | ``` 112 | 113 | 行为(manager.go:24-59): 114 | - `vars == nil` → 返回原始模板(第36-38行) 115 | - `vars != nil` → 替换变量后返回(第47-56行) 116 | 117 | 示例: 118 | ```go 119 | // 原始模板 120 | raw, _ := mgr.GetTemplate(ctx, "test", nil) 121 | 122 | // 替换变量 123 | vars := map[string]any{"name": "World"} 124 | rendered, _ := mgr.GetTemplate(ctx, "test", &vars) 125 | ``` 126 | 127 | ### 2. CreateTemplate 128 | 129 | 约束(manager.go:79-98): 130 | - `template.Name` 不能为空(第85行) 131 | - `template.Messages` 至少一条(第89行) 132 | - 文件不能已存在(第94行) 133 | 134 | ### 3. UpdateTemplate 135 | 136 | 约束(manager.go:100-134): 137 | - `template.Name` 不能为空(第106行) 138 | - 原文件必须存在(第111行) 139 | - 重命名时新名称不能已存在(第117行) 140 | 141 | ### 4. ListTemplates 142 | 143 | 行为(manager.go:61-77): 144 | - 列出 `*.json` 文件名(第67行) 145 | - 返回不含扩展名的列表(第74行) 146 | 147 | ### 5. DeleteTemplate 148 | 149 | 行为(manager.go:136-142): 150 | - 删除指定名称的 `.json` 文件 151 | 152 | --- 153 | 154 | ## 六、使用示例 155 | 156 | ```go 157 | package main 158 | 159 | import ( 160 | "context" 161 | 162 | "github.com/Done-0/gin-scaffold/internal/ai/internal/prompt" 163 | ) 164 | 165 | func main() { 166 | mgr := prompt.New() 167 | ctx := context.Background() 168 | 169 | // 加载并替换变量 170 | vars := map[string]any{ 171 | "symbol": "BTC/USDT", 172 | "price": 66500.00, 173 | } 174 | 175 | tmpl, err := mgr.GetTemplate(ctx, "trading_analyzer", &vars) 176 | if err != nil { 177 | panic(err) 178 | } 179 | 180 | // 使用替换后的内容 181 | fmt.Println(tmpl.Messages[0].Content) 182 | } 183 | ``` 184 | 185 | --- 186 | 187 | ## 七、文件命名 188 | 189 | - 位置:`configs/prompts/` 190 | - 格式:`{name}.json` 191 | - 命名:小写字母+下划线 192 | - 示例:`bazi_analyzer.json` 193 | 194 | --- 195 | 196 | ## 八、实际示例 197 | 198 | ```json 199 | { 200 | "name": "example", 201 | "description": "示例提示词", 202 | "variables": { 203 | "user_name": "用户姓名", 204 | "user_age": "用户年龄" 205 | }, 206 | "messages": [ 207 | { 208 | "role": "system", 209 | "content": "你是AI助手。当前用户:{{.user_name}},年龄:{{.user_age}}岁。" 210 | }, 211 | { 212 | "role": "user", 213 | "content": "{{.user_message}}" 214 | } 215 | ] 216 | } 217 | ``` 218 | 219 | -------------------------------------------------------------------------------- /internal/db/internal/setup.go: -------------------------------------------------------------------------------- 1 | // Package internal provides database setup and initialization functionality 2 | // Author: Done-0 3 | // Created: 2025-08-24 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | 12 | "gorm.io/gorm" 13 | ) 14 | 15 | // setupDatabase handles database initialization based on dialect 16 | func (m *Manager) setupDatabase() error { 17 | dialect := m.config.DBConfig.DBDialect 18 | 19 | switch dialect { 20 | case DialectSQLite: 21 | if err := m.ensureSQLiteDBExists(); err != nil { 22 | return fmt.Errorf("SQLite database creation failed: %w", err) 23 | } 24 | case DialectPostgres, DialectMySQL: 25 | if err := m.setupSystemDatabase(); err != nil { 26 | return fmt.Errorf("system database setup failed: %w", err) 27 | } 28 | default: 29 | return fmt.Errorf("unsupported database dialect: %s", dialect) 30 | } 31 | 32 | // Connect to target database 33 | var err error 34 | m.db, err = m.connectToDB(m.config.DBConfig.DBName) 35 | if err != nil { 36 | return fmt.Errorf("failed to connect to database '%s': %w", m.config.DBConfig.DBName, err) 37 | } 38 | 39 | log.Printf("Database '%s' connected successfully", m.config.DBConfig.DBName) 40 | return nil 41 | } 42 | 43 | // setupSystemDatabase handles PostgreSQL and MySQL system database setup 44 | func (m *Manager) setupSystemDatabase() error { 45 | systemDBName := m.getSystemDBName() 46 | systemDB, err := m.connectToDB(systemDBName) 47 | if err != nil { 48 | return fmt.Errorf("failed to connect to system database '%s': %w", systemDBName, err) 49 | } 50 | defer func() { 51 | if sqlDB, err := systemDB.DB(); err == nil { 52 | sqlDB.Close() 53 | } 54 | }() 55 | 56 | return m.ensureDBExists(systemDB) 57 | } 58 | 59 | // getSystemDBName returns the system database name for the current dialect 60 | func (m *Manager) getSystemDBName() string { 61 | switch m.config.DBConfig.DBDialect { 62 | case DialectPostgres: 63 | return "postgres" 64 | case DialectMySQL: 65 | return "information_schema" 66 | default: 67 | return "" 68 | } 69 | } 70 | 71 | // ensureDBExists ensures the database exists, creates it if it doesn't exist 72 | func (m *Manager) ensureDBExists(db *gorm.DB) error { 73 | switch m.config.DBConfig.DBDialect { 74 | case DialectPostgres: 75 | return m.ensurePostgresDBExists(db) 76 | case DialectMySQL: 77 | return m.ensureMySQLDBExists(db) 78 | case DialectSQLite: 79 | return m.ensureSQLiteDBExists() 80 | default: 81 | return fmt.Errorf("unsupported database dialect: %s", m.config.DBConfig.DBDialect) 82 | } 83 | } 84 | 85 | // ensureSQLiteDBExists ensures SQLite database exists, creates if it doesn't exist 86 | func (m *Manager) ensureSQLiteDBExists() error { 87 | dbPath := filepath.Join(m.config.DBConfig.DBPath, m.config.DBConfig.DBName+".db") 88 | 89 | if _, err := os.Stat(dbPath); os.IsNotExist(err) { 90 | if err := os.MkdirAll(m.config.DBConfig.DBPath, os.ModePerm); err != nil { 91 | return fmt.Errorf("failed to create SQLite database directory: %w", err) 92 | } 93 | 94 | file, err := os.Create(dbPath) 95 | if err != nil { 96 | return fmt.Errorf("failed to create SQLite database file: %w", err) 97 | } 98 | file.Close() 99 | 100 | log.Printf("SQLite database '%s' created successfully", dbPath) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // ensurePostgresDBExists ensures PostgreSQL database exists, creates if it doesn't exist 107 | func (m *Manager) ensurePostgresDBExists(db *gorm.DB) error { 108 | var exists bool 109 | query := "SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = ?)" 110 | if err := db.Raw(query, m.config.DBConfig.DBName).Scan(&exists).Error; err != nil { 111 | return fmt.Errorf("failed to check if PostgreSQL database exists: %w", err) 112 | } 113 | 114 | if !exists { 115 | createQuery := fmt.Sprintf(`CREATE DATABASE "%s" OWNER "%s"`, m.config.DBConfig.DBName, m.config.DBConfig.DBUser) 116 | if err := db.Exec(createQuery).Error; err != nil { 117 | return fmt.Errorf("failed to create PostgreSQL database: %w", err) 118 | } 119 | log.Printf("PostgreSQL database '%s' created successfully", m.config.DBConfig.DBName) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // ensureMySQLDBExists ensures MySQL database exists, creates if it doesn't exist 126 | func (m *Manager) ensureMySQLDBExists(db *gorm.DB) error { 127 | var exists bool 128 | query := "SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = ?)" 129 | if err := db.Raw(query, m.config.DBConfig.DBName).Scan(&exists).Error; err != nil { 130 | return fmt.Errorf("failed to check if MySQL database exists: %w", err) 131 | } 132 | 133 | if !exists { 134 | createQuery := fmt.Sprintf("CREATE DATABASE `%s`", m.config.DBConfig.DBName) 135 | if err := db.Exec(createQuery).Error; err != nil { 136 | return fmt.Errorf("failed to create MySQL database: %w", err) 137 | } 138 | log.Printf("MySQL database '%s' created successfully", m.config.DBConfig.DBName) 139 | } 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /docs/prompt-guide.en-US.md: -------------------------------------------------------------------------------- 1 | # Prompt Writing Guide 2 | 3 | > Based on actual implementation in `internal/ai/internal/prompt` and `internal/utils/template` 4 | 5 | --- 6 | 7 | ## I. JSON Structure 8 | 9 | ```json 10 | { 11 | "name": "string", 12 | "description": "string (optional)", 13 | "variables": { 14 | "key": "description text" 15 | }, 16 | "messages": [ 17 | { 18 | "role": "system|user|assistant", 19 | "content": "text content, supports template syntax" 20 | } 21 | ] 22 | } 23 | ``` 24 | 25 | --- 26 | 27 | ## II. Type Definitions 28 | 29 | ```go 30 | // internal/ai/internal/prompt/types.go 31 | type Template struct { 32 | Name string `json:"name"` 33 | Description string `json:"description,omitempty"` 34 | Variables map[string]string `json:"variables,omitempty"` 35 | Messages []Message `json:"messages"` 36 | } 37 | 38 | type Message = template.Message 39 | 40 | // internal/utils/template/template.go 41 | type Message struct { 42 | Role string `json:"role"` 43 | Content string `json:"content"` 44 | } 45 | ``` 46 | 47 | --- 48 | 49 | ## III. Field Constraints 50 | 51 | | Field | Type | Required | Constraint | 52 | |-------|------|----------|------------| 53 | | `name` | string | Yes | Cannot be empty (manager.go:85) | 54 | | `description` | string | No | - | 55 | | `variables` | map[string]string | No | - | 56 | | `messages` | []Message | Yes | At least one (manager.go:89) | 57 | 58 | --- 59 | 60 | ## IV. Template Syntax 61 | 62 | ### 1. Variables 63 | 64 | ```go 65 | {{.variable}} 66 | ``` 67 | 68 | ### 2. Conditionals 69 | 70 | ```go 71 | {{if .condition}}...{{else}}...{{end}} 72 | {{if gt .value 10}}...{{end}} 73 | {{if eq .status "active"}}...{{end}} 74 | ``` 75 | 76 | ### 3. Loops 77 | 78 | ```go 79 | {{range $index, $item := .list}} 80 | {{$index}} - {{$item.field}} 81 | {{end}} 82 | ``` 83 | 84 | ### 4. Built-in Functions 85 | 86 | ```go 87 | {{add 1 2}} // returns 3 88 | {{unixToTime 1706140800}} // returns "2025年01月24日 15时30分" 89 | ``` 90 | 91 | Definition: `template.go:25-32` 92 | 93 | --- 94 | 95 | ## V. Manager Interface 96 | 97 | ```go 98 | type Manager interface { 99 | GetTemplate(ctx, name, vars) (*Template, error) 100 | ListTemplates(ctx) ([]string, error) 101 | CreateTemplate(ctx, template) error 102 | UpdateTemplate(ctx, name, template) error 103 | DeleteTemplate(ctx, name) error 104 | } 105 | ``` 106 | 107 | ### 1. GetTemplate 108 | 109 | ```go 110 | func (m *manager) GetTemplate(ctx context.Context, name string, vars *map[string]any) (*Template, error) 111 | ``` 112 | 113 | Behavior (manager.go:24-59): 114 | - `vars == nil` → returns raw template (lines 36-38) 115 | - `vars != nil` → returns template with replaced variables (lines 47-56) 116 | 117 | Example: 118 | ```go 119 | // Raw template 120 | raw, _ := mgr.GetTemplate(ctx, "test", nil) 121 | 122 | // With variable replacement 123 | vars := map[string]any{"name": "World"} 124 | rendered, _ := mgr.GetTemplate(ctx, "test", &vars) 125 | ``` 126 | 127 | ### 2. CreateTemplate 128 | 129 | Constraints (manager.go:79-98): 130 | - `template.Name` cannot be empty (line 85) 131 | - `template.Messages` must have at least one (line 89) 132 | - File cannot already exist (line 94) 133 | 134 | ### 3. UpdateTemplate 135 | 136 | Constraints (manager.go:100-134): 137 | - `template.Name` cannot be empty (line 106) 138 | - Original file must exist (line 111) 139 | - When renaming, new name cannot already exist (line 117) 140 | 141 | ### 4. ListTemplates 142 | 143 | Behavior (manager.go:61-77): 144 | - Lists `*.json` file names (line 67) 145 | - Returns list without extensions (line 74) 146 | 147 | ### 5. DeleteTemplate 148 | 149 | Behavior (manager.go:136-142): 150 | - Deletes `.json` file with specified name 151 | 152 | --- 153 | 154 | ## VI. Usage Example 155 | 156 | ```go 157 | package main 158 | 159 | import ( 160 | "context" 161 | 162 | "github.com/Done-0/gin-scaffold/internal/ai/internal/prompt" 163 | ) 164 | 165 | func main() { 166 | mgr := prompt.New() 167 | ctx := context.Background() 168 | 169 | // Load and replace variables 170 | vars := map[string]any{ 171 | "symbol": "BTC/USDT", 172 | "price": 66500.00, 173 | } 174 | 175 | tmpl, err := mgr.GetTemplate(ctx, "trading_analyzer", &vars) 176 | if err != nil { 177 | panic(err) 178 | } 179 | 180 | // Use replaced content 181 | fmt.Println(tmpl.Messages[0].Content) 182 | } 183 | ``` 184 | 185 | --- 186 | 187 | ## VII. File Naming 188 | 189 | - Location: `configs/prompts/` 190 | - Format: `{name}.json` 191 | - Naming: lowercase + underscore 192 | - Example: `bazi_analyzer.json` 193 | 194 | --- 195 | 196 | ## VIII. Real Example 197 | 198 | ```json 199 | { 200 | "name": "example", 201 | "description": "Example prompt", 202 | "variables": { 203 | "user_name": "User name", 204 | "user_age": "User age" 205 | }, 206 | "messages": [ 207 | { 208 | "role": "system", 209 | "content": "You are an AI assistant. Current user: {{.user_name}}, age: {{.user_age}}." 210 | }, 211 | { 212 | "role": "user", 213 | "content": "{{.user_message}}" 214 | } 215 | ] 216 | } 217 | ``` 218 | 219 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Done-0/gin-scaffold 2 | 3 | go 1.25.1 4 | 5 | require ( 6 | github.com/IBM/sarama v1.46.3 7 | github.com/bwmarrin/snowflake v0.3.0 8 | github.com/fsnotify/fsnotify v1.9.0 9 | github.com/gin-contrib/cors v1.7.6 10 | github.com/gin-contrib/requestid v1.0.5 11 | github.com/gin-contrib/sse v1.1.0 12 | github.com/gin-gonic/gin v1.10.1 13 | github.com/go-playground/validator/v10 v10.27.0 14 | github.com/google/wire v0.7.0 15 | github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible 16 | github.com/nicksnyder/go-i18n/v2 v2.6.0 17 | github.com/redis/go-redis/v9 v9.14.0 18 | github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 19 | github.com/sashabaranov/go-openai v1.41.2 20 | github.com/sirupsen/logrus v1.9.3 21 | github.com/spf13/viper v1.21.0 22 | github.com/stretchr/testify v1.11.1 23 | golang.org/x/text v0.30.0 24 | golang.org/x/time v0.14.0 25 | google.golang.org/genai v1.36.0 26 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 27 | gorm.io/driver/mysql v1.6.0 28 | gorm.io/driver/postgres v1.6.0 29 | gorm.io/driver/sqlite v1.6.0 30 | gorm.io/gorm v1.31.0 31 | ) 32 | 33 | require ( 34 | cloud.google.com/go v0.116.0 // indirect 35 | cloud.google.com/go/auth v0.9.3 // indirect 36 | cloud.google.com/go/compute/metadata v0.5.0 // indirect 37 | filippo.io/edwards25519 v1.1.0 // indirect 38 | github.com/bytedance/gopkg v0.1.3 // indirect 39 | github.com/bytedance/sonic v1.14.1 // indirect 40 | github.com/bytedance/sonic/loader v0.3.0 // indirect 41 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 42 | github.com/cloudwego/base64x v0.1.6 // indirect 43 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 44 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 45 | github.com/eapache/go-resiliency v1.7.0 // indirect 46 | github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect 47 | github.com/eapache/queue v1.1.0 // indirect 48 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 49 | github.com/go-playground/locales v0.14.1 // indirect 50 | github.com/go-playground/universal-translator v0.18.1 // indirect 51 | github.com/go-sql-driver/mysql v1.8.1 // indirect 52 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 53 | github.com/goccy/go-json v0.10.5 // indirect 54 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 55 | github.com/golang/snappy v0.0.4 // indirect 56 | github.com/google/go-cmp v0.7.0 // indirect 57 | github.com/google/s2a-go v0.1.8 // indirect 58 | github.com/google/uuid v1.6.0 // indirect 59 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 60 | github.com/gorilla/websocket v1.5.3 // indirect 61 | github.com/hashicorp/go-uuid v1.0.3 // indirect 62 | github.com/jackc/pgpassfile v1.0.0 // indirect 63 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 64 | github.com/jackc/pgx/v5 v5.6.0 // indirect 65 | github.com/jackc/puddle/v2 v2.2.2 // indirect 66 | github.com/jcmturner/aescts/v2 v2.0.0 // indirect 67 | github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect 68 | github.com/jcmturner/gofork v1.7.6 // indirect 69 | github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect 70 | github.com/jcmturner/rpc/v2 v2.0.3 // indirect 71 | github.com/jinzhu/inflection v1.0.0 // indirect 72 | github.com/jinzhu/now v1.1.5 // indirect 73 | github.com/jonboulle/clockwork v0.5.0 // indirect 74 | github.com/json-iterator/go v1.1.12 // indirect 75 | github.com/klauspost/compress v1.18.1 // indirect 76 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 77 | github.com/leodido/go-urn v1.4.0 // indirect 78 | github.com/lestrrat-go/strftime v1.1.1 // indirect 79 | github.com/mattn/go-isatty v0.0.20 // indirect 80 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 81 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 82 | github.com/modern-go/reflect2 v1.0.2 // indirect 83 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 84 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 85 | github.com/pkg/errors v0.9.1 // indirect 86 | github.com/pmezard/go-difflib v1.0.0 // indirect 87 | github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect 88 | github.com/sagikazarmark/locafero v0.11.0 // indirect 89 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 90 | github.com/spf13/afero v1.15.0 // indirect 91 | github.com/spf13/cast v1.10.0 // indirect 92 | github.com/spf13/pflag v1.0.10 // indirect 93 | github.com/subosito/gotenv v1.6.0 // indirect 94 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 95 | github.com/ugorji/go/codec v1.3.0 // indirect 96 | go.opencensus.io v0.24.0 // indirect 97 | go.yaml.in/yaml/v3 v3.0.4 // indirect 98 | golang.org/x/arch v0.21.0 // indirect 99 | golang.org/x/crypto v0.43.0 // indirect 100 | golang.org/x/net v0.46.0 // indirect 101 | golang.org/x/sync v0.17.0 // indirect 102 | golang.org/x/sys v0.37.0 // indirect 103 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 104 | google.golang.org/grpc v1.66.2 // indirect 105 | google.golang.org/protobuf v1.36.8 // indirect 106 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 107 | gopkg.in/yaml.v3 v3.0.1 // indirect 108 | ) 109 | -------------------------------------------------------------------------------- /internal/utils/errorx/internal/status.go: -------------------------------------------------------------------------------- 1 | // Package internal provides error status handling internal implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package internal 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "strings" 10 | ) 11 | 12 | // StatusError status error interface 13 | type StatusError interface { 14 | error 15 | Code() int32 // Get status code 16 | } 17 | 18 | // statusError status error implementation 19 | type statusError struct { 20 | code int32 // Error code 21 | message string // Error message 22 | extra map[string]string // Extra information 23 | params map[string]any // Template parameters 24 | } 25 | 26 | // withStatus error wrapper with status 27 | type withStatus struct { 28 | status *statusError // Status error 29 | cause error // Original error 30 | stack string // Stack information 31 | } 32 | 33 | // Code gets status code 34 | func (w *statusError) Code() int32 { 35 | return w.code 36 | } 37 | 38 | // Msg gets error message 39 | func (w *statusError) Msg() string { 40 | return w.message 41 | } 42 | 43 | // Error error string representation 44 | func (w *statusError) Error() string { 45 | return fmt.Sprintf("code=%d message=%s", w.code, w.message) 46 | } 47 | 48 | // Extra gets extra information 49 | func (w *statusError) Extra() map[string]string { 50 | return w.extra 51 | } 52 | 53 | // Params gets template parameters 54 | func (w *statusError) Params() map[string]any { 55 | return w.params 56 | } 57 | 58 | // Code gets status code 59 | func (w *withStatus) Code() int32 { 60 | return w.status.Code() 61 | } 62 | 63 | // Msg gets error message 64 | func (w *withStatus) Msg() string { 65 | return w.status.Msg() 66 | } 67 | 68 | // Extra gets extra information 69 | func (w *withStatus) Extra() map[string]string { 70 | return w.status.extra 71 | } 72 | 73 | // Params gets template parameters 74 | func (w *withStatus) Params() map[string]any { 75 | return w.status.params 76 | } 77 | 78 | // Unwrap supports Go errors.Unwrap() 79 | func (w *withStatus) Unwrap() error { 80 | return w.cause 81 | } 82 | 83 | // Is supports Go errors.Is() 84 | func (w *withStatus) Is(target error) bool { 85 | var ws StatusError 86 | if errors.As(target, &ws) && w.status.Code() == ws.Code() { 87 | return true 88 | } 89 | return false 90 | } 91 | 92 | // As supports Go errors.As() 93 | func (w *withStatus) As(target any) bool { 94 | return errors.As(w.status, target) 95 | } 96 | 97 | // StackTrace gets stack trace information 98 | func (w *withStatus) StackTrace() string { 99 | return w.stack 100 | } 101 | 102 | // Error error string representation 103 | func (w *withStatus) Error() string { 104 | b := strings.Builder{} 105 | b.WriteString(w.status.Error()) 106 | 107 | if w.cause != nil { 108 | b.WriteString("\n") 109 | b.WriteString(fmt.Sprintf("cause=%s", w.cause)) 110 | } 111 | 112 | if w.stack != "" { 113 | b.WriteString("\n") 114 | b.WriteString(fmt.Sprintf("stack=%s", w.stack)) 115 | } 116 | 117 | return b.String() 118 | } 119 | 120 | // Option configuration option 121 | type Option func(ws *withStatus) 122 | 123 | // Param creates parameter replacement option 124 | func Param(k, v string) Option { 125 | return func(ws *withStatus) { 126 | if ws == nil || ws.status == nil { 127 | return 128 | } 129 | if ws.status.params == nil { 130 | ws.status.params = make(map[string]any) 131 | } 132 | ws.status.params[k] = v 133 | ws.status.message = strings.ReplaceAll(ws.status.message, fmt.Sprintf("{{.%s}}", k), v) 134 | } 135 | } 136 | 137 | // Extra creates extra information option 138 | func Extra(k, v string) Option { 139 | return func(ws *withStatus) { 140 | if ws == nil || ws.status == nil { 141 | return 142 | } 143 | if ws.status.extra == nil { 144 | ws.status.extra = make(map[string]string) 145 | } 146 | ws.status.extra[k] = v 147 | } 148 | } 149 | 150 | // NewByCode creates new error based on error code 151 | func NewByCode(code int32, options ...Option) error { 152 | ws := &withStatus{ 153 | status: getStatusByCode(code), 154 | cause: nil, 155 | stack: stack(), 156 | } 157 | 158 | for _, opt := range options { 159 | opt(ws) 160 | } 161 | 162 | return ws 163 | } 164 | 165 | // WrapByCode wraps existing error with error code 166 | func WrapByCode(err error, code int32, options ...Option) error { 167 | if err == nil { 168 | return nil 169 | } 170 | 171 | ws := &withStatus{ 172 | status: getStatusByCode(code), 173 | cause: err, 174 | } 175 | 176 | for _, opt := range options { 177 | opt(ws) 178 | } 179 | 180 | // skip if stack has already exist 181 | var stackTracer StackTracer 182 | if errors.As(err, &stackTracer) { 183 | return ws 184 | } 185 | 186 | ws.stack = stack() 187 | 188 | return ws 189 | } 190 | 191 | // getStatusByCode gets status error based on error code 192 | func getStatusByCode(code int32) *statusError { 193 | codeDefinition, ok := CodeDefinitions[code] 194 | if ok { 195 | // predefined err code 196 | return &statusError{ 197 | code: code, 198 | message: codeDefinition.Message, 199 | extra: make(map[string]string), 200 | params: make(map[string]any), 201 | } 202 | } 203 | 204 | return &statusError{ 205 | code: code, 206 | message: DefaultErrorMsg, 207 | extra: make(map[string]string), 208 | params: make(map[string]any), 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /pkg/serve/controller/test_controller.go: -------------------------------------------------------------------------------- 1 | // Package controller provides test controller 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package controller 5 | 6 | import ( 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "github.com/Done-0/gin-scaffold/internal/sse" 12 | "github.com/Done-0/gin-scaffold/internal/types/errno" 13 | "github.com/Done-0/gin-scaffold/internal/utils/errorx" 14 | "github.com/Done-0/gin-scaffold/internal/utils/validator" 15 | "github.com/Done-0/gin-scaffold/internal/utils/vo" 16 | "github.com/Done-0/gin-scaffold/pkg/serve/controller/dto" 17 | "github.com/Done-0/gin-scaffold/pkg/serve/service" 18 | ) 19 | 20 | // TestController test HTTP controller 21 | type TestController struct { 22 | testService service.TestService 23 | sseManager sse.SSEManager 24 | } 25 | 26 | // NewTestController creates test controller 27 | func NewTestController(testService service.TestService, sseManager sse.SSEManager) *TestController { 28 | return &TestController{ 29 | testService: testService, 30 | sseManager: sseManager, 31 | } 32 | } 33 | 34 | // TestPing handles ping test endpoint 35 | // @Router /api/v1/test/testPing [get] 36 | func (tc *TestController) TestPing(c *gin.Context) { 37 | response, err := tc.testService.TestPing(c) 38 | if err != nil { 39 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 40 | return 41 | } 42 | 43 | c.JSON(200, vo.Success(c, response)) 44 | } 45 | 46 | // TestHello handles hello test endpoint 47 | // @Router /api/v1/test/testHello [get] 48 | func (tc *TestController) TestHello(c *gin.Context) { 49 | response, err := tc.testService.TestHello(c) 50 | if err != nil { 51 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 52 | return 53 | } 54 | 55 | c.JSON(200, vo.Success(c, response)) 56 | } 57 | 58 | // TestLogger handles logger test endpoint 59 | // @Router /api/v1/test/testLogger [get] 60 | func (tc *TestController) TestLogger(c *gin.Context) { 61 | response, err := tc.testService.TestLogger(c) 62 | if err != nil { 63 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 64 | return 65 | } 66 | 67 | c.JSON(200, vo.Success(c, response)) 68 | } 69 | 70 | // TestRedis handles redis test endpoint 71 | // @Router /api/v1/test/testRedis [post] 72 | func (tc *TestController) TestRedis(c *gin.Context) { 73 | req := &dto.TestRedisRequest{} 74 | if err := c.ShouldBindJSON(req); err != nil { 75 | c.JSON(400, vo.Fail(c, err, errorx.New(errno.ErrInvalidParams, errorx.KV("msg", "bind JSON failed")))) 76 | return 77 | } 78 | 79 | response, err := tc.testService.TestRedis(c, req) 80 | if err != nil { 81 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 82 | return 83 | } 84 | 85 | c.JSON(200, vo.Success(c, response)) 86 | } 87 | 88 | // TestSuccess handles success test endpoint 89 | // @Router /api/v1/test/testSuccessRes [get] 90 | func (tc *TestController) TestSuccess(c *gin.Context) { 91 | response, err := tc.testService.TestSuccess(c) 92 | if err != nil { 93 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 94 | return 95 | } 96 | 97 | c.JSON(200, vo.Success(c, response)) 98 | } 99 | 100 | // TestError handles error test endpoint 101 | // @Router /api/v1/test/testErrRes [get] 102 | func (tc *TestController) TestError(c *gin.Context) { 103 | response, err := tc.testService.TestError(c) 104 | if err != nil { 105 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 106 | return 107 | } 108 | 109 | c.JSON(200, vo.Success(c, response)) 110 | } 111 | 112 | // TestErrorMiddleware handles error middleware test endpoint 113 | // @Router /api/v1/test/testErrorMiddleware [get] 114 | func (tc *TestController) TestErrorMiddleware(c *gin.Context) { 115 | // This will trigger the recovery middleware 116 | panic("Test panic for recovery middleware") 117 | } 118 | 119 | // TestLong handles long request test endpoint 120 | // @Router /api/v2/test/testLongReq [post] 121 | func (tc *TestController) TestLong(c *gin.Context) { 122 | req := &dto.TestLongRequest{} 123 | if err := c.ShouldBindJSON(req); err != nil { 124 | c.JSON(400, vo.Fail(c, err, errorx.New(errno.ErrInvalidParams, errorx.KV("msg", "bind JSON failed")))) 125 | return 126 | } 127 | 128 | errors := validator.Validate(req) 129 | if errors != nil { 130 | c.JSON(http.StatusBadRequest, vo.Fail(c, errors, errorx.New(errno.ErrInvalidParams, errorx.KV("msg", "validation failed")))) 131 | return 132 | } 133 | 134 | response, err := tc.testService.TestLong(c, req) 135 | if err != nil { 136 | c.JSON(500, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 137 | return 138 | } 139 | 140 | c.JSON(200, vo.Success(c, response)) 141 | } 142 | 143 | // TestI18n handles i18n test endpoint 144 | // @Router /api/v1/test/testI18n [get] 145 | func (tc *TestController) TestI18n(c *gin.Context) { 146 | response, err := tc.testService.TestI18n(c) 147 | if err != nil { 148 | c.JSON(http.StatusInternalServerError, vo.Fail(c, err, errorx.New(errno.ErrInternalServer, errorx.KV("msg", "i18n test failed")))) 149 | return 150 | } 151 | 152 | c.JSON(http.StatusOK, vo.Success(c, response)) 153 | } 154 | 155 | // TestStream handles simple SSE streaming test endpoint 156 | // @Router /api/v1/test/testStream [post] 157 | func (tc *TestController) TestStream(c *gin.Context) { 158 | req := &dto.TestStreamRequest{} 159 | if err := c.ShouldBindJSON(req); err != nil { 160 | c.JSON(http.StatusBadRequest, vo.Fail(c, err, errorx.New(errno.ErrInvalidParams, errorx.KV("msg", "bind JSON failed")))) 161 | return 162 | } 163 | 164 | errors := validator.Validate(req) 165 | if errors != nil { 166 | c.JSON(http.StatusBadRequest, vo.Fail(c, errors, errorx.New(errno.ErrInvalidParams, errorx.KV("msg", "validation failed")))) 167 | return 168 | } 169 | 170 | events, err := tc.testService.TestStream(c, req) 171 | if err != nil { 172 | c.JSON(http.StatusInternalServerError, vo.Fail(c, err, errorx.New(errno.ErrInternalServer))) 173 | return 174 | } 175 | 176 | _ = tc.sseManager.StreamToClient(c, events) 177 | } 178 | -------------------------------------------------------------------------------- /pkg/serve/service/impl/test_service_impl.go: -------------------------------------------------------------------------------- 1 | // Package impl provides test service implementation 2 | // Author: Done-0 3 | // Created: 2025-09-25 4 | package impl 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "time" 12 | 13 | "github.com/gin-contrib/sse" 14 | "github.com/gin-gonic/gin" 15 | 16 | "github.com/Done-0/gin-scaffold/internal/ai" 17 | "github.com/Done-0/gin-scaffold/internal/logger" 18 | "github.com/Done-0/gin-scaffold/internal/redis" 19 | "github.com/Done-0/gin-scaffold/internal/types/errno" 20 | "github.com/Done-0/gin-scaffold/pkg/serve/controller/dto" 21 | "github.com/Done-0/gin-scaffold/pkg/serve/service" 22 | "github.com/Done-0/gin-scaffold/pkg/vo" 23 | ) 24 | 25 | // TestServiceImpl test service implementation 26 | type TestServiceImpl struct { 27 | loggerManager logger.LoggerManager 28 | redisManager redis.RedisManager 29 | aiManager *ai.AIManager 30 | } 31 | 32 | // NewTestService creates test service implementation 33 | func NewTestService(loggerManager logger.LoggerManager, redisManager redis.RedisManager, aiManager *ai.AIManager) service.TestService { 34 | return &TestServiceImpl{ 35 | loggerManager: loggerManager, 36 | redisManager: redisManager, 37 | aiManager: aiManager, 38 | } 39 | } 40 | 41 | // TestPing handles ping test 42 | func (ts *TestServiceImpl) TestPing(c *gin.Context) (*vo.TestPingResponse, error) { 43 | return &vo.TestPingResponse{ 44 | Message: "Pong successfully!", 45 | Time: time.Now().Format(time.RFC3339), 46 | }, nil 47 | } 48 | 49 | // TestHello handles hello test 50 | func (ts *TestServiceImpl) TestHello(c *gin.Context) (*vo.TestHelloResponse, error) { 51 | return &vo.TestHelloResponse{ 52 | Message: "Hello, gin-scaffold! 🎉!", 53 | Version: "1.0.0", 54 | }, nil 55 | } 56 | 57 | // TestLogger handles logger test 58 | func (ts *TestServiceImpl) TestLogger(c *gin.Context) (*vo.TestLoggerResponse, error) { 59 | logger := ts.loggerManager.Logger() 60 | logger.Info("Test logger endpoint called") 61 | 62 | return &vo.TestLoggerResponse{ 63 | Message: "Log test succeeded!", 64 | Level: "info", 65 | }, nil 66 | } 67 | 68 | // TestRedis handles redis test 69 | func (ts *TestServiceImpl) TestRedis(c *gin.Context, req *dto.TestRedisRequest) (*vo.TestRedisResponse, error) { 70 | client := ts.redisManager.Client() 71 | 72 | ttl := time.Duration(req.TTL) * time.Second 73 | 74 | err := client.Set(c.Request.Context(), req.Key, req.Value, ttl).Err() 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | val, err := client.Get(c.Request.Context(), req.Key).Result() 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | if val != req.Value { 85 | return nil, errors.New("redis test failed: value mismatch") 86 | } 87 | 88 | return &vo.TestRedisResponse{ 89 | Message: "Cache functionality test completed!", 90 | Key: req.Key, 91 | Value: val, 92 | TTL: int(ttl.Seconds()), 93 | }, nil 94 | } 95 | 96 | // TestSuccess handles success test 97 | func (ts *TestServiceImpl) TestSuccess(c *gin.Context) (*vo.TestSuccessResponse, error) { 98 | return &vo.TestSuccessResponse{ 99 | Message: "Successful response validation passed!", 100 | Status: "success", 101 | }, nil 102 | } 103 | 104 | // TestError handles error test 105 | func (ts *TestServiceImpl) TestError(c *gin.Context) (*vo.TestErrorResponse, error) { 106 | return &vo.TestErrorResponse{ 107 | Message: "Server exception", 108 | Code: errno.ErrInternalServer, 109 | }, nil 110 | } 111 | 112 | // TestLong handles long request test 113 | func (ts *TestServiceImpl) TestLong(c *gin.Context, req *dto.TestLongRequest) (*vo.TestLongResponse, error) { 114 | duration := time.Duration(req.Duration) * time.Second 115 | time.Sleep(duration) 116 | 117 | return &vo.TestLongResponse{ 118 | Message: "Simulated long-running request completed!", 119 | Duration: int(duration.Seconds()), 120 | }, nil 121 | } 122 | 123 | // TestI18n handles i18n test 124 | func (ts *TestServiceImpl) TestI18n(c *gin.Context) (*vo.TestI18nResponse, error) { 125 | return &vo.TestI18nResponse{ 126 | Message: "i18n test succeeded!", 127 | }, nil 128 | } 129 | 130 | // TestStream handles SSE related test 131 | func (ts *TestServiceImpl) TestStream(c *gin.Context, req *dto.TestStreamRequest) (<-chan *sse.Event, error) { 132 | vars := map[string]any{ 133 | "user_name": req.Name, 134 | "greet_time": time.Now().Format("2006-01-02 15:04:05"), 135 | "user_message": fmt.Sprintf("This is a message from %s", req.Name), 136 | } 137 | 138 | tmpl, err := ts.aiManager.GetTemplate(context.Background(), "example", &vars) 139 | if err != nil { 140 | ts.loggerManager.Logger().Errorf("failed to load prompt template 'example': %v", err) 141 | return nil, err 142 | } 143 | 144 | if len(tmpl.Messages) == 0 { 145 | return nil, errors.New("prompt template 'example' has no messages") 146 | } 147 | 148 | messages := make([]ai.Message, len(tmpl.Messages)) 149 | for i, msg := range tmpl.Messages { 150 | messages[i] = ai.Message{ 151 | Role: msg.Role, 152 | Content: msg.Content, 153 | } 154 | } 155 | 156 | events := make(chan *sse.Event, 100) 157 | 158 | go func() { 159 | defer close(events) 160 | 161 | ctx, cancel := context.WithCancel(context.Background()) 162 | defer cancel() 163 | 164 | heartbeatTicker := time.NewTicker(15 * time.Second) 165 | defer heartbeatTicker.Stop() 166 | 167 | stream, err := ts.aiManager.ChatStream(ctx, &ai.ChatRequest{ 168 | Messages: messages, 169 | }) 170 | if err != nil { 171 | ts.loggerManager.Logger().Errorf("failed to start AI chat stream: %v", err) 172 | return 173 | } 174 | 175 | go func() { 176 | for { 177 | select { 178 | case <-ctx.Done(): 179 | return 180 | case <-heartbeatTicker.C: 181 | events <- &sse.Event{Event: "heartbeat", Data: ""} 182 | } 183 | } 184 | }() 185 | 186 | for resp := range stream { 187 | if resp == nil { 188 | continue 189 | } 190 | 191 | payload, err := json.Marshal(resp) 192 | if err != nil { 193 | continue 194 | } 195 | 196 | events <- &sse.Event{Data: string(payload)} 197 | } 198 | }() 199 | 200 | return events, nil 201 | } 202 | -------------------------------------------------------------------------------- /docs/coding-standards.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Go 语言编码规范 2 | 3 | ## 一、命名规范 4 | 5 | ### 基本原则 6 | 7 | - 命名必须以字母(A-Z、a-z)或下划线开头,后续可使用字母、下划线或数字(0-9)。 8 | - 严禁在命名中使用特殊符号,如 @、$、% 等。 9 | - Go 语言区分大小写,首字母大写的标识符可被外部包访问(公开),首字母小写则仅包内可访问(私有)。 10 | 11 | ### 1. 包命名(package) 12 | 13 | - 包名必须与目录名保持一致,应选择简洁、有意义且不与标准库冲突的名称。 14 | - 包名必须全部小写,多个单词可使用下划线分隔或采用混合式小写(不推荐使用驼峰式)。 15 | 16 | ```go 17 | package demo 18 | package main 19 | ``` 20 | 21 | ### 2. 文件命名 22 | 23 | - 文件名应有明确含义,简洁易懂。 24 | - 必须使用小写字母,多个单词间使用下划线分隔。 25 | 26 | ```go 27 | my_test.go 28 | ``` 29 | 30 | ### 3. 结构体命名 31 | 32 | - 必须采用驼峰命名法,首字母根据访问控制需求决定大小写。 33 | - 结构体声明和初始化必须采用多行格式,示例如下: 34 | 35 | ```go 36 | // 多行声明 37 | type User struct { 38 | Username string 39 | Email string 40 | } 41 | 42 | // 多行初始化 43 | user := User{ 44 | Username: "admin", 45 | Email: "admin@example.com", 46 | } 47 | ``` 48 | 49 | ### 4. 接口命名 50 | 51 | - 必须采用驼峰命名法,首字母根据访问控制需求决定大小写。 52 | - 单一功能的接口名应以 "er" 作为后缀,例如 Reader、Writer。 53 | 54 | ```go 55 | type Reader interface { 56 | Read(p []byte) (n int, err error) 57 | } 58 | ``` 59 | 60 | ### 5. 变量命名 61 | 62 | - 必须采用驼峰命名法,首字母根据访问控制需求决定大小写。 63 | - 特有名词的处理规则: 64 | - 如果变量为私有且特有名词为首个单词,则使用小写,如 apiClient。 65 | - 其他情况应保持该名词原有的写法,如 APIClient、repoID、UserID。 66 | - 错误示例:UrlArray,应写为 urlArray 或 URLArray。 67 | - 布尔类型变量名必须以 Has、Is、Can 或 Allow 开头。 68 | 69 | ```go 70 | var isExist bool 71 | var hasConflict bool 72 | var canManage bool 73 | var allowGitHook bool 74 | ``` 75 | 76 | ### 6. 常量命名 77 | 78 | - 常量命名必须采用驼峰式,并根据所属类别添加前缀。 79 | 80 | ```go 81 | // HTTP method constants 82 | const ( 83 | MethodGET = "GET" 84 | MethodPOST = "POST" 85 | ) 86 | ``` 87 | 88 | - 枚举类型的常量,也应遵循此规范: 89 | 90 | ```go 91 | type Scheme string 92 | 93 | const ( 94 | SchemeHTTP Scheme = "http" 95 | SchemeHTTPS Scheme = "https" 96 | ) 97 | ``` 98 | 99 | ### 7. 关键字 100 | 101 | Go 语言的关键字:break、case、chan、const、continue、default、defer、else、fallthrough、for、func、go、goto、if、import、interface、map、package、range、return、select、struct、switch、type、var 102 | 103 | ## 二、注释规范 104 | 105 | Go 语言支持 C 风格的注释语法,包括 `/**/` 和 `//`。 106 | 107 | - 行注释(//)是最常用的注释形式。 108 | - 块注释(/\* \*/)主要用于包注释,不可嵌套使用,通常用于文档说明或注释大段代码。 109 | 110 | ### 1. 包注释 111 | 112 | - 每个包必须有一个包注释,位于 package 子句之前。 113 | - 包内如果有多个文件,包注释只需在一个文件中出现(建议是与包同名的文件)。 114 | - 包注释必须包含以下信息(按顺序): 115 | - 包的基本简介(包名及功能说明) 116 | - 创建者信息,格式:创建者:[GitHub 用户名] 117 | - 创建时间,格式:创建时间:yyyy-MM-dd 118 | 119 | ```go 120 | // Package biz_err 提供业务错误码和错误信息定义 121 | // 创建者:Done-0 122 | // 创建时间:2025-07-01 123 | ``` 124 | 125 | ### 2. 结构体与接口注释 126 | 127 | - 每个自定义结构体或接口必须有注释说明,放在定义的前一行。 128 | - 注释格式为:[结构体名/接口名],[说明]。 129 | - 结构体的每个成员变量必须有说明,放在成员变量后面并保持对齐。 130 | - 例如:下方的 `User` 为结构体名,`用户对象,定义了用户的基础信息` 为说明。 131 | 132 | ```go 133 | // User,用户对象,定义了用户的基础信息 134 | type User struct { 135 | Username string // 用户名 136 | Email string // 邮箱 137 | } 138 | ``` 139 | 140 | ### 3. 函数与方法注释(可选) 141 | 142 | 每个函数或方法需有注释说明,包含以下内容(按顺序): 143 | 144 | - 简要说明:以函数名开头,使用空格分隔说明部分 145 | - 参数列表:每行一个参数,参数名开头,“: ”分隔说明部分 146 | - 返回值:每行一个返回值 147 | 148 | ```go 149 | // NewtAttrModel 属性数据层操作类的工厂方法 150 | // 参数: 151 | // ctx: 上下文信息 152 | // 153 | // 返回值: 154 | // *AttrModel: 属性操作类指针 155 | func NewAttrModel(ctx *common.Context) *AttrModel { 156 | } 157 | ``` 158 | 159 | ### 4. 代码逻辑注释 160 | 161 | - 对于关键位置或复杂逻辑处理,必须添加逻辑说明注释。 162 | 163 | ```go 164 | // 从 Redis 中批量读取属性,对于没有读取到的 id,记录到一个数组里面,准备从 DB 中读取 165 | // 后续代码... 166 | ``` 167 | 168 | ### 5. 注释风格 169 | 170 | - 统一使用中文注释。 171 | - 中英文字符之间必须使用空格分隔,包括中文与英文、中文与英文标点之间。 172 | 173 | ```go 174 | // 从 Redis 中批量读取属性,对于没有读取到的 id,记录到一个数组里面,准备从 DB 中读取 175 | ``` 176 | 177 | - 建议全部使用单行注释。 178 | - 单行注释不得超过 120 个字符。 179 | 180 | ## 三、代码风格 181 | 182 | ### 1. 缩进与折行 183 | 184 | - 缩进必须使用 gofmt 工具格式化(使用 tab 缩进)。 185 | - 每行代码不应超过 120 个字符,超过时应使用换行并保持格式优雅。 186 | 187 | > 使用 Goland 开发工具时,可通过快捷键 Control + Alt + L 格式化代码。 188 | 189 | ### 2. 语句结尾 190 | 191 | - Go 语言不需要使用分号结尾,一行代表一条语句。 192 | - 多条语句写在同一行时,必须使用分号分隔。 193 | 194 | ```go 195 | package main 196 | 197 | func main() { 198 | var a int = 5; var b int = 10 199 | // 多条语句写在同一行时,必须使用分号分隔 200 | c := a + b; fmt.Println(c) 201 | } 202 | ``` 203 | 204 | - 代码简单时可以使用多行语句,但建议使用单行语句 205 | 206 | ```go 207 | package main 208 | 209 | func main() { 210 | var a int = 5 211 | var b int = 10 212 | 213 | c := a + b 214 | fmt.Println(c) 215 | } 216 | ``` 217 | 218 | ### 3. 括号与空格 219 | 220 | - 左大括号不得换行(Go 语法强制要求)。 221 | - 所有运算符与操作数之间必须留有空格。 222 | 223 | ```go 224 | // 正确示例 225 | if a > 0 { 226 | // 代码块 227 | } 228 | 229 | // 错误示例 230 | if a>0 // a、0 和 > 之间应有空格 231 | { // 左大括号不可换行,会导致语法错误 232 | // 代码块 233 | } 234 | ``` 235 | 236 | ### 4. import 规范 237 | 238 | - 单个包引入时,建议使用括号格式: 239 | 240 | ```go 241 | import ( 242 | "fmt" 243 | ) 244 | ``` 245 | 246 | - 多个包引入时,应按以下顺序分组,并用空行分隔: 247 | 1. 标准库包 248 | 2. 第三方包 249 | 3. 项目内部包 250 | 4. 匿名导入 (`_`)、别名导入和点导入 (`.`) 251 | 252 | ```go 253 | import ( 254 | "fmt" 255 | "net/http" 256 | "runtime" 257 | 258 | "github.com/gin-gonic/gin" 259 | 260 | "github.com/Done-0/gin-scaffold/internal/global" 261 | "github.com/Done-0/gin-scaffold/pkg/vo" 262 | 263 | _ "github.com/go-sql-driver/mysql" // 匿名导入 264 | customname "github.com/pkg/errors" // 别名导入 265 | . "github.com/alecthomas/kingpin/v2" // 点导入(第三方包) 266 | ) 267 | ``` 268 | 269 | - 禁止使用相对路径引入外部包: 270 | 271 | ```go 272 | // 错误示例 273 | import "../net" // 禁止使用相对路径引入外部包 274 | 275 | // 正确示例 276 | import "github.com/repo/proj/src/net" 277 | ``` 278 | 279 | - 包名和导入路径不匹配,建议使用别名: 280 | 281 | ```go 282 | // 错误示例 283 | import "github.com/Done-0/gin-scaffold/gin-scaffold/internal/model/account" // 此文件的实际包名为 model 284 | 285 | // 正确示例 286 | import model "github.com/Done-0/gin-scaffold/gin-scaffold/internal/model/account" // 使用 model 别名 287 | ``` 288 | 289 | ### 5. 错误处理 290 | 291 | - 不得丢弃任何有返回 err 的调用,禁止使用 `_` 丢弃错误,必须全部处理。 292 | - 错误处理原则: 293 | - 一旦发生错误,应立即返回(尽早 return)。 294 | - 除非确切了解后果,否则不要使用 panic。 295 | - 英文错误描述必须全部小写,不需要标点结尾。 296 | - 必须采用独立的错误流进行处理。 297 | 298 | ```go 299 | // 错误示例 300 | if err != nil { 301 | // 错误处理 302 | } else { 303 | // 正常代码 304 | } 305 | 306 | // 正确示例 307 | if err != nil { 308 | // 错误处理 309 | return // 或 continue 等 310 | } 311 | // 正常代码 312 | ``` 313 | 314 | ### 6. 测试规范 315 | 316 | - 测试文件命名必须以 `_test.go` 结尾,如 `example_test.go`。 317 | - 测试函数名称必须以 `Test` 开头,如 `TestExample`。 318 | - 每个重要函数都应编写测试用例,与正式代码一起提交,便于回归测试。 319 | 320 | ## 四、常用工具 321 | 322 | Go 语言提供了多种工具帮助开发者遵循代码规范: 323 | 324 | ### gofmt 325 | 326 | 大部分格式问题可通过 gofmt 解决,它能自动格式化代码,确保所有 Go 代码与官方推荐格式保持一致。所有格式相关问题均以 gofmt 结果为准。 327 | 328 | ### goimports 329 | 330 | 强烈建议使用 goimports,它在 gofmt 基础上增加了自动删除和引入包的功能。 331 | 332 | ```bash 333 | go get golang.org/x/tools/cmd/goimports 334 | ``` 335 | 336 | ### go vet 337 | 338 | vet 工具可静态分析源码中的各种问题,如多余代码、提前 return 的逻辑、struct 的 tag 是否符合标准等。 339 | 340 | ```bash 341 | go get golang.org/x/tools/cmd/vet 342 | ``` 343 | 344 | 使用方法: 345 | 346 | ```bash 347 | go vet . 348 | ``` 349 | -------------------------------------------------------------------------------- /internal/ai/internal/provider/openai.go: -------------------------------------------------------------------------------- 1 | // Package provider implements AI provider interfaces 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package provider 5 | 6 | import ( 7 | "context" 8 | "crypto/rand" 9 | "encoding/hex" 10 | "io" 11 | "net/http" 12 | "sync/atomic" 13 | "time" 14 | 15 | "github.com/sashabaranov/go-openai" 16 | "golang.org/x/time/rate" 17 | 18 | "github.com/Done-0/gin-scaffold/configs" 19 | 20 | rateUtil "github.com/Done-0/gin-scaffold/internal/utils/rate" 21 | ) 22 | 23 | type openAIProvider struct { 24 | config *configs.ProviderInstanceConfig 25 | rateLimiter *rate.Limiter 26 | keyCounter *uint64 27 | modelCounter *uint64 28 | } 29 | 30 | func NewOpenAI(config *configs.ProviderInstanceConfig, keyCounter *uint64, modelCounter *uint64) (Provider, error) { 31 | rateLimit, burst, err := rateUtil.ParseLimit(config.RateLimit) 32 | if err != nil { 33 | return nil, err 34 | } 35 | limiter := rate.NewLimiter(rateLimit, burst) 36 | 37 | return &openAIProvider{ 38 | config: config, 39 | rateLimiter: limiter, 40 | keyCounter: keyCounter, 41 | modelCounter: modelCounter, 42 | }, nil 43 | } 44 | 45 | func (p *openAIProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { 46 | model := req.Model 47 | if model == "" { 48 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 49 | model = p.config.Models[modelIndex%uint64(len(p.config.Models))] 50 | } 51 | 52 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 53 | apiKey := p.config.Keys[keyIndex%uint64(len(p.config.Keys))] 54 | 55 | config := openai.DefaultConfig(apiKey) 56 | config.BaseURL = p.config.BaseURL 57 | config.HTTPClient = &http.Client{ 58 | Timeout: time.Duration(p.config.Timeout) * time.Second, 59 | Transport: &http.Transport{ 60 | MaxIdleConns: 100, 61 | MaxIdleConnsPerHost: 20, 62 | MaxConnsPerHost: 100, 63 | IdleConnTimeout: 90 * time.Second, 64 | DisableKeepAlives: false, 65 | }, 66 | } 67 | client := openai.NewClientWithConfig(config) 68 | 69 | messages := make([]openai.ChatCompletionMessage, len(req.Messages)) 70 | for i, msg := range req.Messages { 71 | messages[i] = openai.ChatCompletionMessage{ 72 | Role: msg.Role, 73 | Content: msg.Content, 74 | } 75 | } 76 | 77 | if err := p.rateLimiter.Wait(ctx); err != nil { 78 | return nil, err 79 | } 80 | 81 | request := openai.ChatCompletionRequest{ 82 | Model: model, 83 | Messages: messages, 84 | MaxTokens: p.config.MaxTokens, 85 | Temperature: p.config.Temperature, 86 | TopP: p.config.TopP, 87 | } 88 | 89 | var resp openai.ChatCompletionResponse 90 | var err error 91 | for attempt := 0; attempt <= p.config.MaxRetries; attempt++ { 92 | resp, err = client.CreateChatCompletion(ctx, request) 93 | if err == nil { 94 | break 95 | } 96 | if attempt < p.config.MaxRetries { 97 | time.Sleep(time.Duration(attempt+1) * time.Second) 98 | } 99 | } 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | bytes := make([]byte, 16) 105 | rand.Read(bytes) 106 | chatResp := &ChatResponse{ 107 | ID: "chatcmpl-" + hex.EncodeToString(bytes), 108 | Object: resp.Object, 109 | Created: resp.Created, 110 | Model: resp.Model, 111 | Choices: []Choice{{ 112 | Index: resp.Choices[0].Index, 113 | Message: Message{ 114 | Role: resp.Choices[0].Message.Role, 115 | Content: resp.Choices[0].Message.Content, 116 | ReasoningContent: resp.Choices[0].Message.ReasoningContent, 117 | }, 118 | FinishReason: string(resp.Choices[0].FinishReason), 119 | }}, 120 | SystemFingerprint: resp.SystemFingerprint, 121 | Provider: "openai", 122 | } 123 | 124 | if string(resp.Choices[0].FinishReason) != "" { 125 | chatResp.Usage = Usage{ 126 | PromptTokens: resp.Usage.PromptTokens, 127 | CompletionTokens: resp.Usage.CompletionTokens, 128 | TotalTokens: resp.Usage.TotalTokens, 129 | } 130 | } 131 | 132 | return chatResp, nil 133 | } 134 | 135 | func (p *openAIProvider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *ChatStreamResponse, error) { 136 | model := req.Model 137 | if model == "" { 138 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 139 | model = p.config.Models[modelIndex%uint64(len(p.config.Models))] 140 | } 141 | 142 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 143 | apiKey := p.config.Keys[keyIndex%uint64(len(p.config.Keys))] 144 | 145 | config := openai.DefaultConfig(apiKey) 146 | config.BaseURL = p.config.BaseURL 147 | config.HTTPClient = &http.Client{ 148 | Timeout: time.Duration(p.config.Timeout) * time.Second, 149 | Transport: &http.Transport{ 150 | MaxIdleConns: 100, 151 | MaxIdleConnsPerHost: 20, 152 | MaxConnsPerHost: 100, 153 | IdleConnTimeout: 90 * time.Second, 154 | DisableKeepAlives: false, 155 | }, 156 | } 157 | client := openai.NewClientWithConfig(config) 158 | 159 | if err := p.rateLimiter.Wait(ctx); err != nil { 160 | return nil, err 161 | } 162 | 163 | messages := make([]openai.ChatCompletionMessage, len(req.Messages)) 164 | for i, msg := range req.Messages { 165 | messages[i] = openai.ChatCompletionMessage{ 166 | Role: msg.Role, 167 | Content: msg.Content, 168 | } 169 | } 170 | 171 | request := openai.ChatCompletionRequest{ 172 | Model: model, 173 | Messages: messages, 174 | Stream: true, 175 | MaxTokens: p.config.MaxTokens, 176 | Temperature: float32(p.config.Temperature), 177 | TopP: float32(p.config.TopP), 178 | } 179 | 180 | var stream *openai.ChatCompletionStream 181 | var err error 182 | for attempt := 0; attempt <= p.config.MaxRetries; attempt++ { 183 | stream, err = client.CreateChatCompletionStream(ctx, request) 184 | if err == nil { 185 | break 186 | } 187 | if attempt < p.config.MaxRetries { 188 | time.Sleep(time.Duration(attempt+1) * time.Second) 189 | } 190 | } 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | ch := make(chan *ChatStreamResponse) 196 | go func() { 197 | defer close(ch) 198 | defer stream.Close() 199 | 200 | for { 201 | response, err := stream.Recv() 202 | if err != nil { 203 | if err == io.EOF { 204 | break 205 | } 206 | break 207 | } 208 | 209 | bytes := make([]byte, 16) 210 | rand.Read(bytes) 211 | streamResp := &ChatStreamResponse{ 212 | ID: "chatcmpl-" + hex.EncodeToString(bytes), 213 | Object: response.Object, 214 | Created: response.Created, 215 | Model: response.Model, 216 | SystemFingerprint: response.SystemFingerprint, 217 | Provider: "openai", 218 | } 219 | 220 | if len(response.Choices) > 0 { 221 | streamResp.Choices = []StreamChoice{{ 222 | Index: response.Choices[0].Index, 223 | Delta: MessageDelta{ 224 | Role: response.Choices[0].Delta.Role, 225 | Content: response.Choices[0].Delta.Content, 226 | ReasoningContent: response.Choices[0].Delta.ReasoningContent, 227 | }, 228 | FinishReason: string(response.Choices[0].FinishReason), 229 | }} 230 | } 231 | 232 | if response.Usage != nil { 233 | streamResp.Usage = &Usage{ 234 | PromptTokens: response.Usage.PromptTokens, 235 | CompletionTokens: response.Usage.CompletionTokens, 236 | TotalTokens: response.Usage.TotalTokens, 237 | } 238 | } 239 | 240 | ch <- streamResp 241 | } 242 | }() 243 | return ch, nil 244 | } 245 | -------------------------------------------------------------------------------- /internal/ai/internal/prompt/manager_test.go: -------------------------------------------------------------------------------- 1 | // Package prompt provides dynamic prompt loading and management tests 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package prompt 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/Done-0/gin-scaffold/configs" 13 | ) 14 | 15 | func TestManager(t *testing.T) { 16 | testDir := filepath.Join(os.TempDir(), "prompt_test") 17 | os.MkdirAll(testDir, 0755) 18 | defer os.RemoveAll(testDir) 19 | 20 | configDir := filepath.Join(testDir, "configs") 21 | os.MkdirAll(configDir, 0755) 22 | 23 | configFile := filepath.Join(configDir, "config.local.yml") 24 | promptDir := filepath.Join(testDir, "prompts") 25 | os.MkdirAll(promptDir, 0755) 26 | 27 | configContent := `AI: 28 | PROMPT: 29 | DIR: ` + promptDir 30 | os.WriteFile(configFile, []byte(configContent), 0644) 31 | 32 | oldDir, _ := os.Getwd() 33 | os.Chdir(testDir) 34 | defer os.Chdir(oldDir) 35 | 36 | if err := configs.New(); err != nil { 37 | t.Fatalf("Failed to initialize config: %v", err) 38 | } 39 | 40 | manager := New() 41 | ctx := context.Background() 42 | 43 | t.Run("CreateTemplate", func(t *testing.T) { 44 | template := &Template{ 45 | Name: "test_template", 46 | Description: "test description", 47 | Messages: []Message{ 48 | {Role: "system", Content: "You are {{.role}} assistant"}, 49 | {Role: "user", Content: "{{.message}}"}, 50 | }, 51 | } 52 | 53 | t.Logf("Creating template: %s", template.Name) 54 | if err := manager.CreateTemplate(ctx, template); err != nil { 55 | t.Fatalf("CreateTemplate failed: %v", err) 56 | } 57 | t.Logf("Template created successfully") 58 | }) 59 | 60 | t.Run("GetTemplate_WithVariables", func(t *testing.T) { 61 | vars := map[string]any{"role": "AI", "message": "Hello"} 62 | t.Logf("Getting template with variables: %+v", vars) 63 | 64 | result, err := manager.GetTemplate(ctx, "test_template", &vars) 65 | if err != nil { 66 | t.Fatalf("GetTemplate failed: %v", err) 67 | } 68 | 69 | t.Logf("Original: %s", "You are {{role}} assistant") 70 | t.Logf("Result: %s", result.Messages[0].Content) 71 | 72 | if result.Messages[0].Content != "You are AI assistant" { 73 | t.Errorf("Variable replacement failed, got: %s", result.Messages[0].Content) 74 | } 75 | t.Logf("Variable replacement working correctly") 76 | }) 77 | 78 | t.Run("GetTemplate_WithoutVariables", func(t *testing.T) { 79 | t.Logf("Getting raw template without variables") 80 | 81 | raw, err := manager.GetTemplate(ctx, "test_template", nil) 82 | if err != nil { 83 | t.Fatalf("GetTemplate without vars failed: %v", err) 84 | } 85 | 86 | t.Logf("Raw content: %s", raw.Messages[0].Content) 87 | 88 | if raw.Messages[0].Content != "You are {{.role}} assistant" { 89 | t.Errorf("Raw template content incorrect, got: %s", raw.Messages[0].Content) 90 | } 91 | t.Logf("Raw template content preserved") 92 | }) 93 | 94 | t.Run("ListTemplates", func(t *testing.T) { 95 | t.Logf("Listing all templates") 96 | 97 | names, err := manager.ListTemplates(ctx) 98 | if err != nil { 99 | t.Fatalf("ListTemplates failed: %v", err) 100 | } 101 | 102 | t.Logf("Found templates: %v", names) 103 | 104 | found := false 105 | for _, name := range names { 106 | if name == "test_template" { 107 | found = true 108 | break 109 | } 110 | } 111 | if !found { 112 | t.Errorf("Template 'test_template' not found in list: %v", names) 113 | } 114 | t.Logf("Template found in list") 115 | }) 116 | 117 | t.Run("UpdateTemplate", func(t *testing.T) { 118 | updated := &Template{ 119 | Name: "test_template", 120 | Description: "updated description", 121 | Messages: []Message{{Role: "system", Content: "Updated content"}}, 122 | } 123 | 124 | t.Logf("Updating template with new content: %s", updated.Messages[0].Content) 125 | 126 | if err := manager.UpdateTemplate(ctx, "test_template", updated); err != nil { 127 | t.Fatalf("UpdateTemplate failed: %v", err) 128 | } 129 | 130 | result, _ := manager.GetTemplate(ctx, "test_template", nil) 131 | t.Logf("Updated content: %s", result.Messages[0].Content) 132 | t.Logf("Template updated successfully") 133 | }) 134 | 135 | t.Run("UpdateTemplate_Rename", func(t *testing.T) { 136 | original := &Template{ 137 | Name: "original_name", 138 | Description: "original description", 139 | Messages: []Message{{Role: "system", Content: "Original content"}}, 140 | } 141 | manager.CreateTemplate(ctx, original) 142 | 143 | renamed := &Template{ 144 | Name: "renamed_template", 145 | Description: "renamed description", 146 | Messages: []Message{{Role: "system", Content: "Renamed content"}}, 147 | } 148 | 149 | t.Logf("Renaming template: original_name -> %s", renamed.Name) 150 | 151 | if err := manager.UpdateTemplate(ctx, "original_name", renamed); err != nil { 152 | t.Fatalf("UpdateTemplate rename failed: %v", err) 153 | } 154 | 155 | if _, err := manager.GetTemplate(ctx, "original_name", nil); err == nil { 156 | t.Error("Old template name should not exist after rename") 157 | } else { 158 | t.Logf("Old template correctly removed") 159 | } 160 | 161 | result, err := manager.GetTemplate(ctx, "renamed_template", nil) 162 | if err != nil { 163 | t.Fatalf("New template name should exist: %v", err) 164 | } 165 | t.Logf("New template found: %s", result.Name) 166 | t.Logf("Content: %s", result.Messages[0].Content) 167 | }) 168 | 169 | t.Run("DeleteTemplate", func(t *testing.T) { 170 | t.Logf("Deleting template: test_template") 171 | 172 | if err := manager.DeleteTemplate(ctx, "test_template"); err != nil { 173 | t.Fatalf("DeleteTemplate failed: %v", err) 174 | } 175 | t.Logf("Template deleted successfully") 176 | }) 177 | 178 | t.Run("ErrorHandling_NonexistentTemplate", func(t *testing.T) { 179 | t.Logf("Testing error handling for nonexistent template") 180 | 181 | if _, err := manager.GetTemplate(ctx, "nonexistent", nil); err == nil { 182 | t.Error("Should fail for nonexistent template") 183 | } else { 184 | t.Logf("Correctly failed with error: %v", err) 185 | } 186 | }) 187 | 188 | t.Run("ErrorHandling_DuplicateTemplate", func(t *testing.T) { 189 | testTemplate := &Template{ 190 | Name: "duplicate", 191 | Messages: []Message{{Role: "system", Content: "test"}}, 192 | } 193 | 194 | t.Logf("Creating template: %s", testTemplate.Name) 195 | manager.CreateTemplate(ctx, testTemplate) 196 | 197 | t.Logf("Attempting to create duplicate template") 198 | if err := manager.CreateTemplate(ctx, testTemplate); err == nil { 199 | t.Error("Should fail creating duplicate template") 200 | } else { 201 | t.Logf("✅ Correctly failed with error: %v", err) 202 | } 203 | }) 204 | 205 | t.Run("ErrorHandling_EmptyName", func(t *testing.T) { 206 | emptyTemplate := &Template{ 207 | Name: "", 208 | Messages: []Message{{Role: "system", Content: "test"}}, 209 | } 210 | 211 | t.Logf("Testing empty template name") 212 | if err := manager.CreateTemplate(ctx, emptyTemplate); err == nil { 213 | t.Error("Should fail for empty template name") 214 | } else { 215 | t.Logf("✅ Correctly failed with error: %v", err) 216 | } 217 | }) 218 | 219 | t.Run("ErrorHandling_EmptyMessages", func(t *testing.T) { 220 | emptyMsgTemplate := &Template{ 221 | Name: "empty_messages", 222 | Messages: []Message{}, 223 | } 224 | 225 | t.Logf("Testing template with no messages") 226 | if err := manager.CreateTemplate(ctx, emptyMsgTemplate); err == nil { 227 | t.Error("Should fail for template with no messages") 228 | } else { 229 | t.Logf("✅ Correctly failed with error: %v", err) 230 | } 231 | }) 232 | } 233 | -------------------------------------------------------------------------------- /internal/ai/internal/provider/gemini.go: -------------------------------------------------------------------------------- 1 | // Package provider implements AI provider interfaces 2 | // Author: Done-0 3 | // Created: 2025-08-31 4 | package provider 5 | 6 | import ( 7 | "context" 8 | "crypto/rand" 9 | "encoding/hex" 10 | "sync/atomic" 11 | "time" 12 | 13 | "golang.org/x/time/rate" 14 | "google.golang.org/genai" 15 | 16 | "github.com/Done-0/gin-scaffold/configs" 17 | 18 | rateUtil "github.com/Done-0/gin-scaffold/internal/utils/rate" 19 | ) 20 | 21 | type geminiProvider struct { 22 | config *configs.ProviderInstanceConfig 23 | rateLimiter *rate.Limiter 24 | keyCounter *uint64 25 | modelCounter *uint64 26 | } 27 | 28 | func NewGemini(config *configs.ProviderInstanceConfig, keyCounter *uint64, modelCounter *uint64) (Provider, error) { 29 | rateLimit, burst, err := rateUtil.ParseLimit(config.RateLimit) 30 | if err != nil { 31 | return nil, err 32 | } 33 | limiter := rate.NewLimiter(rateLimit, burst) 34 | 35 | return &geminiProvider{ 36 | config: config, 37 | rateLimiter: limiter, 38 | keyCounter: keyCounter, 39 | modelCounter: modelCounter, 40 | }, nil 41 | } 42 | 43 | func (p *geminiProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { 44 | model := req.Model 45 | if model == "" { 46 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 47 | model = p.config.Models[modelIndex%uint64(len(p.config.Models))] 48 | } 49 | 50 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 51 | apiKey := p.config.Keys[keyIndex%uint64(len(p.config.Keys))] 52 | 53 | if err := p.rateLimiter.Wait(ctx); err != nil { 54 | return nil, err 55 | } 56 | 57 | clientConfig := &genai.ClientConfig{ 58 | APIKey: apiKey, 59 | Backend: genai.BackendGeminiAPI, 60 | } 61 | 62 | client, err := genai.NewClient(ctx, clientConfig) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | var prompt string 68 | for _, msg := range req.Messages { 69 | prompt += msg.Content + "\n" 70 | } 71 | 72 | var resp *genai.GenerateContentResponse 73 | for attempt := 0; attempt <= p.config.MaxRetries; attempt++ { 74 | resp, err = client.Models.GenerateContent( 75 | ctx, 76 | model, 77 | genai.Text(prompt), 78 | &genai.GenerateContentConfig{ 79 | Temperature: &p.config.Temperature, 80 | MaxOutputTokens: int32(p.config.MaxTokens), 81 | TopP: &p.config.TopP, 82 | TopK: func() *float32 { v := float32(p.config.TopK); return &v }(), 83 | ThinkingConfig: &genai.ThinkingConfig{ 84 | IncludeThoughts: true, 85 | }, 86 | }, 87 | ) 88 | if err == nil { 89 | break 90 | } 91 | if attempt < p.config.MaxRetries { 92 | time.Sleep(time.Duration(attempt+1) * time.Second) 93 | } 94 | } 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | var content, reasoningContent string 100 | if len(resp.Candidates) > 0 { 101 | candidate := resp.Candidates[0] 102 | if candidate.Content != nil { 103 | for _, part := range candidate.Content.Parts { 104 | if len(part.Text) == 0 { 105 | continue 106 | } 107 | if part.Thought { 108 | reasoningContent += part.Text 109 | } else { 110 | content += part.Text 111 | } 112 | } 113 | } 114 | } 115 | 116 | now := time.Now() 117 | bytes := make([]byte, 16) 118 | rand.Read(bytes) 119 | chatResp := &ChatResponse{ 120 | ID: "chatcmpl-" + hex.EncodeToString(bytes), 121 | Object: "chat.completion", 122 | Created: now.Unix(), 123 | Model: model, 124 | Choices: []Choice{{ 125 | Index: 0, 126 | Message: Message{ 127 | Role: "assistant", 128 | Content: content, 129 | ReasoningContent: reasoningContent, 130 | }, 131 | FinishReason: string(resp.Candidates[0].FinishReason), 132 | }}, 133 | SystemFingerprint: "", 134 | Provider: "gemini", 135 | } 136 | 137 | if resp.Candidates[0].FinishReason != "" { 138 | chatResp.Usage = Usage{ 139 | PromptTokens: int(resp.UsageMetadata.PromptTokenCount), 140 | CompletionTokens: int(resp.UsageMetadata.CandidatesTokenCount), 141 | TotalTokens: int(resp.UsageMetadata.TotalTokenCount), 142 | } 143 | } 144 | 145 | return chatResp, nil 146 | } 147 | 148 | func (p *geminiProvider) ChatStream(ctx context.Context, req *ChatRequest) (<-chan *ChatStreamResponse, error) { 149 | model := req.Model 150 | if model == "" { 151 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 152 | model = p.config.Models[modelIndex%uint64(len(p.config.Models))] 153 | } 154 | 155 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 156 | apiKey := p.config.Keys[keyIndex%uint64(len(p.config.Keys))] 157 | 158 | if err := p.rateLimiter.Wait(ctx); err != nil { 159 | return nil, err 160 | } 161 | 162 | clientConfig := &genai.ClientConfig{ 163 | APIKey: apiKey, 164 | Backend: genai.BackendGeminiAPI, 165 | } 166 | 167 | contents := make([]*genai.Content, len(req.Messages)) 168 | for i, msg := range req.Messages { 169 | role := genai.RoleUser 170 | if msg.Role == "assistant" { 171 | role = genai.RoleModel 172 | } 173 | contents[i] = &genai.Content{ 174 | Parts: []*genai.Part{{Text: msg.Content}}, 175 | Role: role, 176 | } 177 | } 178 | 179 | var client *genai.Client 180 | var err error 181 | for attempt := 0; attempt <= p.config.MaxRetries; attempt++ { 182 | client, err = genai.NewClient(ctx, clientConfig) 183 | if err == nil { 184 | break 185 | } 186 | if attempt < p.config.MaxRetries { 187 | time.Sleep(time.Duration(attempt+1) * time.Second) 188 | } 189 | } 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | stream := client.Models.GenerateContentStream( 195 | ctx, 196 | model, 197 | contents, 198 | &genai.GenerateContentConfig{ 199 | Temperature: &p.config.Temperature, 200 | MaxOutputTokens: int32(p.config.MaxTokens), 201 | TopP: &p.config.TopP, 202 | TopK: func() *float32 { v := float32(p.config.TopK); return &v }(), 203 | ThinkingConfig: &genai.ThinkingConfig{ 204 | IncludeThoughts: true, 205 | }, 206 | }, 207 | ) 208 | 209 | ch := make(chan *ChatStreamResponse) 210 | go func() { 211 | defer close(ch) 212 | 213 | if stream == nil { 214 | return 215 | } 216 | 217 | for chunk := range stream { 218 | if chunk == nil { 219 | continue 220 | } 221 | 222 | if len(chunk.Candidates) == 0 { 223 | continue 224 | } 225 | 226 | candidate := chunk.Candidates[0] 227 | if candidate.Content == nil || len(candidate.Content.Parts) == 0 { 228 | continue 229 | } 230 | 231 | var normalContent, thinkingContent string 232 | 233 | for _, part := range candidate.Content.Parts { 234 | if part.Text == "" { 235 | continue 236 | } 237 | 238 | if part.Thought { 239 | thinkingContent += part.Text 240 | } else { 241 | normalContent += part.Text 242 | } 243 | } 244 | 245 | now := time.Now() 246 | 247 | bytes := make([]byte, 16) 248 | rand.Read(bytes) 249 | streamResp := &ChatStreamResponse{ 250 | ID: "chatcmpl-" + hex.EncodeToString(bytes), 251 | Object: "chat.completion.chunk", 252 | Created: now.Unix(), 253 | Model: model, 254 | Choices: []StreamChoice{{ 255 | Index: 0, 256 | Delta: MessageDelta{ 257 | Role: "assistant", 258 | Content: normalContent, 259 | ReasoningContent: thinkingContent, 260 | }, 261 | FinishReason: string(candidate.FinishReason), 262 | }}, 263 | SystemFingerprint: "", 264 | Provider: "gemini", 265 | } 266 | 267 | if candidate.FinishReason != "" { 268 | streamResp.Usage = &Usage{ 269 | PromptTokens: int(chunk.UsageMetadata.PromptTokenCount), 270 | CompletionTokens: int(chunk.UsageMetadata.CandidatesTokenCount), 271 | TotalTokens: int(chunk.UsageMetadata.TotalTokenCount), 272 | } 273 | } 274 | 275 | select { 276 | case ch <- streamResp: 277 | case <-ctx.Done(): 278 | return 279 | } 280 | 281 | if candidate.FinishReason == genai.FinishReasonStop || candidate.FinishReason == genai.FinishReasonMaxTokens { 282 | break 283 | } 284 | } 285 | }() 286 | return ch, nil 287 | } 288 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | gin-scaffold is a production-ready Go web service scaffold built on Gin framework with enterprise-grade features including database support (PostgreSQL, MySQL, SQLite), Redis caching, Kafka messaging, SSE streaming, i18n, and AI provider integrations (OpenAI, Gemini). 8 | 9 | ## Development Commands 10 | 11 | ### Running the Application 12 | 13 | ```bash 14 | # Local development 15 | go run main.go 16 | 17 | # Production mode 18 | ENV=prod go run main.go 19 | ``` 20 | 21 | ### Testing 22 | 23 | ```bash 24 | # Run all tests 25 | go test ./... 26 | 27 | # Run specific test 28 | go test ./internal/utils/vo/vo_utils_test.go 29 | 30 | # Run tests with verbose output 31 | go test -v ./... 32 | ``` 33 | 34 | ### Code Quality 35 | 36 | ```bash 37 | # Format code 38 | gofmt -w . 39 | 40 | # Auto-import management 41 | goimports -w . 42 | 43 | # Static analysis 44 | go vet ./... 45 | 46 | # Generate Wire dependency injection code 47 | cd pkg/wire && wire 48 | ``` 49 | 50 | ## Architecture Overview 51 | 52 | ### Dependency Injection with Wire 53 | 54 | The application uses Google Wire for compile-time dependency injection. All dependencies are wired together in `pkg/wire/`: 55 | - `wire.go` defines the dependency graph using `//go:build wireinject` 56 | - `wire_gen.go` is auto-generated (never edit manually) 57 | - `providers.go` contains all provider functions 58 | - The `Container` struct holds all initialized dependencies (managers, controllers, services) 59 | 60 | To add new dependencies: 61 | 1. Add provider function to `providers.go` 62 | 2. Add to `AllProviders` wire set 63 | 3. Add field to `Container` struct in `wire.go` 64 | 4. Run `wire` to regenerate `wire_gen.go` 65 | 66 | ### Application Lifecycle 67 | 68 | The application follows this initialization sequence (see `cmd/gin_server.go:25`): 69 | 1. Load configuration from `configs/config.{local|prod}.yml` based on ENV 70 | 2. Wire all dependencies via `wire.NewContainer()` 71 | 3. Initialize managers in order: Logger → Database → Redis 72 | 4. Setup Gin engine with middleware and routes 73 | 5. Start HTTP server with graceful shutdown support 74 | 75 | All managers implement `Initialize()` and `Close()` methods and are deferred for proper cleanup. 76 | 77 | ### Module Organization 78 | 79 | The codebase follows a domain-driven structure: 80 | 81 | **Infrastructure Layer** (`internal/`): 82 | - `db/` - Database management with GORM (auto-migration, multi-DB support) 83 | - `redis/` - Redis client with connection pooling 84 | - `logger/` - Logrus-based structured logging with rotation 85 | - `queue/` - Kafka producer/consumer 86 | - `sse/` - Server-Sent Events manager 87 | - `i18n/` - Internationalization with go-i18n 88 | - `ai/` - Multi-provider AI service (OpenAI/Gemini) with rate limiting and prompt management 89 | 90 | **Domain Layer** (`internal/`): 91 | - `model/` - GORM models with base model pattern 92 | - `types/` - Domain types, constants, error codes 93 | - `utils/` - Shared utilities (errorx, validator, snowflake, etc.) 94 | 95 | **Application Layer** (`pkg/`): 96 | - `serve/controller/` - HTTP controllers with DTO validation 97 | - `serve/service/` - Business logic layer 98 | - `serve/mapper/` - Data access layer (optional, use GORM directly if simple) 99 | - `vo/` - View objects for API responses 100 | - `router/` - Route registration organized by modules 101 | 102 | ### Three-Layer Architecture Pattern 103 | 104 | Follow this structure when adding new features: 105 | ``` 106 | pkg/serve/ 107 | ├── controller/ 108 | │ └── {module}/ 109 | │ ├── dto/ # Request DTOs with validation tags 110 | │ └── {module}.go # HTTP handlers 111 | ├── service/ 112 | │ └── {module}/ 113 | │ ├── {module}.go # Service interface 114 | │ └── impl/ 115 | │ └── {module}.go # Service implementation 116 | └── mapper/ 117 | └── {module}/ 118 | ├── {module}.go # Mapper interface 119 | └── impl/ 120 | └── {module}.go # Data access implementation 121 | ``` 122 | 123 | ### Configuration Management 124 | 125 | Configuration uses Viper with hot-reload support: 126 | - `configs/config.local.yml` - Local development 127 | - `configs/config.prod.yml` - Production 128 | - Config changes are automatically detected and logged 129 | - Access config via `configs.GetConfig()` with RWMutex protection 130 | - Use `UpdateField()` to programmatically modify config 131 | 132 | ### Error Handling System 133 | 134 | The custom error system (`internal/utils/errorx/`) provides: 135 | - Structured errors with status codes and i18n messages 136 | - Stack trace capture for debugging 137 | - Template parameter support for dynamic messages 138 | - Error code registry with range allocation (see `internal/types/errno/README.md`) 139 | 140 | Register error codes in `internal/types/errno/`: 141 | ```go 142 | errorx.Register(10001, "user not found") 143 | ``` 144 | 145 | Create errors: 146 | ```go 147 | return errorx.New(10001) 148 | return errorx.New(10002, errorx.KV("field", "email")) 149 | ``` 150 | 151 | Error code ranges: 152 | - 10000-19999: System errors (next: 10009) 153 | 154 | ### Middleware Stack 155 | 156 | Middleware is registered in `internal/middleware/middleware.go:13`: 157 | 1. RequestID - Adds unique request ID 158 | 2. Logger - Request/response logging 159 | 3. Recovery - Panic recovery 160 | 4. CORS - Cross-origin support 161 | 5. Custom middleware as needed 162 | 163 | ### Route Organization 164 | 165 | Routes are organized by module in `pkg/router/routes/`: 166 | - Each module has its own routes file (e.g., `test_routes.go`) 167 | - Routes are registered by API version (`/api/v1`, `/api/v2`) 168 | - Registration function pattern: `RegisterXxxRoutes(container, v1, v2)` 169 | 170 | ## Coding Standards 171 | 172 | This project follows strict Go coding conventions documented in `docs/coding-standards.en-US.md`. Key points: 173 | 174 | ### Naming Conventions 175 | - Packages: lowercase, match directory name 176 | - Files: lowercase with underscores (e.g., `user_service.go`) 177 | - Structs/Interfaces: CamelCase, exported if uppercase first letter 178 | - Variables: camelCase, special acronyms like `API`, `ID` keep original case unless first word 179 | - Booleans: must start with `Has`, `Is`, `Can`, `Allow` 180 | - Constants: CamelCase with category prefix (e.g., `MethodGET`) 181 | 182 | ### Comments (English only) 183 | - Package comment required with: description, `Author: [GitHub]`, `Created: YYYY-MM-DD` 184 | - Struct/Interface: `// [Name], [description]` format 185 | - Struct fields: inline comments aligned 186 | - Functions: description, parameters, returns (optional but recommended) 187 | 188 | ### Code Style 189 | - Use `gofmt` for formatting (tabs, not spaces) 190 | - Max 120 characters per line 191 | - Opening brace on same line 192 | - Imports grouped: stdlib → third-party → internal 193 | - Early returns for error handling (no else blocks) 194 | - Test files end with `_test.go`, functions start with `Test` 195 | 196 | ### Import Organization 197 | ```go 198 | import ( 199 | "fmt" 200 | "net/http" 201 | 202 | "github.com/gin-gonic/gin" 203 | 204 | "github.com/Done-0/gin-scaffold/internal/logger" 205 | "github.com/Done-0/gin-scaffold/pkg/vo" 206 | ) 207 | ``` 208 | 209 | ## AI Provider Integration 210 | 211 | The AI module (`internal/ai/`) supports multiple providers with: 212 | - **Load balancing**: Round-robin across API keys 213 | - **Rate limiting**: Per-instance rate limits (e.g., "60/min") 214 | - **Retry logic**: Configurable max retries 215 | - **Prompt templates**: YAML-based templates in `configs/prompts/` 216 | - **Stream support**: SSE-based streaming responses 217 | 218 | Configuration in `configs/config.*.yml` under `AI.PROVIDERS`: 219 | - Each provider (openai, gemini) can have multiple instances 220 | - Each instance has its own keys, models, and parameters 221 | - Enable/disable at provider or instance level 222 | 223 | ## Common Patterns 224 | 225 | ### Creating a New API Endpoint 226 | 227 | 1. Define error codes in `internal/types/errno/system.go` 228 | 2. Create DTO in `pkg/serve/controller/dto/` with validation tags 229 | 3. Create VO in `pkg/vo/` for response 230 | 4. Implement service interface and implementation 231 | 5. Create controller with dependency injection 232 | 6. Register routes in `pkg/router/routes/` 233 | 7. Add provider to `pkg/wire/providers.go` 234 | 8. Update `Container` in `pkg/wire/wire.go` 235 | 236 | ### Database Models 237 | 238 | Extend `internal/model/base/base.go` for automatic timestamps: 239 | ```go 240 | type User struct { 241 | base.BaseModel 242 | Username string `gorm:"uniqueIndex;not null"` 243 | } 244 | ``` 245 | 246 | Models auto-migrate on startup via `internal/db/internal/setup.go:15`. 247 | 248 | ### Working with Redis 249 | 250 | Access via `RedisManager` in container: 251 | ```go 252 | rdb := container.RedisManager.GetClient() 253 | rdb.Set(ctx, "key", "value", 0) 254 | ``` 255 | 256 | ### SSE Streaming 257 | 258 | Use `SSEManager` for server-sent events: 259 | ```go 260 | stream := container.SSEManager.CreateStream(clientID) 261 | defer container.SSEManager.RemoveStream(clientID) 262 | // Send events via stream 263 | ``` 264 | 265 | ## Environment Variables 266 | 267 | - `ENV`: Set to `prod`/`production` for production mode, otherwise local config is used 268 | - Config files are selected automatically based on ENV 269 | -------------------------------------------------------------------------------- /docs/coding-standards.en-US.md: -------------------------------------------------------------------------------- 1 | # Go Coding Standards 2 | 3 | ## 1. Naming Conventions 4 | 5 | ### Basic Principles 6 | 7 | - Names must begin with a letter (A-Z, a-z) or an underscore, and can be followed by letters, underscores, or numbers (0-9). 8 | - Special characters such as @, $, % are strictly prohibited in names. 9 | - Go is case-sensitive. Identifiers starting with an uppercase letter are public (exported), while those starting with a lowercase letter are private (internal to the package). 10 | 11 | ### 1.1. Package Naming 12 | 13 | - The package name must be consistent with the directory name. Choose a name that is concise, meaningful, and does not conflict with the standard library. 14 | - Package names must be all lowercase. Multiple words can be separated by underscores or use mixed case (camelCase is not recommended). 15 | 16 | ```go 17 | package demo 18 | package main 19 | ``` 20 | 21 | ### 1.2. File Naming 22 | 23 | - Filenames should be clear, concise, and easy to understand. 24 | - They must use lowercase letters, with words separated by underscores. 25 | 26 | ```go 27 | my_test.go 28 | ``` 29 | 30 | ### 1.3. Struct Naming 31 | 32 | - Struct names must use CamelCase. The first letter's case depends on the desired access control (public/private). 33 | - Struct declarations and initializations must use a multi-line format, as shown below: 34 | 35 | ```go 36 | // Multi-line declaration 37 | type User struct { 38 | Username string 39 | Email string 40 | } 41 | 42 | // Multi-line initialization 43 | user := User{ 44 | Username: "admin", 45 | Email: "admin@example.com", 46 | } 47 | ``` 48 | 49 | ### 1.4. Interface Naming 50 | 51 | - Interface names must use CamelCase. The first letter's case depends on the desired access control. 52 | - Interfaces with a single method should be named by the method name plus an "er" suffix (e.g., `Reader`, `Writer`). 53 | 54 | ```go 55 | type Reader interface { 56 | Read(p []byte) (n int, err error) 57 | } 58 | ``` 59 | 60 | ### 1.5. Variable Naming 61 | 62 | - Variable names must use CamelCase. The first letter's case depends on the desired access control. 63 | - Rules for handling special nouns (e.g., API, ID): 64 | - If the variable is private and the special noun is the first word, use lowercase (e.g., `apiClient`). 65 | - In other cases, keep the original capitalization (e.g., `APIClient`, `repoID`, `UserID`). 66 | - Incorrect example: `UrlArray`. Correct examples: `urlArray` or `URLArray`. 67 | - Boolean variable names must start with `Has`, `Is`, `Can`, or `Allow`. 68 | 69 | ```go 70 | var isExist bool 71 | var hasConflict bool 72 | var canManage bool 73 | var allowGitHook bool 74 | ``` 75 | 76 | ### 1.6. Constant Naming 77 | 78 | - Constant names must use CamelCase and be prefixed according to their category. 79 | 80 | ```go 81 | // HTTP method constants 82 | const ( 83 | MethodGET = "GET" 84 | MethodPOST = "POST" 85 | ) 86 | ``` 87 | 88 | - Enumerated constants should also follow this convention: 89 | 90 | ```go 91 | type Scheme string 92 | 93 | const ( 94 | SchemeHTTP Scheme = "http" 95 | SchemeHTTPS Scheme = "https" 96 | ) 97 | ``` 98 | 99 | ### 1.7. Keywords 100 | 101 | Go keywords: `break`, `case`, `chan`, `const`, `continue`, `default`, `defer`, `else`, `fallthrough`, `for`, `func`, `go`, `goto`, `if`, `import`, `interface`, `map`, `package`, `range`, `return`, `select`, `struct`, `switch`, `type`, `var` 102 | 103 | ## 2. Commenting Standards 104 | 105 | Go supports C-style comments: `/**/` and `//`. 106 | 107 | - Line comments (`//`) are the most common form. 108 | - Block comments (`/* */`) are mainly used for package comments and cannot be nested. They are typically used for documentation or commenting out large blocks of code. 109 | 110 | ### 2.1. Package Comments 111 | 112 | - Every package must have a package comment preceding the `package` clause. 113 | - If a package has multiple files, the package comment only needs to appear in one file (preferably the one with the same name as the package). 114 | - The package comment must include the following information in order: 115 | - A brief introduction to the package (name and functionality). 116 | - Creator information, format: `Creator: [GitHub Username]` 117 | - Creation date, format: `Created: YYYY-MM-DD` 118 | 119 | ```go 120 | // Package biz_err provides business error codes and messages. 121 | // Creator: Done-0 122 | // Created: 2025-07-01 123 | ``` 124 | 125 | ### 2.2. Struct and Interface Comments 126 | 127 | - Every custom struct or interface must have a comment on the line preceding its definition. 128 | - The format is: `// [Struct/Interface Name], [Description]`. 129 | - Each field of a struct must have a comment, placed after the field and aligned. 130 | - Example: `User` is the struct name, and `user object, defines basic user information` is the description. 131 | 132 | ```go 133 | // User, user object, defines basic user information. 134 | type User struct { 135 | Username string // Username 136 | Email string // Email 137 | } 138 | ``` 139 | 140 | ### 2.3. Function and Method Comments (Optional) 141 | 142 | Each function or method should have a comment that includes (in order): 143 | 144 | - A brief description: Start with the function name, followed by a space and the description. 145 | - Parameters: One per line, starting with the parameter name, followed by `: ` and the description. 146 | - Return values: One per line. 147 | 148 | ```go 149 | // NewAttrModel is a factory method for the attribute data layer. 150 | // Parameters: 151 | // ctx: Context information. 152 | // 153 | // Returns: 154 | // *AttrModel: A pointer to the attribute model. 155 | func NewAttrModel(ctx *common.Context) *AttrModel { 156 | } 157 | ``` 158 | 159 | ### 2.4. Code Logic Comments 160 | 161 | - Add comments to explain critical sections or complex logic. 162 | 163 | ```go 164 | // Batch read attributes from Redis. For IDs not found, 165 | // record them in an array to be read from the DB later. 166 | // ... subsequent code ... 167 | ``` 168 | 169 | ### 2.5. Comment Style 170 | 171 | - Use English for all comments. 172 | - A space must separate Chinese and English characters, including between Chinese characters and English punctuation. 173 | - It is recommended to use single-line comments exclusively. 174 | - A single-line comment should not exceed 120 characters. 175 | 176 | ## 3. Code Style 177 | 178 | ### 3.1. Indentation and Line Breaks 179 | 180 | - Indentation must be formatted with the `gofmt` tool (using tabs). 181 | - Each line of code should not exceed 120 characters. Longer lines should be broken and formatted elegantly. 182 | 183 | > In Goland, you can format code with the shortcut `Control + Alt + L`. 184 | 185 | ### 3.2. Statement Termination 186 | 187 | - Go does not require semicolons at the end of statements; a new line implies a new statement. 188 | - If multiple statements are on the same line, they must be separated by semicolons. 189 | 190 | ```go 191 | package main 192 | 193 | func main() { 194 | var a int = 5; var b int = 10 195 | // Multiple statements on the same line must be separated by semicolons. 196 | c := a + b; fmt.Println(c) 197 | } 198 | ``` 199 | 200 | - While multi-statement lines are allowed for simple code, single-statement lines are recommended. 201 | 202 | ```go 203 | package main 204 | 205 | func main() { 206 | var a int = 5 207 | var b int = 10 208 | 209 | c := a + b 210 | fmt.Println(c) 211 | } 212 | ``` 213 | 214 | ### 3.3. Braces and Spaces 215 | 216 | - The opening brace must not be on a new line (enforced by Go syntax). 217 | - A space must be present between all operators and their operands. 218 | 219 | ```go 220 | // Correct 221 | if a > 0 { 222 | // Code block 223 | } 224 | 225 | // Incorrect 226 | if a>0 // Spaces should be around > 227 | { // Opening brace cannot be on a new line 228 | // Code block 229 | } 230 | ``` 231 | 232 | ### 3.4. Import Standards 233 | 234 | - For a single package, use the parenthesized format: 235 | 236 | ```go 237 | import ( 238 | "fmt" 239 | ) 240 | ``` 241 | 242 | - When importing multiple packages, they should be grouped in the following order, separated by blank lines: 243 | 244 | 1. Standard library packages 245 | 2. Third-party packages 246 | 3. Internal project packages 247 | 248 | - Aliased imports, blank imports (`_`), and dot imports (`.`) should be placed within their respective groups and sorted alphabetically. **Note:** Dot imports can pollute the current namespace and should be used with caution. 249 | 250 | ```go 251 | import ( 252 | "fmt" 253 | "net/http" 254 | "runtime" 255 | 256 | "github.com/gin-gonic/gin" 257 | 258 | "github.com/Done-0/gin-scaffold/internal/global" 259 | "github.com/Done-0/gin-scaffold/pkg/vo" 260 | 261 | _ "github.com/go-sql-driver/mysql" // Blank import (third-party) 262 | customname "github.com/pkg/errors" // Aliased import (third-party) 263 | . "github.com/alecthomas/kingpin/v2" // Dot import (third-party) 264 | ) 265 | ``` 266 | 267 | - Do not use relative paths to import external packages: 268 | 269 | ```go 270 | // Incorrect 271 | import "../net" // Relative imports of external packages are forbidden. 272 | 273 | // Correct 274 | import "github.com/repo/proj/src/net" 275 | ``` 276 | 277 | - If the package name and import path do not match, use an alias: 278 | 279 | ```go 280 | // Incorrect 281 | import "github.com/Done-0/gin-scaffold/gin-scaffold/internal/model/account" // The actual package name is `model`. 282 | 283 | // Correct 284 | import model "github.com/Done-0/gin-scaffold/gin-scaffold/internal/model/account" // Use `model` as an alias. 285 | ``` 286 | 287 | ### 3.5. Error Handling 288 | 289 | - Never discard an error returned from a call. Do not use `_` to discard errors; they must all be handled. 290 | - Error handling principles: 291 | - Return immediately upon error (return early). 292 | - Do not use `panic` unless you know the exact consequences. 293 | - Error messages in English must be all lowercase and should not end with punctuation. 294 | - Errors must be handled in a separate error flow. 295 | 296 | ```go 297 | // Incorrect 298 | if err != nil { 299 | // Error handling 300 | } else { 301 | // Normal code 302 | } 303 | 304 | // Correct 305 | if err != nil { 306 | // Error handling 307 | return // or continue, etc. 308 | } 309 | // Normal code 310 | ``` 311 | 312 | ### 3.6. Testing Standards 313 | 314 | - Test filenames must end with `_test.go` (e.g., `example_test.go`). 315 | - Test function names must start with `Test` (e.g., `TestExample`). 316 | - Every important function should have a test case submitted with the official code for regression testing. 317 | 318 | ## 4. Common Tools 319 | 320 | Go provides several tools to help developers follow coding standards: 321 | 322 | ### gofmt 323 | 324 | Most formatting issues can be resolved with `gofmt`. It automatically formats code to ensure consistency with official Go standards. All formatting questions are settled by the output of `gofmt`. 325 | 326 | ### goimports 327 | 328 | `goimports` is highly recommended. It builds on `gofmt` by automatically adding and removing package imports. 329 | 330 | ```bash 331 | go get golang.org/x/tools/cmd/goimports 332 | ``` 333 | 334 | ### go vet 335 | 336 | The `vet` tool statically analyzes source code for various issues, such as dead code, prematurely returned logic, and non-standard struct tags. 337 | 338 | ```bash 339 | go get golang.org/x/tools/cmd/vet 340 | ``` 341 | 342 | Usage: 343 | 344 | ```bash 345 | go vet . 346 | ``` 347 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /internal/ai/internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "sync/atomic" 5 | "testing" 6 | 7 | "github.com/Done-0/gin-scaffold/configs" 8 | ) 9 | 10 | func TestNewOpenAI(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | config *configs.ProviderInstanceConfig 14 | wantErr bool 15 | }{ 16 | { 17 | name: "valid config", 18 | config: &configs.ProviderInstanceConfig{ 19 | Enabled: true, 20 | BaseURL: "https://api.openai.com/v1", 21 | Keys: []string{"test-key"}, 22 | Models: []string{"gpt-3.5-turbo"}, 23 | Timeout: 30, 24 | MaxRetries: 3, 25 | RateLimit: "60/min", 26 | }, 27 | wantErr: false, 28 | }, 29 | { 30 | name: "invalid rate limit", 31 | config: &configs.ProviderInstanceConfig{ 32 | Enabled: true, 33 | BaseURL: "https://api.openai.com/v1", 34 | Keys: []string{"test-key"}, 35 | Models: []string{"gpt-3.5-turbo"}, 36 | Timeout: 30, 37 | MaxRetries: 3, 38 | RateLimit: "invalid", 39 | }, 40 | wantErr: true, 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | keyCounter := new(uint64) 47 | modelCounter := new(uint64) 48 | provider, err := NewOpenAI(tt.config, keyCounter, modelCounter) 49 | if (err != nil) != tt.wantErr { 50 | t.Errorf("NewOpenAI() error = %v, wantErr %v", err, tt.wantErr) 51 | return 52 | } 53 | if !tt.wantErr && provider == nil { 54 | t.Error("NewOpenAI() returned nil provider") 55 | } 56 | if tt.wantErr && provider != nil { 57 | t.Error("NewOpenAI() should return nil on error") 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func TestNewGemini(t *testing.T) { 64 | tests := []struct { 65 | name string 66 | config *configs.ProviderInstanceConfig 67 | wantErr bool 68 | }{ 69 | { 70 | name: "valid config", 71 | config: &configs.ProviderInstanceConfig{ 72 | Enabled: true, 73 | BaseURL: "https://generativelanguage.googleapis.com/v1beta", 74 | Keys: []string{"test-key"}, 75 | Models: []string{"gemini-pro"}, 76 | Timeout: 30, 77 | MaxRetries: 3, 78 | RateLimit: "30/min", 79 | }, 80 | wantErr: false, 81 | }, 82 | { 83 | name: "invalid rate limit", 84 | config: &configs.ProviderInstanceConfig{ 85 | Enabled: true, 86 | BaseURL: "https://generativelanguage.googleapis.com/v1beta", 87 | Keys: []string{"test-key"}, 88 | Models: []string{"gemini-pro"}, 89 | Timeout: 30, 90 | MaxRetries: 3, 91 | RateLimit: "invalid", 92 | }, 93 | wantErr: true, 94 | }, 95 | } 96 | 97 | for _, tt := range tests { 98 | t.Run(tt.name, func(t *testing.T) { 99 | keyCounter := new(uint64) 100 | modelCounter := new(uint64) 101 | provider, err := NewGemini(tt.config, keyCounter, modelCounter) 102 | if (err != nil) != tt.wantErr { 103 | t.Errorf("NewGemini() error = %v, wantErr %v", err, tt.wantErr) 104 | return 105 | } 106 | if !tt.wantErr && provider == nil { 107 | t.Error("NewGemini() returned nil provider") 108 | } 109 | if tt.wantErr && provider != nil { 110 | t.Error("NewGemini() should return nil on error") 111 | } 112 | }) 113 | } 114 | } 115 | 116 | func TestProviderRateLimiter(t *testing.T) { 117 | config := &configs.ProviderInstanceConfig{ 118 | Enabled: true, 119 | BaseURL: "https://api.openai.com/v1", 120 | Keys: []string{"test-key"}, 121 | Models: []string{"gpt-3.5-turbo"}, 122 | Timeout: 30, 123 | MaxRetries: 3, 124 | RateLimit: "2/s", 125 | } 126 | 127 | keyCounter := new(uint64) 128 | modelCounter := new(uint64) 129 | provider, err := NewOpenAI(config, keyCounter, modelCounter) 130 | if err != nil { 131 | t.Fatalf("NewOpenAI() failed: %v", err) 132 | } 133 | 134 | openaiProvider, ok := provider.(*openAIProvider) 135 | if !ok { 136 | t.Fatal("provider is not *openAIProvider") 137 | } 138 | 139 | if openaiProvider.rateLimiter == nil { 140 | t.Fatal("rate limiter is nil") 141 | } 142 | 143 | if !openaiProvider.rateLimiter.Allow() { 144 | t.Error("rate limiter should allow first request") 145 | } 146 | } 147 | 148 | func TestKeyRoundRobin(t *testing.T) { 149 | config := &configs.ProviderInstanceConfig{ 150 | Enabled: true, 151 | BaseURL: "https://api.openai.com/v1", 152 | Keys: []string{"key-1", "key-2", "key-3"}, 153 | Models: []string{"gpt-3.5-turbo"}, 154 | Timeout: 30, 155 | MaxRetries: 3, 156 | RateLimit: "100/s", 157 | } 158 | 159 | keyCounter := new(uint64) 160 | modelCounter := new(uint64) 161 | expected := []string{"key-1", "key-2", "key-3", "key-1", "key-2", "key-3"} 162 | 163 | for i, want := range expected { 164 | provider, err := NewOpenAI(config, keyCounter, modelCounter) 165 | if err != nil { 166 | t.Fatalf("NewOpenAI() failed: %v", err) 167 | } 168 | 169 | p := provider.(*openAIProvider) 170 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 171 | got := config.Keys[keyIndex%uint64(len(config.Keys))] 172 | 173 | if got != want { 174 | t.Errorf("request %d: got %s, want %s", i, got, want) 175 | } 176 | } 177 | 178 | if *keyCounter != uint64(len(expected)) { 179 | t.Errorf("counter = %d, want %d", *keyCounter, len(expected)) 180 | } 181 | } 182 | 183 | func TestKeyRoundRobinGemini(t *testing.T) { 184 | config := &configs.ProviderInstanceConfig{ 185 | Enabled: true, 186 | BaseURL: "https://generativelanguage.googleapis.com", 187 | Keys: []string{"key-1", "key-2", "key-3"}, 188 | Models: []string{"gemini-pro"}, 189 | Timeout: 30, 190 | MaxRetries: 3, 191 | RateLimit: "100/s", 192 | } 193 | 194 | keyCounter := new(uint64) 195 | modelCounter := new(uint64) 196 | expected := []string{"key-1", "key-2", "key-3", "key-1", "key-2", "key-3"} 197 | 198 | for i, want := range expected { 199 | provider, err := NewGemini(config, keyCounter, modelCounter) 200 | if err != nil { 201 | t.Fatalf("NewGemini() failed: %v", err) 202 | } 203 | 204 | p := provider.(*geminiProvider) 205 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 206 | got := config.Keys[keyIndex%uint64(len(config.Keys))] 207 | 208 | if got != want { 209 | t.Errorf("request %d: got %s, want %s", i, got, want) 210 | } 211 | } 212 | 213 | if *keyCounter != uint64(len(expected)) { 214 | t.Errorf("counter = %d, want %d", *keyCounter, len(expected)) 215 | } 216 | } 217 | 218 | func TestDynamicKeyUpdate(t *testing.T) { 219 | config := &configs.ProviderInstanceConfig{ 220 | Enabled: true, 221 | BaseURL: "https://api.openai.com/v1", 222 | Keys: []string{"key-1", "key-2", "key-3"}, 223 | Models: []string{"gpt-3.5-turbo"}, 224 | Timeout: 30, 225 | MaxRetries: 3, 226 | RateLimit: "100/s", 227 | } 228 | 229 | keyCounter := new(uint64) 230 | modelCounter := new(uint64) 231 | 232 | for i := 0; i < 3; i++ { 233 | provider, _ := NewOpenAI(config, keyCounter, modelCounter) 234 | p := provider.(*openAIProvider) 235 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 236 | _ = config.Keys[keyIndex%uint64(len(config.Keys))] 237 | } 238 | 239 | config.Keys = []string{"new-key-1", "new-key-2"} 240 | 241 | expected := []string{"new-key-2", "new-key-1", "new-key-2"} 242 | for i, want := range expected { 243 | provider, err := NewOpenAI(config, keyCounter, modelCounter) 244 | if err != nil { 245 | t.Fatalf("NewOpenAI() failed: %v", err) 246 | } 247 | 248 | p := provider.(*openAIProvider) 249 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 250 | got := config.Keys[keyIndex%uint64(len(config.Keys))] 251 | 252 | if got != want { 253 | t.Errorf("after config update, request %d: got %s, want %s", i, got, want) 254 | } 255 | } 256 | } 257 | 258 | // TestMultiInstanceMultiKeyMultiModel tests multiple instances with multiple keys and models 259 | func TestMultiInstanceMultiKeyMultiModel(t *testing.T) { 260 | instance1 := &configs.ProviderInstanceConfig{ 261 | Enabled: true, 262 | Name: "instance-01", 263 | BaseURL: "https://api.openai.com/v1", 264 | Keys: []string{"i1-key-A", "i1-key-B"}, 265 | Models: []string{"gpt-3.5", "gpt-4"}, 266 | Timeout: 30, 267 | MaxRetries: 3, 268 | RateLimit: "100/s", 269 | } 270 | 271 | instance2 := &configs.ProviderInstanceConfig{ 272 | Enabled: true, 273 | Name: "instance-02", 274 | BaseURL: "https://api.openai.com/v1", 275 | Keys: []string{"i2-key-X", "i2-key-Y", "i2-key-Z"}, 276 | Models: []string{"gpt-4-turbo"}, 277 | Timeout: 30, 278 | MaxRetries: 3, 279 | RateLimit: "100/s", 280 | } 281 | 282 | keyCounter1 := new(uint64) 283 | modelCounter1 := new(uint64) 284 | keyCounter2 := new(uint64) 285 | modelCounter2 := new(uint64) 286 | 287 | type testCase struct { 288 | config *configs.ProviderInstanceConfig 289 | keyCounter *uint64 290 | modelCounter *uint64 291 | expectedKey string 292 | expectedModel string 293 | } 294 | 295 | tests := []testCase{ 296 | {instance1, keyCounter1, modelCounter1, "i1-key-A", "gpt-3.5"}, 297 | {instance2, keyCounter2, modelCounter2, "i2-key-X", "gpt-4-turbo"}, 298 | {instance1, keyCounter1, modelCounter1, "i1-key-B", "gpt-4"}, 299 | {instance2, keyCounter2, modelCounter2, "i2-key-Y", "gpt-4-turbo"}, 300 | {instance1, keyCounter1, modelCounter1, "i1-key-A", "gpt-3.5"}, 301 | {instance2, keyCounter2, modelCounter2, "i2-key-Z", "gpt-4-turbo"}, 302 | } 303 | 304 | for i, tc := range tests { 305 | provider, err := NewOpenAI(tc.config, tc.keyCounter, tc.modelCounter) 306 | if err != nil { 307 | t.Fatalf("request %d: NewOpenAI() failed: %v", i, err) 308 | } 309 | 310 | p := provider.(*openAIProvider) 311 | 312 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 313 | gotKey := tc.config.Keys[keyIndex%uint64(len(tc.config.Keys))] 314 | 315 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 316 | gotModel := tc.config.Models[modelIndex%uint64(len(tc.config.Models))] 317 | 318 | if gotKey != tc.expectedKey { 319 | t.Errorf("request %d (%s): key = %s, want %s", i, tc.config.Name, gotKey, tc.expectedKey) 320 | } 321 | 322 | if gotModel != tc.expectedModel { 323 | t.Errorf("request %d (%s): model = %s, want %s", i, tc.config.Name, gotModel, tc.expectedModel) 324 | } 325 | } 326 | 327 | if *keyCounter1 != 3 { 328 | t.Errorf("instance1 key counter = %d, want 3", *keyCounter1) 329 | } 330 | if *modelCounter1 != 3 { 331 | t.Errorf("instance1 model counter = %d, want 3", *modelCounter1) 332 | } 333 | if *keyCounter2 != 3 { 334 | t.Errorf("instance2 key counter = %d, want 3", *keyCounter2) 335 | } 336 | if *modelCounter2 != 3 { 337 | t.Errorf("instance2 model counter = %d, want 3", *modelCounter2) 338 | } 339 | } 340 | 341 | // TestConfigShrinkage verifies behavior when config items are reduced 342 | func TestConfigShrinkage(t *testing.T) { 343 | config := &configs.ProviderInstanceConfig{ 344 | Enabled: true, 345 | Name: "test", 346 | BaseURL: "https://api.openai.com/v1", 347 | Keys: []string{"k1", "k2", "k3", "k4", "k5"}, 348 | Models: []string{"m1", "m2", "m3"}, 349 | Timeout: 30, 350 | MaxRetries: 3, 351 | RateLimit: "100/s", 352 | } 353 | 354 | keyCounter := new(uint64) 355 | modelCounter := new(uint64) 356 | 357 | for i := 0; i < 10; i++ { 358 | provider, _ := NewOpenAI(config, keyCounter, modelCounter) 359 | p := provider.(*openAIProvider) 360 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 361 | _ = config.Keys[keyIndex%uint64(len(config.Keys))] 362 | } 363 | 364 | if *keyCounter != 10 { 365 | t.Errorf("after 10 requests: keyCounter = %d, want 10", *keyCounter) 366 | } 367 | 368 | config.Keys = []string{"new-k1", "new-k2"} 369 | config.Models = []string{"new-m1"} 370 | 371 | expectedKeys := []string{"new-k1", "new-k2", "new-k1", "new-k2", "new-k1"} 372 | expectedModels := []string{"new-m1", "new-m1", "new-m1", "new-m1", "new-m1"} 373 | 374 | for i := 0; i < 5; i++ { 375 | provider, err := NewOpenAI(config, keyCounter, modelCounter) 376 | if err != nil { 377 | t.Fatalf("NewOpenAI() failed: %v", err) 378 | } 379 | 380 | p := provider.(*openAIProvider) 381 | 382 | keyIndex := atomic.AddUint64(p.keyCounter, 1) - 1 383 | gotKey := config.Keys[keyIndex%uint64(len(config.Keys))] 384 | 385 | modelIndex := atomic.AddUint64(p.modelCounter, 1) - 1 386 | gotModel := config.Models[modelIndex%uint64(len(config.Models))] 387 | 388 | if gotKey != expectedKeys[i] { 389 | t.Errorf("request %d after shrink: key = %s, want %s (counter=%d)", i, gotKey, expectedKeys[i], *keyCounter) 390 | } 391 | 392 | if gotModel != expectedModels[i] { 393 | t.Errorf("request %d after shrink: model = %s, want %s (counter=%d)", i, gotModel, expectedModels[i], *modelCounter) 394 | } 395 | } 396 | 397 | if *keyCounter != 15 { 398 | t.Errorf("final keyCounter = %d, want 15", *keyCounter) 399 | } 400 | } 401 | --------------------------------------------------------------------------------