package testutil import ( "fmt" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" ) // InitConfig 初始化测试用配置 func InitConfig() { config.C = config.Config{ Server: config.ServerConfig{ Port: "8080", Mode: "test", }, JWT: config.JWTConfig{ Secret: "test-secret-key-for-testing", AccessExpireMin: 60, RefreshExpireH: 168, }, Session: config.SessionConfig{ LimitDesktop: 2, LimitMobile: 2, LimitWeb: 2, MaxFailures: 5, IPMaxFailures: 20, LockMinutes: 15, }, // 测试默认关闭限流,避免压测式用例触发 429;限流逻辑由 ratelimit_test.go 显式开启验证。 RateLimit: config.RateLimitConfig{Enabled: false}, } } // SetupTestDB 创建 SQLite in-memory 测试数据库并自动迁移 func SetupTestDB() *gorm.DB { InitConfig() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { panic(fmt.Sprintf("failed to open sqlite: %v", err)) } // SQLite 不支持 ENUM,直接用原始 SQL 建表 stmts := []string{ `CREATE TABLE IF NOT EXISTS shops ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, name TEXT NOT NULL, code TEXT UNIQUE, description TEXT, address TEXT, phone TEXT, manager_name TEXT, logo_url TEXT DEFAULT '', wechat_id TEXT DEFAULT '', business_license TEXT, business_hours TEXT, shop_photos TEXT, custom_fields TEXT )`, `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, username TEXT, password_hash TEXT, real_name TEXT, phone TEXT, role TEXT DEFAULT 'operator', is_active INTEGER DEFAULT 1, last_login_at DATETIME, custom_fields TEXT )`, `CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_username ON users(shop_id, username)`, `CREATE TABLE IF NOT EXISTS user_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, user_id INTEGER NOT NULL, sid TEXT NOT NULL UNIQUE, device_id TEXT, device_name TEXT, platform TEXT, platform_class TEXT, ip TEXT, user_agent TEXT, refresh_jti TEXT, created_at DATETIME, last_seen_at DATETIME, revoked_at DATETIME, revoked_reason TEXT, revoked_by INTEGER, refresh_exp_at DATETIME )`, `CREATE TABLE IF NOT EXISTS login_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_code TEXT, username TEXT, ip TEXT, user_agent TEXT, success INTEGER DEFAULT 0, reason TEXT, created_at DATETIME )`, `CREATE TABLE IF NOT EXISTS licenses ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, license_key TEXT UNIQUE, device_id TEXT, type TEXT DEFAULT 'trial', tier TEXT DEFAULT 'standard', expires_at DATETIME, is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3, features TEXT, activated_at DATETIME )`, `CREATE TABLE IF NOT EXISTS license_codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE NOT NULL, type TEXT DEFAULT 'annual', tier TEXT DEFAULT 'standard', duration_days INTEGER NOT NULL DEFAULT 0, max_devices INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'unused', redeemed_shop_id INTEGER, redeemed_at DATETIME, redeemed_device_id TEXT, batch TEXT, note TEXT, created_at DATETIME, updated_at DATETIME )`, `CREATE TABLE IF NOT EXISTS license_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, license_id INTEGER NOT NULL, shop_id INTEGER NOT NULL, device_id TEXT NOT NULL, device_name TEXT, platform TEXT, activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(license_id, device_id) )`, `CREATE TABLE IF NOT EXISTS product_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, name TEXT NOT NULL, parent_id INTEGER, sort_order INTEGER DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, public_id TEXT DEFAULT '', code TEXT, barcode TEXT, name TEXT NOT NULL, series TEXT, spec TEXT, unit TEXT, category_id INTEGER, brand TEXT, purchase_price REAL, sale_price REAL, min_stock INTEGER DEFAULT 0, description TEXT, custom_fields TEXT, remark TEXT, name_pinyin TEXT DEFAULT '', name_initials TEXT DEFAULT '', origin_id INTEGER, shelf_life_id INTEGER, storage_id INTEGER, description_doc_id INTEGER )`, `CREATE TABLE IF NOT EXISTS product_origin_options ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, code TEXT, name TEXT NOT NULL, remark TEXT )`, `CREATE TABLE IF NOT EXISTS product_shelf_life_options ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, code TEXT, name TEXT NOT NULL, remark TEXT )`, `CREATE TABLE IF NOT EXISTS product_storage_options ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, code TEXT, name TEXT NOT NULL, remark TEXT )`, `CREATE TABLE IF NOT EXISTS product_description_docs ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, title TEXT NOT NULL, content TEXT, remark TEXT )`, `CREATE TABLE IF NOT EXISTS warehouses ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, name TEXT NOT NULL, location TEXT, is_default INTEGER DEFAULT 0, custom_fields TEXT )`, `CREATE TABLE IF NOT EXISTS partners ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, code TEXT, name TEXT NOT NULL, type TEXT DEFAULT 'supplier', status TEXT NOT NULL DEFAULT 'enabled', contact TEXT, phone TEXT, address TEXT, bank_account TEXT, credit_limit REAL, balance REAL DEFAULT 0, custom_fields TEXT, remark TEXT )`, `CREATE TABLE IF NOT EXISTS stock_in_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, order_no TEXT, type TEXT DEFAULT 'purchase', warehouse_id INTEGER NOT NULL, partner_id INTEGER, operator_id INTEGER NOT NULL, reviewer_id INTEGER, status TEXT DEFAULT 'draft', order_date DATETIME, total_amount REAL DEFAULT 0, reviewed_at DATETIME, custom_fields TEXT, remark TEXT )`, `CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_sio_order_no ON stock_in_orders(shop_id, order_no)`, `CREATE TABLE IF NOT EXISTS stock_in_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, order_id INTEGER NOT NULL, shop_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity REAL NOT NULL, unit_price REAL DEFAULT 0, total_price REAL DEFAULT 0, batch_no TEXT, production_date DATETIME, custom_fields TEXT, remark TEXT )`, `CREATE TABLE IF NOT EXISTS stock_out_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, order_no TEXT, type TEXT DEFAULT 'sale', warehouse_id INTEGER NOT NULL, partner_id INTEGER, operator_id INTEGER NOT NULL, reviewer_id INTEGER, status TEXT DEFAULT 'draft', order_date DATETIME, total_amount REAL DEFAULT 0, reviewed_at DATETIME, custom_fields TEXT, remark TEXT )`, `CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_soo_order_no ON stock_out_orders(shop_id, order_no)`, `CREATE TABLE IF NOT EXISTS stock_out_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, order_id INTEGER NOT NULL, shop_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity REAL NOT NULL, unit_price REAL DEFAULT 0, total_price REAL DEFAULT 0, custom_fields TEXT, remark TEXT )`, `CREATE TABLE IF NOT EXISTS inventories ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, warehouse_id INTEGER, product_id INTEGER, stock_in_item_id INTEGER, inventory_check_id INTEGER, quantity REAL DEFAULT 0, product_code TEXT DEFAULT '', product_name TEXT DEFAULT '', series TEXT DEFAULT '', spec TEXT DEFAULT '', unit TEXT DEFAULT '', warehouse_name TEXT DEFAULT '', unit_price REAL, production_date DATETIME, batch_no TEXT DEFAULT '', supplier_name TEXT DEFAULT '', remark TEXT DEFAULT '', created_at DATETIME, updated_at DATETIME, deleted_at DATETIME )`, `CREATE TABLE IF NOT EXISTS inventory_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, product_id INTEGER NOT NULL, direction TEXT, quantity REAL, qty_before REAL, qty_after REAL, ref_type TEXT, ref_id INTEGER, operator_id INTEGER, created_at DATETIME )`, `CREATE TABLE IF NOT EXISTS inventory_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, check_no TEXT, warehouse_id INTEGER NOT NULL, operator_id INTEGER NOT NULL, status TEXT DEFAULT 'draft', check_date DATETIME, remark TEXT )`, `CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_check_no ON inventory_checks(shop_id, check_no)`, `CREATE TABLE IF NOT EXISTS inventory_check_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, check_id INTEGER NOT NULL, shop_id INTEGER NOT NULL, product_id INTEGER NOT NULL, system_qty REAL, actual_qty REAL, remark TEXT )`, `CREATE TABLE IF NOT EXISTS finance_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, shop_id INTEGER NOT NULL, partner_id INTEGER, type TEXT, amount REAL DEFAULT 0, balance REAL DEFAULT 0, status TEXT DEFAULT 'open', ref_type TEXT, ref_id INTEGER, operator_id INTEGER, record_date DATETIME, remark TEXT, custom_fields TEXT )`, `CREATE TABLE IF NOT EXISTS number_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, shop_id INTEGER NOT NULL, type TEXT, prefix TEXT DEFAULT '', current_no INTEGER DEFAULT 0, date_format TEXT DEFAULT 'YYYYMMDD', updated_at DATETIME, UNIQUE(shop_id, type) )`, } for _, stmt := range stmts { if err := db.Exec(stmt).Error; err != nil { panic(fmt.Sprintf("failed to create table: %v\nSQL: %s", err, stmt)) } } return db } // CreateTestShop 创建测试门店 func CreateTestShop(db *gorm.DB, code string) *model.Shop { shop := &model.Shop{ Name: "Test Shop " + code, Code: code, } if err := db.Create(shop).Error; err != nil { panic(fmt.Sprintf("failed to create test shop: %v", err)) } return shop } // hashPassword 内部使用的密码哈希函数,避免循环依赖 func hashPassword(plain string) string { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { panic(fmt.Sprintf("failed to hash password: %v", err)) } return string(b) } // CreateTestUser 创建测试用户 func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User { hash := hashPassword(password) user := &model.User{ ShopID: shopID, Username: username, PasswordHash: hash, RealName: "Test User " + username, Role: role, IsActive: true, } if err := db.Create(user).Error; err != nil { panic(fmt.Sprintf("failed to create test user: %v", err)) } return user } // CreateTestWarehouse 创建测试仓库 func CreateTestWarehouse(db *gorm.DB, shopID uint64, name string) *model.Warehouse { w := &model.Warehouse{ TenantBase: model.TenantBase{ ShopID: shopID, }, Name: name, IsDefault: true, } if err := db.Create(w).Error; err != nil { panic(fmt.Sprintf("failed to create test warehouse: %v", err)) } return w } // CreateTestProduct 创建测试商品 func CreateTestProduct(db *gorm.DB, shopID uint64, name string) *model.Product { p := &model.Product{ TenantBase: model.TenantBase{ ShopID: shopID, }, Name: name, Code: "P-" + name, Unit: "个", } if err := db.Create(p).Error; err != nil { panic(fmt.Sprintf("failed to create test product: %v", err)) } return p } // GetAuthToken 生成测试 JWT token func GetAuthToken(userID, shopID uint64, role string) string { InitConfig() now := time.Now() claims := middleware.Claims{ UserID: userID, ShopID: shopID, Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), IssuedAt: jwt.NewNumericDate(now), }, } token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims). SignedString([]byte(config.C.JWT.Secret)) if err != nil { panic(fmt.Sprintf("failed to sign token: %v", err)) } return token }