feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets

Made-with: Cursor
This commit is contained in:
2026-04-03 16:26:38 +08:00
parent 1f16966174
commit b4c5103fb3
56 changed files with 4088 additions and 3742 deletions

View File

@@ -1,45 +1,246 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
AdminToken string `json:"adminToken"`
AuthAPIURL string `json:"authApiUrl"`
// 数据库 DSN为空时回退到测试数据库。
// 格式user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
DatabaseDSN string `json:"databaseDsn"`
}
// 各环境默认 DSN。
const (
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
)
func Load(path string) (*Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err == nil {
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr)
}
}
// 文件不存在时使用默认值,环境变量在下方仍优先生效。
if cfg.AdminToken == "" {
cfg.AdminToken = "changeme"
}
// DATABASE_DSN 环境变量优先于配置文件。
if dsn := os.Getenv("DATABASE_DSN"); dsn != "" {
cfg.DatabaseDSN = dsn
}
if cfg.DatabaseDSN == "" {
cfg.DatabaseDSN = TestDSN
}
return &cfg, nil
}
package config
import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Config is populated entirely from environment variables (optionally set via .env — see Load).
type Config struct {
AppEnv string
AdminToken string
AuthAPIURL string
DatabaseDSN string
// 进程与对外访问(管理后台「系统状态」展示)
HTTPListenAddr string
PublicAPIBaseURL string
// Redis可选用于缓存时配置未启用则不连接
RedisEnabled bool
RedisAddr string
RedisPassword string
RedisDB int
RedisEnv string
RabbitMQEnabled bool
RabbitMQURL string
RabbitMQEnv string
}
// App environment: affects defaults when DATABASE_DSN / RABBITMQ_ENV are omitted.
// APP_ENV=production → prod DB default, RABBITMQ_ENV default prod
// Otherwise → development defaults (test DB, rabbit dev).
const (
EnvDevelopment = "development"
EnvProduction = "production"
)
// Built-in DSN fallbacks when DATABASE_DSN is empty.
const (
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
DefaultRabbitMQHost = "10.1.1.233"
DefaultRabbitMQPort = "5672"
DefaultRabbitVHost = "mengyastore-dev"
DefaultRabbitMQUser = "admin"
ProdRabbitMQHost = "192.168.1.100"
DefaultRabbitVHostProd = "mengyastore-prod"
// Redis与 MySQL 同网段;未设置 REDIS_ADDR / REDIS_PASSWORD 时按 APP_ENV 填入
TestRedisAddr = "10.1.1.100:6379"
ProdRedisAddr = "192.168.1.100:6379"
// 内网默认口令(可用环境变量 REDIS_PASSWORD 覆盖)
DefaultRedisPassword = "tyh@19900420"
)
// Load reads optional .env file(s) then builds Config from the process environment.
//
// Dotenv resolution order:
// 1. File named by ENV_FILE (if set)
// 2. .env in current working directory
//
// Variables (all optional unless noted):
//
// APP_ENV — development | production (default: development)
// ADMIN_TOKEN — admin API token (default: changeme)
// AUTH_API_URL — SproutGate base URL
// DATABASE_DSN — MySQL DSN; if empty, uses TestDSN or ProdDSN from APP_ENV
// RABBITMQ_ENABLED — true/false
// RABBITMQ_URL — full amqp URL
// RABBITMQ_ENV — dev | prod (default from APP_ENV)
// RABBITMQ_PASSWORD — if RABBITMQ_URL empty but RABBITMQ_ENABLED, builds URL with host/vhost defaults
//
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
//
// REDIS_ENABLED — 默认 true显式 false/0/off 则关闭
// REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr
// REDIS_PASSWORD — 默认 DefaultRedisPassword建议生产用环境变量覆盖
// REDIS_DB — 逻辑库编号,默认 1
// REDIS_ENV — dev|prod展示用 Key 前缀环境)
func Load() (*Config, error) {
loadDotenv()
appEnv := normalizeAppEnv(os.Getenv("APP_ENV"))
cfg := &Config{
AppEnv: appEnv,
AdminToken: strings.TrimSpace(os.Getenv("ADMIN_TOKEN")),
AuthAPIURL: strings.TrimSpace(os.Getenv("AUTH_API_URL")),
DatabaseDSN: strings.TrimSpace(os.Getenv("DATABASE_DSN")),
RabbitMQURL: strings.TrimSpace(os.Getenv("RABBITMQ_URL")),
}
if cfg.AdminToken == "" {
cfg.AdminToken = "changeme"
}
if cfg.DatabaseDSN == "" {
if appEnv == EnvProduction {
cfg.DatabaseDSN = ProdDSN
} else {
cfg.DatabaseDSN = TestDSN
}
}
if v := os.Getenv("RABBITMQ_ENABLED"); v != "" {
cfg.RabbitMQEnabled = v == "true" || v == "1"
}
cfg.RabbitMQEnv = strings.TrimSpace(os.Getenv("RABBITMQ_ENV"))
if cfg.RabbitMQEnv == "" {
if appEnv == EnvProduction {
cfg.RabbitMQEnv = "prod"
} else {
cfg.RabbitMQEnv = "dev"
}
}
if cfg.RabbitMQURL == "" && cfg.RabbitMQEnabled {
cfg.RabbitMQURL = composeRabbitMQURL(cfg)
}
if v := os.Getenv("HTTP_LISTEN_ADDR"); v != "" {
cfg.HTTPListenAddr = strings.TrimSpace(v)
}
if cfg.HTTPListenAddr == "" {
cfg.HTTPListenAddr = ":8080"
}
cfg.PublicAPIBaseURL = strings.TrimSpace(os.Getenv("PUBLIC_API_BASE_URL"))
cfg.RedisEnabled = redisEnabledFromEnv(os.Getenv("REDIS_ENABLED"))
cfg.RedisAddr = strings.TrimSpace(os.Getenv("REDIS_ADDR"))
cfg.RedisPassword = os.Getenv("REDIS_PASSWORD")
if v := os.Getenv("REDIS_DB"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.RedisDB = n
}
}
cfg.RedisEnv = strings.TrimSpace(os.Getenv("REDIS_ENV"))
if cfg.RedisEnv == "" {
if appEnv == EnvProduction {
cfg.RedisEnv = "prod"
} else {
cfg.RedisEnv = "dev"
}
}
if cfg.RedisEnabled {
if cfg.RedisAddr == "" {
if appEnv == EnvProduction {
cfg.RedisAddr = ProdRedisAddr
} else {
cfg.RedisAddr = TestRedisAddr
}
}
if cfg.RedisPassword == "" {
cfg.RedisPassword = DefaultRedisPassword
}
if cfg.RedisDB == 0 {
cfg.RedisDB = 1
}
}
return cfg, nil
}
// redisEnabledFromEnv defaults to true unless explicitly turned off.
func redisEnabledFromEnv(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "false" || s == "0" || s == "no" || s == "off" {
return false
}
if s == "true" || s == "1" || s == "yes" || s == "on" {
return true
}
return true
}
func loadDotenv() {
if f := strings.TrimSpace(os.Getenv("ENV_FILE")); f != "" {
_ = godotenv.Load(f)
return
}
// Standard local file; ignore missing.
_ = godotenv.Load(filepath.Clean(".env"))
}
func normalizeAppEnv(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "production", "prod":
return EnvProduction
default:
return EnvDevelopment
}
}
func composeRabbitMQURL(cfg *Config) string {
pass := os.Getenv("RABBITMQ_PASSWORD")
if pass == "" {
return ""
}
host := os.Getenv("RABBITMQ_HOST")
port := os.Getenv("RABBITMQ_PORT")
user := os.Getenv("RABBITMQ_USER")
vhost := os.Getenv("RABBITMQ_VHOST")
if host == "" {
if cfg.RabbitMQEnv == "prod" {
host = ProdRabbitMQHost
} else {
host = DefaultRabbitMQHost
}
}
if port == "" {
port = DefaultRabbitMQPort
}
if user == "" {
user = DefaultRabbitMQUser
}
if vhost == "" {
if cfg.RabbitMQEnv == "prod" {
vhost = DefaultRabbitVHostProd
} else {
vhost = DefaultRabbitVHost
}
}
var path string
if vhost == "/" {
path = "/%2F"
} else {
path = "/" + url.PathEscape(vhost)
}
u := url.URL{
Scheme: "amqp",
User: url.UserPassword(user, pass),
Host: host + ":" + port,
Path: path,
}
return u.String()
}