Files
jiu/backend/config/config.go
T
wangjia 18925d7d15 feat(backend): 在线购买/续费对接 pay 收款中枢——下单/webhook 验签入账/续期叠加/查单兜底
- POST /license/purchase(仅管理员)建单并调 pay 下单,返回收银台 pay_url
- POST /pay/callback 公开接收器:HMAC 验签+时间戳窗口+按 out_trade_no 幂等+金额逐分核对,同事务续期
- 续期与兑换券同口径:未过期从到期日叠加、已过期从现在起算,写入 tier/max_devices/features
- 后台每 60s 查单兜底防 webhook 丢失;closed 标 failed
- 新表 license_purchases(schema.sql/AutoMigrate/testutil 同步);配置 PAY_SECRET/PAY_BASE_URL/PAY_RETURN_URL
- 契约真相源 ~/code/pay-contract openapi.yaml v1.0.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 20:40:06 +08:00

143 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"log"
"strings"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Storage StorageConfig
Session SessionConfig
RateLimit RateLimitConfig
Pay PayConfig
}
// PayConfig pay 收款中枢对接(契约:~/code/pay-contract openapi.yaml v1.0.0)。
// Secret 为 jiu↔pay 双向 HMAC 共享密钥(pay 侧 BIZ_JIU_SECRET 同值),只经环境变量注入;
// 为空时购买接口返回 503,webhook 一律拒绝。
type PayConfig struct {
BaseURL string `mapstructure:"base_url"` // pay 服务地址
Secret string `mapstructure:"secret"` // HMAC 共享密钥(PAY_SECRET
ReturnURL string `mapstructure:"return_url"` // 支付完成回跳页(pay 会拼 out_trade_no
}
type ServerConfig struct {
Port string `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug | release
CORSOrigin string `mapstructure:"cors_origin"` // 允许的 CORS 来源,生产设为具体域名
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessExpireMin int `mapstructure:"access_expire_min"` // Access Token 有效分钟数
RefreshExpireH int `mapstructure:"refresh_expire_h"` // Refresh Token 有效小时数
}
// SessionConfig 登录会话与并发限制(全局默认,可被每店 session_policy 覆盖)。
type SessionConfig struct {
LimitTotal int `mapstructure:"limit_total"` // 单用户跨全部平台的最大并发会话总数(超额踢最旧)
LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)平台闸门,0=禁止该平台登录
LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)平台闸门,0=禁止该平台登录
LimitWeb int `mapstructure:"limit_web"` // web 端平台闸门,0=禁止该平台登录
MaxFailures int `mapstructure:"max_failures"` // 同一账号连续登录失败几次后锁定
IPMaxFailures int `mapstructure:"ip_max_failures"` // 同一 IP 跨账号累计失败几次后锁定该 IP(防单 IP 撞多账号)
LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟)
RetentionDays int `mapstructure:"retention_days"` // 已撤销/过期会话与失败登录记录的保留天数,过期后台清理
}
// RateLimitConfig 应用层限流(按真实客户端 IP / 按门店)。各 *PerMin 为每分钟允许次数,
// burst 取同值(允许一分钟额度的突发,之后按速率回补)。Enabled=false 时所有限流放行。
type RateLimitConfig struct {
Enabled bool `mapstructure:"enabled"`
LoginPerMin int `mapstructure:"login_per_min"` // 未鉴权 /auth/login,按 IP
RefreshPerMin int `mapstructure:"refresh_per_min"` // 未鉴权 /auth/refresh,按 IP
RegisterPerMin int `mapstructure:"register_per_min"` // 未鉴权 /public/register,按 IP
ErrorsPerMin int `mapstructure:"errors_per_min"` // 未鉴权 /public/errors,按 IP
ShopListPerMin int `mapstructure:"shop_list_per_min"` // 公开商品列表(主爬取入口),按 IP,最紧
PublicReadPerMin int `mapstructure:"public_read_per_min"` // 其余公开读接口(单品/release),按 IP
ShopRPS int `mapstructure:"shop_rps"` // 认证流量每店每秒,按 shop_id
ShopBurst int `mapstructure:"shop_burst"` // 认证流量每店突发
}
type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"`
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
}
var C Config
func Load() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
// 环境变量覆盖(生产部署时使用)
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// 显式绑定没有默认值的 key,确保 AutomaticEnv 能找到对应 env var
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
_ = viper.BindEnv("pay.base_url", "PAY_BASE_URL")
_ = viper.BindEnv("pay.secret", "PAY_SECRET")
_ = viper.BindEnv("pay.return_url", "PAY_RETURN_URL")
// 默认值
viper.SetDefault("server.port", "8080")
viper.SetDefault("server.mode", "debug")
viper.SetDefault("server.cors_origin", "*")
viper.SetDefault("jwt.access_expire_min", 60)
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
viper.SetDefault("session.limit_total", 5) // 单用户跨全部平台合计并发上限
viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限)
viper.SetDefault("session.limit_mobile", 2)
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
viper.SetDefault("session.max_failures", 5)
viper.SetDefault("session.ip_max_failures", 20)
viper.SetDefault("session.lock_minutes", 15)
viper.SetDefault("session.retention_days", 90)
viper.SetDefault("ratelimit.enabled", true)
viper.SetDefault("ratelimit.login_per_min", 10)
viper.SetDefault("ratelimit.refresh_per_min", 20)
viper.SetDefault("ratelimit.register_per_min", 5)
viper.SetDefault("ratelimit.errors_per_min", 30)
viper.SetDefault("ratelimit.shop_list_per_min", 30)
viper.SetDefault("ratelimit.public_read_per_min", 60)
viper.SetDefault("ratelimit.shop_rps", 20)
viper.SetDefault("ratelimit.shop_burst", 40)
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./web")
viper.SetDefault("pay.base_url", "https://pay.51yanmei.com")
viper.SetDefault("pay.return_url", "https://jiu.51yanmei.com/license/result/")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars")
}
if err := viper.Unmarshal(&C); err != nil {
log.Fatalf("[config] failed to unmarshal config: %v", err)
}
}