All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
1. 修正 retail_price 架构:
- 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
(上级只能改下级成本价,不能改零售价)
- 新增 PATCH /api/admin/packages/:id/retail-price 接口
(代理自己改自己的零售价,校验 retail_price >= cost_price)
2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
- 删除 config.yaml 中 wechat.official_account 配置节
- 删除 NewOfficialAccountApp() 旧工厂函数
- 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
- 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释
3. 归档四个已完成提案到 openspec/changes/archive/
4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)
5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
317 lines
15 KiB
Go
317 lines
15 KiB
Go
package config
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
)
|
||
|
||
// globalConfig 保存当前配置,支持原子访问
|
||
var globalConfig atomic.Pointer[Config]
|
||
|
||
// Config 应用配置
|
||
type Config struct {
|
||
Server ServerConfig `mapstructure:"server"`
|
||
Redis RedisConfig `mapstructure:"redis"`
|
||
Database DatabaseConfig `mapstructure:"database"`
|
||
Queue QueueConfig `mapstructure:"queue"`
|
||
Logging LoggingConfig `mapstructure:"logging"`
|
||
Middleware MiddlewareConfig `mapstructure:"middleware"`
|
||
SMS SMSConfig `mapstructure:"sms"`
|
||
JWT JWTConfig `mapstructure:"jwt"`
|
||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||
Storage StorageConfig `mapstructure:"storage"`
|
||
Gateway GatewayConfig `mapstructure:"gateway"`
|
||
}
|
||
|
||
// ServerConfig HTTP 服务器配置
|
||
type ServerConfig struct {
|
||
Address string `mapstructure:"address"` // 例如 ":3000"
|
||
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "10s"
|
||
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "10s"
|
||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 例如 "30s"
|
||
Prefork bool `mapstructure:"prefork"` // 多进程模式
|
||
}
|
||
|
||
// RedisConfig Redis 连接配置
|
||
type RedisConfig struct {
|
||
Address string `mapstructure:"address"` // 例如 "localhost"
|
||
Port int `mapstructure:"port"` // 例如 "6379"
|
||
Password string `mapstructure:"password"` // 无认证时留空
|
||
DB int `mapstructure:"db"` // 数据库编号(0-15)
|
||
PoolSize int `mapstructure:"pool_size"` // 最大连接数
|
||
MinIdleConns int `mapstructure:"min_idle_conns"` // 保活连接数
|
||
DialTimeout time.Duration `mapstructure:"dial_timeout"` // 例如 "5s"
|
||
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "3s"
|
||
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "3s"
|
||
}
|
||
|
||
// DatabaseConfig 数据库连接配置
|
||
type DatabaseConfig struct {
|
||
Host string `mapstructure:"host"` // 数据库主机地址
|
||
Port int `mapstructure:"port"` // 数据库端口
|
||
User string `mapstructure:"user"` // 数据库用户名
|
||
Password string `mapstructure:"password"` // 数据库密码(明文存储)
|
||
DBName string `mapstructure:"dbname"` // 数据库名称
|
||
SSLMode string `mapstructure:"sslmode"` // SSL 模式:disable, require, verify-ca, verify-full
|
||
MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数(默认:25)
|
||
MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数(默认:10)
|
||
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生命周期(默认:5m)
|
||
}
|
||
|
||
// QueueConfig 任务队列配置
|
||
type QueueConfig struct {
|
||
Concurrency int `mapstructure:"concurrency"` // Worker 并发数(默认:10)
|
||
Queues map[string]int `mapstructure:"queues"` // 队列优先级配置(队列名 -> 权重)
|
||
RetryMax int `mapstructure:"retry_max"` // 最大重试次数(默认:5)
|
||
Timeout time.Duration `mapstructure:"timeout"` // 任务超时时间(默认:10m)
|
||
}
|
||
|
||
// LoggingConfig 日志配置
|
||
type LoggingConfig struct {
|
||
Level string `mapstructure:"level"` // debug, info, warn, error
|
||
Development bool `mapstructure:"development"` // 启用开发模式(美化输出)
|
||
AppLog LogRotationConfig `mapstructure:"app_log"` // 应用日志配置
|
||
AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP 访问日志配置
|
||
}
|
||
|
||
// LogRotationConfig Lumberjack 日志轮转配置
|
||
type LogRotationConfig struct {
|
||
Filename string `mapstructure:"filename"` // 日志文件路径
|
||
MaxSize int `mapstructure:"max_size"` // 轮转前的最大大小(MB)
|
||
MaxBackups int `mapstructure:"max_backups"` // 保留的旧文件最大数量
|
||
MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数
|
||
Compress bool `mapstructure:"compress"` // 压缩轮转的文件
|
||
}
|
||
|
||
// MiddlewareConfig 中间件配置
|
||
type MiddlewareConfig struct {
|
||
EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器(默认:false)
|
||
RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置
|
||
}
|
||
|
||
// RateLimiterConfig 限流器配置
|
||
type RateLimiterConfig struct {
|
||
Max int `mapstructure:"max"` // 时间窗口内的最大请求数
|
||
Expiration time.Duration `mapstructure:"expiration"` // 时间窗口(例如 "1m")
|
||
Storage string `mapstructure:"storage"` // "memory" 或 "redis"
|
||
}
|
||
|
||
// SMSConfig 短信服务配置
|
||
type SMSConfig struct {
|
||
GatewayURL string `mapstructure:"gateway_url"` // 短信网关地址
|
||
Username string `mapstructure:"username"` // 账号用户名
|
||
Password string `mapstructure:"password"` // 账号密码
|
||
Signature string `mapstructure:"signature"` // 短信签名(例如:【签名】)
|
||
Timeout time.Duration `mapstructure:"timeout"` // 请求超时时间
|
||
}
|
||
|
||
// JWTConfig JWT 认证配置
|
||
type JWTConfig struct {
|
||
SecretKey string `mapstructure:"secret_key"` // JWT 签名密钥
|
||
TokenDuration time.Duration `mapstructure:"token_duration"` // Token 有效期(C 端 JWT)
|
||
AccessTokenTTL time.Duration `mapstructure:"access_token_ttl"` // 访问令牌有效期(B 端 Redis Token)
|
||
RefreshTokenTTL time.Duration `mapstructure:"refresh_token_ttl"` // 刷新令牌有效期(B 端 Redis Token)
|
||
}
|
||
|
||
// DefaultAdminConfig 默认超级管理员配置
|
||
type DefaultAdminConfig struct {
|
||
Username string `mapstructure:"username"`
|
||
Password string `mapstructure:"password"`
|
||
Phone string `mapstructure:"phone"`
|
||
}
|
||
|
||
// StorageConfig 对象存储配置
|
||
type StorageConfig struct {
|
||
Provider string `mapstructure:"provider"` // 存储提供商:s3
|
||
S3 S3Config `mapstructure:"s3"` // S3 兼容存储配置
|
||
Presign PresignConfig `mapstructure:"presign"` // 预签名 URL 配置
|
||
TempDir string `mapstructure:"temp_dir"` // 临时文件目录
|
||
}
|
||
|
||
// GatewayConfig Gateway 服务配置
|
||
type GatewayConfig struct {
|
||
BaseURL string `mapstructure:"base_url"` // Gateway API 基础 URL
|
||
AppID string `mapstructure:"app_id"` // 应用 ID
|
||
AppSecret string `mapstructure:"app_secret"` // 应用密钥
|
||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||
}
|
||
|
||
// S3Config S3 兼容存储配置
|
||
type S3Config struct {
|
||
Endpoint string `mapstructure:"endpoint"` // 服务端点(如:http://obs-helf.cucloud.cn)
|
||
Region string `mapstructure:"region"` // 区域(如:cn-langfang-2)
|
||
Bucket string `mapstructure:"bucket"` // 存储桶名称
|
||
AccessKeyID string `mapstructure:"access_key_id"` // 访问密钥 ID
|
||
SecretAccessKey string `mapstructure:"secret_access_key"` // 访问密钥
|
||
UseSSL bool `mapstructure:"use_ssl"` // 是否使用 SSL
|
||
PathStyle bool `mapstructure:"path_style"` // 是否使用路径风格(兼容性)
|
||
}
|
||
|
||
// PresignConfig 预签名 URL 配置
|
||
type PresignConfig struct {
|
||
UploadExpires time.Duration `mapstructure:"upload_expires"` // 上传 URL 有效期(默认:15m)
|
||
DownloadExpires time.Duration `mapstructure:"download_expires"` // 下载 URL 有效期(默认:24h)
|
||
}
|
||
|
||
type requiredField struct {
|
||
value string
|
||
name string
|
||
envName string
|
||
}
|
||
|
||
func (c *Config) ValidateRequired() error {
|
||
fields := []requiredField{
|
||
{c.Database.Host, "database.host", "JUNHONG_DATABASE_HOST"},
|
||
{c.Database.User, "database.user", "JUNHONG_DATABASE_USER"},
|
||
{c.Database.Password, "database.password", "JUNHONG_DATABASE_PASSWORD"},
|
||
{c.Database.DBName, "database.dbname", "JUNHONG_DATABASE_DBNAME"},
|
||
{c.Redis.Address, "redis.address", "JUNHONG_REDIS_ADDRESS"},
|
||
{c.JWT.SecretKey, "jwt.secret_key", "JUNHONG_JWT_SECRET_KEY"},
|
||
}
|
||
|
||
var missing []string
|
||
for _, f := range fields {
|
||
if f.value == "" {
|
||
missing = append(missing, fmt.Sprintf(" - %s (环境变量: %s)", f.name, f.envName))
|
||
}
|
||
}
|
||
|
||
if len(missing) > 0 {
|
||
return fmt.Errorf("缺少必填配置项:\n%s", strings.Join(missing, "\n"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (c *Config) Validate() error {
|
||
// 服务器验证
|
||
if c.Server.Address == "" {
|
||
return fmt.Errorf("invalid configuration: server.address: must be non-empty (current value: empty)")
|
||
}
|
||
if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second {
|
||
return fmt.Errorf("invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout)
|
||
}
|
||
if c.Server.WriteTimeout < 5*time.Second || c.Server.WriteTimeout > 300*time.Second {
|
||
return fmt.Errorf("invalid configuration: server.write_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.WriteTimeout)
|
||
}
|
||
if c.Server.ShutdownTimeout < 10*time.Second || c.Server.ShutdownTimeout > 120*time.Second {
|
||
return fmt.Errorf("invalid configuration: server.shutdown_timeout: duration out of range (current value: %s, expected: 10s-120s)", c.Server.ShutdownTimeout)
|
||
}
|
||
|
||
// Redis 验证
|
||
if c.Redis.Address == "" {
|
||
return fmt.Errorf("invalid configuration: redis.address: must be non-empty (current value: empty)")
|
||
}
|
||
// Port 验证(独立字段)
|
||
if c.Redis.Port <= 0 || c.Redis.Port > 65535 {
|
||
return fmt.Errorf("invalid configuration: redis.port: port number out of range (current value: %d, expected: 1-65535)", c.Redis.Port)
|
||
}
|
||
if c.Redis.DB < 0 || c.Redis.DB > 15 {
|
||
return fmt.Errorf("invalid configuration: redis.db: database number out of range (current value: %d, expected: 0-15)", c.Redis.DB)
|
||
}
|
||
if c.Redis.PoolSize <= 0 || c.Redis.PoolSize > 1000 {
|
||
return fmt.Errorf("invalid configuration: redis.pool_size: pool size out of range (current value: %d, expected: 1-1000)", c.Redis.PoolSize)
|
||
}
|
||
if c.Redis.MinIdleConns < 0 || c.Redis.MinIdleConns > c.Redis.PoolSize {
|
||
return fmt.Errorf("invalid configuration: redis.min_idle_conns: must be 0 to pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize)
|
||
}
|
||
|
||
// 日志验证
|
||
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
|
||
if !validLevels[c.Logging.Level] {
|
||
return fmt.Errorf("invalid configuration: logging.level: invalid log level (current value: %s, expected: debug, info, warn, error)", c.Logging.Level)
|
||
}
|
||
if c.Logging.AppLog.Filename == "" {
|
||
return fmt.Errorf("invalid configuration: logging.app_log.filename: must be non-empty valid file path")
|
||
}
|
||
if c.Logging.AccessLog.Filename == "" {
|
||
return fmt.Errorf("invalid configuration: logging.access_log.filename: must be non-empty valid file path")
|
||
}
|
||
if c.Logging.AppLog.MaxSize < 1 || c.Logging.AppLog.MaxSize > 1000 {
|
||
return fmt.Errorf("invalid configuration: logging.app_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AppLog.MaxSize)
|
||
}
|
||
if c.Logging.AccessLog.MaxSize < 1 || c.Logging.AccessLog.MaxSize > 1000 {
|
||
return fmt.Errorf("invalid configuration: logging.access_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AccessLog.MaxSize)
|
||
}
|
||
|
||
// 中间件验证
|
||
if c.Middleware.RateLimiter.Max <= 0 {
|
||
c.Middleware.RateLimiter.Max = 100 // 默认值
|
||
}
|
||
if c.Middleware.RateLimiter.Max > 10000 {
|
||
return fmt.Errorf("invalid configuration: middleware.rate_limiter.max: request limit out of range (current value: %d, expected: 1-10000)", c.Middleware.RateLimiter.Max)
|
||
}
|
||
validStorage := map[string]bool{"memory": true, "redis": true}
|
||
if c.Middleware.RateLimiter.Storage != "" && !validStorage[c.Middleware.RateLimiter.Storage] {
|
||
return fmt.Errorf("invalid configuration: middleware.rate_limiter.storage: invalid storage type (current value: %s, expected: memory or redis)", c.Middleware.RateLimiter.Storage)
|
||
}
|
||
|
||
// 短信服务验证(可选,配置 GatewayURL 时才验证其他字段)
|
||
if c.SMS.GatewayURL != "" {
|
||
if c.SMS.Username == "" {
|
||
return fmt.Errorf("invalid configuration: sms.username: must be non-empty when gateway_url is configured")
|
||
}
|
||
if c.SMS.Password == "" {
|
||
return fmt.Errorf("invalid configuration: sms.password: must be non-empty when gateway_url is configured")
|
||
}
|
||
if c.SMS.Signature == "" {
|
||
return fmt.Errorf("invalid configuration: sms.signature: must be non-empty when gateway_url is configured")
|
||
}
|
||
if c.SMS.Timeout > 0 && (c.SMS.Timeout < 5*time.Second || c.SMS.Timeout > 60*time.Second) {
|
||
return fmt.Errorf("invalid configuration: sms.timeout: duration out of range (current value: %s, expected: 5s-60s)", c.SMS.Timeout)
|
||
}
|
||
}
|
||
|
||
// JWT 验证(SecretKey 必填验证在 ValidateRequired 中处理)
|
||
if len(c.JWT.SecretKey) > 0 && len(c.JWT.SecretKey) < 32 {
|
||
return fmt.Errorf("invalid configuration: jwt.secret_key: secret key too short (current length: %d, expected: >= 32)", len(c.JWT.SecretKey))
|
||
}
|
||
if c.JWT.TokenDuration < 1*time.Hour || c.JWT.TokenDuration > 720*time.Hour {
|
||
return fmt.Errorf("invalid configuration: jwt.token_duration: duration out of range (current value: %s, expected: 1h-720h)", c.JWT.TokenDuration)
|
||
}
|
||
if c.JWT.AccessTokenTTL < 1*time.Hour || c.JWT.AccessTokenTTL > 168*time.Hour {
|
||
return fmt.Errorf("invalid configuration: jwt.access_token_ttl: duration out of range (current value: %s, expected: 1h-168h)", c.JWT.AccessTokenTTL)
|
||
}
|
||
if c.JWT.RefreshTokenTTL < 24*time.Hour || c.JWT.RefreshTokenTTL > 720*time.Hour {
|
||
return fmt.Errorf("invalid configuration: jwt.refresh_token_ttl: duration out of range (current value: %s, expected: 24h-720h)", c.JWT.RefreshTokenTTL)
|
||
}
|
||
|
||
// Gateway 验证(可选,配置 BaseURL 时才验证其他字段)
|
||
if c.Gateway.BaseURL != "" {
|
||
if !strings.HasPrefix(c.Gateway.BaseURL, "http://") && !strings.HasPrefix(c.Gateway.BaseURL, "https://") {
|
||
return fmt.Errorf("invalid configuration: gateway.base_url: must start with http:// or https:// (current value: %s)", c.Gateway.BaseURL)
|
||
}
|
||
if c.Gateway.AppID == "" {
|
||
return fmt.Errorf("invalid configuration: gateway.app_id: must be non-empty when base_url is configured")
|
||
}
|
||
if c.Gateway.AppSecret == "" {
|
||
return fmt.Errorf("invalid configuration: gateway.app_secret: must be non-empty when base_url is configured")
|
||
}
|
||
if c.Gateway.Timeout < 5 || c.Gateway.Timeout > 300 {
|
||
return fmt.Errorf("invalid configuration: gateway.timeout: timeout out of range (current value: %d, expected: 5-300 seconds)", c.Gateway.Timeout)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Get 返回当前配置
|
||
func Get() *Config {
|
||
return globalConfig.Load()
|
||
}
|
||
|
||
// Set 原子地更新全局配置
|
||
func Set(cfg *Config) error {
|
||
if cfg == nil {
|
||
return errors.New("config cannot be nil")
|
||
}
|
||
if err := cfg.Validate(); err != nil {
|
||
return err
|
||
}
|
||
globalConfig.Store(cfg)
|
||
return nil
|
||
}
|