2026-03-07 22:58:43 +08:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-09 04:38:07 +08:00
|
|
|
|
"log"
|
2026-03-07 22:58:43 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Config 应用配置(从环境变量读取,启动时加载 .env)
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
|
Port string
|
|
|
|
|
|
Mode string
|
|
|
|
|
|
DBDSN string
|
|
|
|
|
|
TrustedProxies []string
|
|
|
|
|
|
CORSOrigins []string
|
|
|
|
|
|
Version string // APP_VERSION,打包/部署前写在 .env,/health 返回
|
|
|
|
|
|
|
|
|
|
|
|
// 统一 API 域名字段:支付回调、转账回调、apiDomain 等均由 BaseURL 拼接
|
|
|
|
|
|
BaseURL string // API_BASE_URL,如 https://soulapi.quwanzhi.com(无尾部斜杠)
|
|
|
|
|
|
|
2026-03-14 14:37:17 +08:00
|
|
|
|
// 存客宝配置
|
|
|
|
|
|
CkbLeadAPIKey string // CKB_LEAD_API_KEY,请求到存客宝添加好友使用的 apiKey(内部 /v1/api/scenarios)
|
|
|
|
|
|
CkbOpenAPIKey string // CKB_OPEN_API_KEY,开放 API 鉴权使用的 apiKey(/v1/open/auth/token)
|
|
|
|
|
|
CkbOpenAccount string // CKB_OPEN_ACCOUNT,对应存客宝登录账号(开放 API 使用)
|
2026-03-11 14:49:45 +08:00
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
// 微信小程序配置
|
|
|
|
|
|
WechatAppID string
|
|
|
|
|
|
WechatAppSecret string
|
|
|
|
|
|
WechatMchID string
|
|
|
|
|
|
WechatMchKey string
|
|
|
|
|
|
WechatNotifyURL string // 由 BaseURL + /api/miniprogram/pay/notify 派生
|
|
|
|
|
|
WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取
|
|
|
|
|
|
|
|
|
|
|
|
// 微信转账配置(API v3)
|
|
|
|
|
|
WechatAPIv3Key string
|
|
|
|
|
|
WechatCertPath string
|
|
|
|
|
|
WechatKeyPath string
|
|
|
|
|
|
WechatSerialNo string
|
|
|
|
|
|
WechatTransferURL string // 由 BaseURL + /api/payment/wechat/transfer/notify 派生
|
|
|
|
|
|
|
|
|
|
|
|
// 管理端登录(与 next-project 一致:ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_SESSION_SECRET)
|
|
|
|
|
|
AdminUsername string
|
|
|
|
|
|
AdminPassword string
|
|
|
|
|
|
AdminSessionSecret string
|
|
|
|
|
|
|
|
|
|
|
|
// 订单对账定时任务间隔(分钟),0 表示不启动内置定时任务
|
|
|
|
|
|
SyncOrdersIntervalMinutes int
|
2026-03-14 23:27:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 上传目录(绝对路径,air 运行时避免相对路径解析错误)
|
|
|
|
|
|
UploadDir string
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BaseURLJoin 将路径拼接到 BaseURL,path 应以 / 开头
|
|
|
|
|
|
func (c *Config) BaseURLJoin(path string) string {
|
|
|
|
|
|
base := strings.TrimSuffix(c.BaseURL, "/")
|
|
|
|
|
|
if base == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
p := strings.TrimSpace(path)
|
|
|
|
|
|
if p != "" && p[0] != '/' {
|
|
|
|
|
|
p = "/" + p
|
|
|
|
|
|
}
|
|
|
|
|
|
return base + p
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
|
|
|
|
|
|
var defaultCORSOrigins = []string{
|
|
|
|
|
|
"http://localhost:5174",
|
|
|
|
|
|
"http://127.0.0.1:5174",
|
|
|
|
|
|
"https://soul.quwanzhi.com",
|
|
|
|
|
|
"http://soul.quwanzhi.com",
|
|
|
|
|
|
"https://souladmin.quwanzhi.com",
|
|
|
|
|
|
"http://souladmin.quwanzhi.com",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// current 由 main 在 Load 后设置,供 handler/middleware 读取
|
|
|
|
|
|
var current *Config
|
|
|
|
|
|
|
|
|
|
|
|
// SetCurrent 设置全局配置(main 启动时调用一次)
|
|
|
|
|
|
func SetCurrent(cfg *Config) { current = cfg }
|
|
|
|
|
|
|
|
|
|
|
|
// Get 返回当前配置,未设置时返回 nil
|
|
|
|
|
|
func Get() *Config { return current }
|
|
|
|
|
|
|
|
|
|
|
|
// parseCORSOrigins 从环境变量 CORS_ORIGINS 读取(逗号分隔),未设置则用默认值
|
|
|
|
|
|
func parseCORSOrigins() []string {
|
|
|
|
|
|
s := os.Getenv("CORS_ORIGINS")
|
|
|
|
|
|
if s == "" {
|
|
|
|
|
|
return defaultCORSOrigins
|
|
|
|
|
|
}
|
|
|
|
|
|
parts := strings.Split(s, ",")
|
|
|
|
|
|
origins := make([]string, 0, len(parts))
|
|
|
|
|
|
for _, p := range parts {
|
|
|
|
|
|
if o := strings.TrimSpace(p); o != "" {
|
|
|
|
|
|
origins = append(origins, o)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(origins) == 0 {
|
|
|
|
|
|
return defaultCORSOrigins
|
|
|
|
|
|
}
|
|
|
|
|
|
return origins
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load 加载配置,端口等从 .env 读取。
|
|
|
|
|
|
// 环境区分:APP_ENV=development 加载 .env.development,APP_ENV=production 加载 .env.production;
|
|
|
|
|
|
// air 运行时通过 env_files 或 full_bin 设置 APP_ENV,开发用 .env.development,部署用 .env.production。
|
|
|
|
|
|
func Load() (*Config, error) {
|
|
|
|
|
|
workDir, _ := os.Getwd()
|
|
|
|
|
|
execDir := ""
|
|
|
|
|
|
if execPath, err := os.Executable(); err == nil {
|
|
|
|
|
|
execDir = filepath.Dir(execPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
loadEnv := func(name string) {
|
|
|
|
|
|
for _, dir := range []string{execDir, workDir, "."} {
|
|
|
|
|
|
if dir == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
p := filepath.Join(dir, name)
|
|
|
|
|
|
if _, err := os.Stat(p); err == nil {
|
|
|
|
|
|
_ = godotenv.Load(p)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
overloadEnv := func(name string) {
|
|
|
|
|
|
for _, dir := range []string{execDir, workDir, "."} {
|
|
|
|
|
|
if dir == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
p := filepath.Join(dir, name)
|
|
|
|
|
|
if _, err := os.Stat(p); err == nil {
|
|
|
|
|
|
_ = godotenv.Overload(p)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 加载 .env 作为基础
|
|
|
|
|
|
loadEnv(".env")
|
|
|
|
|
|
// 2. 按 APP_ENV 覆盖(优先读已设置的 APP_ENV,如 air 的 env_files 已注入)
|
|
|
|
|
|
appEnv := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
|
|
|
|
|
|
if appEnv == "development" {
|
|
|
|
|
|
overloadEnv(".env.development")
|
|
|
|
|
|
} else if appEnv == "production" {
|
|
|
|
|
|
overloadEnv(".env.production")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
port := os.Getenv("PORT")
|
|
|
|
|
|
if port == "" {
|
|
|
|
|
|
port = "8080"
|
|
|
|
|
|
}
|
|
|
|
|
|
mode := os.Getenv("GIN_MODE")
|
|
|
|
|
|
if mode == "" {
|
|
|
|
|
|
mode = "debug"
|
|
|
|
|
|
}
|
|
|
|
|
|
dsn := os.Getenv("DB_DSN")
|
|
|
|
|
|
if dsn == "" {
|
2026-03-09 04:37:46 +08:00
|
|
|
|
log.Fatal("DB_DSN 未配置,请在 .env 中配置腾讯云 MySQL 连接串,例如:DB_DSN=user:pass@tcp(xxx.gz.cdb.myqcloud.com:3306)/soul_miniprogram?charset=utf8mb4&parseTime=True")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
version := os.Getenv("APP_VERSION")
|
|
|
|
|
|
if version == "" {
|
|
|
|
|
|
version = "0.0.0"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 微信配置
|
|
|
|
|
|
wechatAppID := os.Getenv("WECHAT_APPID")
|
|
|
|
|
|
if wechatAppID == "" {
|
|
|
|
|
|
wechatAppID = "wxb8bbb2b10dec74aa" // 默认小程序AppID
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatAppSecret := os.Getenv("WECHAT_APPSECRET")
|
|
|
|
|
|
if wechatAppSecret == "" {
|
|
|
|
|
|
wechatAppSecret = "3c1fb1f63e6e052222bbcead9d07fe0c" // 默认小程序AppSecret
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatMchID := os.Getenv("WECHAT_MCH_ID")
|
|
|
|
|
|
if wechatMchID == "" {
|
|
|
|
|
|
wechatMchID = "1318592501" // 默认商户号
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatMchKey := os.Getenv("WECHAT_MCH_KEY")
|
|
|
|
|
|
if wechatMchKey == "" {
|
|
|
|
|
|
wechatMchKey = "wx3e31b068be59ddc131b068be59ddc2" // 默认API密钥(v2)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 统一域名:API_BASE_URL 派生支付/转账回调,可选 WECHAT_NOTIFY_URL 覆盖
|
|
|
|
|
|
baseURL := strings.TrimSpace(strings.TrimSuffix(os.Getenv("API_BASE_URL"), "/"))
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = "https://soulapi.quwanzhi.com"
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
|
|
|
|
|
|
if wechatNotifyURL == "" {
|
|
|
|
|
|
wechatNotifyURL = baseURL + "/api/miniprogram/pay/notify"
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatMiniProgramState := strings.TrimSpace(os.Getenv("WECHAT_MINI_PROGRAM_STATE"))
|
|
|
|
|
|
if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" {
|
|
|
|
|
|
wechatMiniProgramState = "formal" // 默认正式版,避免生成开发版码导致「开发版已过期」
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转账配置
|
|
|
|
|
|
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
|
|
|
|
|
|
if wechatAPIv3Key == "" {
|
|
|
|
|
|
wechatAPIv3Key = "wx3e31b068be59ddc131b068be59ddc2" // 默认 API v3 密钥
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatCertPath := os.Getenv("WECHAT_CERT_PATH")
|
|
|
|
|
|
if wechatCertPath == "" {
|
|
|
|
|
|
wechatCertPath = "certs/apiclient_cert.pem" // 默认证书路径
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatKeyPath := os.Getenv("WECHAT_KEY_PATH")
|
|
|
|
|
|
if wechatKeyPath == "" {
|
|
|
|
|
|
wechatKeyPath = "certs/apiclient_key.pem" // 默认私钥路径
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatSerialNo := os.Getenv("WECHAT_SERIAL_NO")
|
|
|
|
|
|
if wechatSerialNo == "" {
|
|
|
|
|
|
wechatSerialNo = "4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5" // 默认证书序列号
|
|
|
|
|
|
}
|
|
|
|
|
|
wechatTransferURL := os.Getenv("WECHAT_TRANSFER_URL")
|
|
|
|
|
|
if wechatTransferURL == "" {
|
|
|
|
|
|
wechatTransferURL = baseURL + "/api/payment/wechat/transfer/notify"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
adminUsername := os.Getenv("ADMIN_USERNAME")
|
|
|
|
|
|
if adminUsername == "" {
|
|
|
|
|
|
adminUsername = "admin"
|
|
|
|
|
|
}
|
|
|
|
|
|
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
|
|
|
|
|
if adminPassword == "" {
|
|
|
|
|
|
adminPassword = "admin123"
|
|
|
|
|
|
}
|
|
|
|
|
|
adminSessionSecret := os.Getenv("ADMIN_SESSION_SECRET")
|
|
|
|
|
|
if adminSessionSecret == "" {
|
|
|
|
|
|
adminSessionSecret = "soul-admin-secret-change-in-prod"
|
|
|
|
|
|
}
|
2026-03-11 14:49:45 +08:00
|
|
|
|
ckbLeadAPIKey := os.Getenv("CKB_LEAD_API_KEY")
|
2026-03-14 14:37:17 +08:00
|
|
|
|
ckbOpenAPIKey := os.Getenv("CKB_OPEN_API_KEY")
|
|
|
|
|
|
ckbOpenAccount := os.Getenv("CKB_OPEN_ACCOUNT")
|
2026-03-07 22:58:43 +08:00
|
|
|
|
syncOrdersInterval := 5
|
|
|
|
|
|
if s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" {
|
|
|
|
|
|
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
|
|
|
|
|
|
syncOrdersInterval = n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 23:27:22 +08:00
|
|
|
|
// 上传目录:优先 UPLOAD_DIR 环境变量,否则用项目根下的 uploads
|
|
|
|
|
|
uploadDir := strings.TrimSpace(os.Getenv("UPLOAD_DIR"))
|
|
|
|
|
|
if uploadDir == "" {
|
|
|
|
|
|
uploadDir = resolveUploadDir(workDir, execDir)
|
|
|
|
|
|
} else if !filepath.IsAbs(uploadDir) {
|
|
|
|
|
|
uploadDir, _ = filepath.Abs(filepath.Join(workDir, uploadDir))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
return &Config{
|
|
|
|
|
|
Port: port,
|
|
|
|
|
|
Mode: mode,
|
|
|
|
|
|
DBDSN: dsn,
|
|
|
|
|
|
TrustedProxies: []string{"127.0.0.1", "::1"},
|
|
|
|
|
|
CORSOrigins: parseCORSOrigins(),
|
|
|
|
|
|
Version: version,
|
|
|
|
|
|
BaseURL: baseURL,
|
2026-03-11 14:49:45 +08:00
|
|
|
|
CkbLeadAPIKey: ckbLeadAPIKey,
|
2026-03-14 14:37:17 +08:00
|
|
|
|
CkbOpenAPIKey: ckbOpenAPIKey,
|
|
|
|
|
|
CkbOpenAccount: ckbOpenAccount,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
WechatAppID: wechatAppID,
|
|
|
|
|
|
WechatAppSecret: wechatAppSecret,
|
|
|
|
|
|
WechatMchID: wechatMchID,
|
|
|
|
|
|
WechatMchKey: wechatMchKey,
|
|
|
|
|
|
WechatNotifyURL: wechatNotifyURL,
|
|
|
|
|
|
WechatMiniProgramState: wechatMiniProgramState,
|
|
|
|
|
|
WechatAPIv3Key: wechatAPIv3Key,
|
|
|
|
|
|
WechatCertPath: wechatCertPath,
|
|
|
|
|
|
WechatKeyPath: wechatKeyPath,
|
|
|
|
|
|
WechatSerialNo: wechatSerialNo,
|
|
|
|
|
|
WechatTransferURL: wechatTransferURL,
|
|
|
|
|
|
AdminUsername: adminUsername,
|
|
|
|
|
|
AdminPassword: adminPassword,
|
|
|
|
|
|
AdminSessionSecret: adminSessionSecret,
|
|
|
|
|
|
SyncOrdersIntervalMinutes: syncOrdersInterval,
|
2026-03-14 23:27:22 +08:00
|
|
|
|
UploadDir: uploadDir,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
2026-03-14 23:27:22 +08:00
|
|
|
|
|
|
|
|
|
|
// resolveUploadDir 解析上传目录绝对路径(air 运行时 exe 在 tmp/,需用项目根)
|
|
|
|
|
|
func resolveUploadDir(workDir, execDir string) string {
|
|
|
|
|
|
root := workDir
|
|
|
|
|
|
if execDir != "" {
|
|
|
|
|
|
base := filepath.Base(execDir)
|
|
|
|
|
|
if base == "tmp" {
|
|
|
|
|
|
root = filepath.Dir(execDir)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
root = execDir
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
abs, _ := filepath.Abs(filepath.Join(root, "uploads"))
|
|
|
|
|
|
return abs
|
|
|
|
|
|
}
|