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/router" "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/internal/util" ) func main() { // 加载配置 config.Load() // 生产环境启动前置检查 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:只信任本机 nginx(127.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{}, ) 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) } }