Files
jiu/backend/main.go
T
wangjia a773a33779 feat: App→网页免登录(一次性换票 SSO)——移动/桌面点链接直达已登录官网
- backend:POST /auth/web-ticket(需登录,签 60s 单次票据,每用户限频)+
  POST /auth/web-ticket/exchange(公开,原子即焚兑换正常登录态,响应同
  /auth/login 另带 shop_code);web_tickets 新表 + user_sessions.kind 列
  (sso 会话:web 平台、refresh 12h 硬到期、不占设备并发配额、设备管理可见
  可踢);清理任务顺带清过期票据;schema.sql/AutoMigrate/testutil 同步
- web:/sso 落地页——兑票写入与 Web 版共享的 localStorage 后跳 redirect;
  redirect 白名单只收本站相对路径(防 open redirect),票据即时从地址栏抹除
- client:launchAuthedWebPath(ref, path) 封装(签票→拼 /sso URL→launchUrl,
  失败降级未登录直开;桌面/移动同一套);授权面板「查看套餐价格」接入

test(backend): web_ticket_test 三用例(兑换+续期/单次即焚/过期拒绝/
sso 不占配额双向验证)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:30:08 +08:00

193 lines
6.4 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/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:只信任本机 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)
}
}