Files
jiu/backend/main.go
T
wangjia 3c687e5e0b feat(backend): 限流/登录失败锁状态外置 Redis(todo #2)
新包 internal/ratelimit:内存/redis(GCRA+Lua) 双实现 + 出错逐调用降级内存
(fail-open 到内存不 fail-closed)。REDIS_ADDR 空=内存模式,行为与既往一致;
配置后跨重启保状态、支持多实例。miniredis 全覆盖测试,零真实外部依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:23 +08:00

198 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 main
import (
"fmt"
"log"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/ratelimit"
"github.com/wangjia/jiu/backend/internal/router"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
// 加载配置
config.Load()
// 限流/登录锁状态存储:REDIS_ADDR 配置后外置 redis(跨重启保状态),
// 未配置或连不上时落内存(与既往行为一致)。须在 router.Setup 之前。
ratelimit.Init(config.C.Redis.Addr)
// 生产环境启动前置检查
if config.C.Server.Mode == "release" {
if config.C.Server.CORSOrigin == "*" {
log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin")
}
// SEC-002:默认/空 JWT 密钥随仓库公开,任何人可自签 superadmin token。
// 漏配 JWT_SECRET 时必须拒绝启动,而不是带公开密钥上线。
if config.C.JWT.Secret == "" ||
config.C.JWT.Secret == "change-this-to-a-random-secret-in-production" {
log.Fatal("jwt.secret is empty or still the repo default — set JWT_SECRET in production")
}
}
// 初始化数据库
db := initDB()
// ALTER 已有数据库的 enum,使 superadmin 在已运行实例上也生效(忽略错误)
db.Exec("ALTER TABLE users MODIFY COLUMN role ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator'")
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db)
// 回填存量往来单位的拼音搜索列(幂等,只处理空值行)
backfillPartnerPinyin(db)
// 2026-07 定价字段消歧迁移:旧列(unit_price/total_price/total_amount)值拷入新列,
// 幂等(新列为 0 才拷),旧列保留不再读写、观察一版后手动 DROP
service.BackfillPricingColumns(db)
// 启动会话/失败登录保留期清理任务(后台 goroutine)
service.StartSessionCleanup(db, config.C.Session.RetentionDays)
// 启动 Gin
gin.SetMode(config.C.Server.Mode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
// 真实客户端 IP:只信任本机 nginx127.0.0.1/::1)写的 X-Real-IP,丢弃客户端伪造的
// X-Forwarded-For。这样 c.ClientIP() 返回不可伪造的真实 IP,是所有按 IP 限流/审计的基础。
// 代理链:client → nginx(127.0.0.1:8445) → 后端(127.0.0.1:8080);将来若在 nginx 前再加
// 一层代理,需把其地址并入下列可信网段。
_ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"})
r.RemoteIPHeaders = []string{"X-Real-IP"}
// CORS
corsOrigin := config.C.Server.CORSOrigin
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", corsOrigin)
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Serve uploaded images (in production, Nginx handles /images/)
r.Static("/images", config.C.Storage.UploadDir)
router.Setup(r, db)
addr := fmt.Sprintf(":%s", config.C.Server.Port)
log.Printf("Server starting on %s (mode: %s)", addr, config.C.Server.Mode)
if err := r.Run(addr); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
func initDB() *gorm.DB {
dsn := config.C.Database.DSN
if dsn == "" {
log.Fatal("database.dsn is required in config")
}
logLevel := logger.Silent
if config.C.Server.Mode == "debug" {
logLevel = logger.Info
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
TranslateError: true, // 把 MySQL 1062 翻译成 gorm.ErrDuplicatedKey,供编码撞唯一约束时重试
})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(config.C.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(config.C.Database.MaxOpenConns)
return db
}
func autoMigrate(db *gorm.DB) {
err := db.AutoMigrate(
&model.Shop{},
&model.User{},
&model.License{},
&model.LicenseDevice{},
&model.LicenseCode{},
&model.LicensePurchase{},
&model.UserSession{},
&model.LoginAttempt{},
&model.ProductCategory{},
&model.Product{},
&model.Warehouse{},
&model.Partner{},
&model.StockInOrder{},
&model.StockInItem{},
&model.StockOutOrder{},
&model.StockOutItem{},
&model.Inventory{},
&model.InventoryLog{},
&model.InventoryCheck{},
&model.InventoryCheckItem{},
&model.FinanceRecord{},
&model.NumberRule{},
&model.ProductNameOption{},
&model.ProductSeriesOption{},
&model.ProductSpecOption{},
&model.ProductOriginOption{},
&model.ProductShelfLifeOption{},
&model.ProductStorageOption{},
&model.ProductDescriptionDoc{},
&model.ProductImage{},
&model.ErrorReport{},
&model.Feedback{},
&model.WebTicket{},
)
if err != nil {
log.Fatalf("auto migrate failed: %v", err)
}
// products(shop_id, code) 联合唯一索引:ShopID 在共用 TenantBase 上无法用 struct tag 表达,
// 故在此幂等显式建(保证同店内商品编码唯一,DB 层兜底防止重复编码静默落库)。
if !db.Migrator().HasIndex(&model.Product{}, "uk_shop_code") {
if err := db.Exec("CREATE UNIQUE INDEX uk_shop_code ON products (shop_id, code)").Error; err != nil {
log.Fatalf("create unique index uk_shop_code failed: %v", err)
}
}
// stock_out_items(shop_id, product_code) 复合索引:支撑出库列表「按商品编码反查单据」,
// 避免在 2.6 万+ 明细行上全表扫。ShopID 在嵌入字段上无法用 struct tag 表达,故显式幂等建。
if !db.Migrator().HasIndex(&model.StockOutItem{}, "idx_so_items_shop_code") {
if err := db.Exec("CREATE INDEX idx_so_items_shop_code ON stock_out_items (shop_id, product_code)").Error; err != nil {
log.Fatalf("create index idx_so_items_shop_code failed: %v", err)
}
}
log.Println("AutoMigrate completed")
}
// backfillPartnerPinyin 为存量往来单位生成拼音搜索列(name_pinyin 为空的行)。
// 与 products 的拼音列同机制:写入时自动生成,这里兜底历史数据。
func backfillPartnerPinyin(db *gorm.DB) {
var partners []model.Partner
db.Where("(name_pinyin = '' OR name_pinyin IS NULL) AND deleted_at IS NULL").Find(&partners)
filled := 0
for i := range partners {
py, ini := util.ToPinyin(partners[i].Name)
if py == "" && ini == "" {
continue
}
db.Model(&model.Partner{}).Where("id = ?", partners[i].ID).
Updates(map[string]interface{}{"name_pinyin": py, "name_initials": ini})
filled++
}
if filled > 0 {
log.Printf("backfill partner pinyin: %d rows", filled)
}
}