Files
jiu/backend/main.go
T
wangjia 4fd0bb8b83 feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns):
  入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total;
  出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润)
- 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total;
  确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit)
- 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底)
- 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端)
- SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 12:44:22 +08:00

191 lines
6.3 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.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)
}
}