18925d7d15
- 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
192 lines
6.4 KiB
Go
192 lines
6.4 KiB
Go
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)
|
||
}
|
||
}
|
||
|