3c687e5e0b
新包 internal/ratelimit:内存/redis(GCRA+Lua) 双实现 + 出错逐调用降级内存 (fail-open 到内存不 fail-closed)。REDIS_ADDR 空=内存模式,行为与既往一致; 配置后跨重启保状态、支持多实例。miniredis 全覆盖测试,零真实外部依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
156 lines
7.4 KiB
Go
156 lines
7.4 KiB
Go
package config
|
||
|
||
import (
|
||
"log"
|
||
"strings"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
type Config struct {
|
||
Server ServerConfig
|
||
Database DatabaseConfig
|
||
JWT JWTConfig
|
||
Storage StorageConfig
|
||
Session SessionConfig
|
||
RateLimit RateLimitConfig
|
||
Redis RedisConfig
|
||
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"` // 认证流量每店突发
|
||
// 日配额(分钟级限流之上的第二道反爬闸,0=关闭该闸):同一 IP 每自然日累计上限
|
||
DailyProductPerIP int `mapstructure:"daily_product_per_ip"` // 单品详情(API + OG 页同池)
|
||
DailyShopListPerIP int `mapstructure:"daily_shoplist_per_ip"` // 店铺公开商品列表
|
||
}
|
||
|
||
// RedisConfig 限流/登录锁状态外置。Addr 为空 = 内存模式(单实例,重启清零)。
|
||
type RedisConfig struct {
|
||
Addr string `mapstructure:"addr"` // 如 127.0.0.1:6379
|
||
}
|
||
|
||
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.BindEnv("redis.addr", "REDIS_ADDR")
|
||
|
||
// 默认值
|
||
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("ratelimit.daily_product_per_ip", 1000)
|
||
viper.SetDefault("ratelimit.daily_shoplist_per_ip", 300)
|
||
viper.SetDefault("redis.addr", "") // 空=内存模式
|
||
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)
|
||
}
|
||
}
|