init: 后端框架脚手架 (Go + Gin + GORM + MySQL)

- 项目目录结构:backend/ deploy/ schema/ migrations/
- 数据库 Schema:所有建表 SQL,含 hotel_id 多租户隔离
- Go 后端:config、model、handler、service、middleware、router
- 认证:账号密码登录 + JWT(Access + Refresh Token)
- 许可证:HMAC-SHA256 激活码生成 + 设备绑定验证
- 业务模块:商品、仓库、往来单位、入库、出库、库存、盘点
- 库存事务:入库/出库审核时原子更新库存 + 流水记录
- 数据导入:Excel/CSV 批量导入商品、往来单位
- Docker Compose:本地 MySQL + Adminer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-04 01:24:53 +08:00
commit 0e42f0e417
39 changed files with 2989 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserInactive = errors.New("user is disabled")
)
type AuthService struct {
db *gorm.DB
}
func NewAuthService(db *gorm.DB) *AuthService {
return &AuthService{db: db}
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // 秒
}
// Login 账号密码登录
func (s *AuthService) Login(hotelCode, username, password string) (*TokenPair, *model.User, error) {
var hotel model.Hotel
if err := s.db.Where("code = ?", hotelCode).First(&hotel).Error; err != nil {
return nil, nil, ErrInvalidCredentials
}
var user model.User
if err := s.db.Where("hotel_id = ? AND username = ? AND deleted_at IS NULL", hotel.ID, username).
First(&user).Error; err != nil {
return nil, nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
pair, err := s.issueTokens(user.ID, hotel.ID, user.Role)
if err != nil {
return nil, nil, err
}
return pair, &user, nil
}
// HashPassword 生成 bcrypt 哈希
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
return string(b), err
}
// RefreshTokens 用 Refresh Token 换新 Token Pair
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
claims := &middleware.Claims{}
token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(config.C.JWT.Secret), nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid refresh token")
}
return s.issueTokens(claims.UserID, claims.HotelID, claims.Role)
}
func (s *AuthService) issueTokens(userID, hotelID uint64, role string) (*TokenPair, error) {
cfg := config.C.JWT
now := time.Now()
accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
accessClaims := middleware.Claims{
UserID: userID,
HotelID: hotelID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExp),
IssuedAt: jwt.NewNumericDate(now),
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.Secret))
if err != nil {
return nil, err
}
refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour)
refreshClaims := middleware.Claims{
UserID: userID,
HotelID: hotelID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExp),
IssuedAt: jwt.NewNumericDate(now),
},
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.Secret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: cfg.AccessExpireMin * 60,
}, nil
}
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
var (
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseInactive = errors.New("license is inactive")
ErrLicenseExpired = errors.New("license has expired")
ErrDeviceMismatch = errors.New("license is bound to another device")
)
type LicenseService struct {
db *gorm.DB
}
func NewLicenseService(db *gorm.DB) *LicenseService {
return &LicenseService{db: db}
}
// GenerateKey 生成许可证激活码
// 格式:HMAC-SHA256(hotelID+deviceID+expiry, secret) → base32, 每5字符加'-'
func GenerateKey(hotelID uint64, licenseType string, expiresAt *time.Time) string {
payload := fmt.Sprintf("%d:%s", hotelID, licenseType)
if expiresAt != nil {
payload += ":" + expiresAt.Format("20060102")
}
mac := hmac.New(sha256.New, []byte(config.C.License.HMACSecret))
mac.Write([]byte(payload))
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil))
// 截取前20字符,分4段,每段5字符
raw = strings.ToUpper(raw)[:20]
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
}
// Activate 激活许可证(绑定设备)
func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License, error) {
var lic model.License
if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if !lic.IsActive {
return nil, ErrLicenseInactive
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
// 若已绑定设备,校验是否一致
if lic.DeviceID != "" && lic.DeviceID != deviceID {
return nil, ErrDeviceMismatch
}
now := time.Now()
lic.DeviceID = deviceID
lic.ActivatedAt = &now
s.db.Save(&lic)
return &lic, nil
}
// Verify 验证(客户端启动时调用)
func (s *LicenseService) Verify(hotelID uint64, deviceID string) (*model.License, error) {
var lic model.License
if err := s.db.Where("hotel_id = ? AND device_id = ? AND is_active = 1", hotelID, deviceID).
First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
return &lic, nil
}
// Deactivate 解绑设备(换机时使用)
func (s *LicenseService) Deactivate(hotelID uint64, deviceID string) error {
return s.db.Model(&model.License{}).
Where("hotel_id = ? AND device_id = ?", hotelID, deviceID).
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
}
+160
View File
@@ -0,0 +1,160 @@
package service
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
var ErrInsufficientStock = errors.New("insufficient stock")
type StockService struct {
db *gorm.DB
}
func NewStockService(db *gorm.DB) *StockService {
return &StockService{db: db}
}
// ApproveStockIn 审核入库单,审核通过后更新库存(事务)
func (s *StockService) ApproveStockIn(hotelID, orderID, reviewerID uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items").
Where("id = ? AND hotel_id = ?", orderID, hotelID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending" {
return errors.New("order is not in pending status")
}
now := time.Now()
for _, item := range order.Items {
if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID,
"in", item.Quantity, orderID, "stock_in", reviewerID); err != nil {
return err
}
}
return tx.Model(&order).Updates(map[string]interface{}{
"status": "approved",
"reviewer_id": reviewerID,
"reviewed_at": now,
}).Error
})
}
// ApproveStockOut 审核出库单
func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockOutOrder
if err := tx.Preload("Items").
Where("id = ? AND hotel_id = ?", orderID, hotelID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending" {
return errors.New("order is not in pending status")
}
// 预检库存
for _, item := range order.Items {
var inv model.Inventory
if err := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotelID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
return fmt.Errorf("product %d not in inventory", item.ProductID)
}
if inv.Quantity < item.Quantity {
return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f",
ErrInsufficientStock, item.ProductID, inv.Quantity, item.Quantity)
}
}
now := time.Now()
for _, item := range order.Items {
if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID,
"out", item.Quantity, orderID, "stock_out", reviewerID); err != nil {
return err
}
}
return tx.Model(&order).Updates(map[string]interface{}{
"status": "approved",
"reviewer_id": reviewerID,
"reviewed_at": now,
}).Error
})
}
// updateInventory 更新库存并写流水(在事务中调用)
func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, productID uint64,
direction string, qty float64, refID uint64, refType string, operatorID uint64) error {
var inv model.Inventory
result := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotelID, warehouseID, productID).First(&inv)
qtyBefore := inv.Quantity
var qtyAfter float64
if direction == "in" {
qtyAfter = qtyBefore + qty
if result.Error != nil {
// 不存在则创建
inv = model.Inventory{HotelID: hotelID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter}
if err := tx.Create(&inv).Error; err != nil {
return err
}
} else {
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
return err
}
}
} else {
qtyAfter = qtyBefore - qty
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
return err
}
}
log := model.InventoryLog{
HotelID: hotelID,
WarehouseID: warehouseID,
ProductID: productID,
Direction: direction,
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qtyAfter,
RefType: refType,
RefID: refID,
OperatorID: &operatorID,
}
return tx.Create(&log).Error
}
// GenerateOrderNo 生成单号(事务安全)
func (s *StockService) GenerateOrderNo(hotelID uint64, orderType string) (string, error) {
var no string
err := s.db.Transaction(func(tx *gorm.DB) error {
var rule model.NumberRule
result := tx.Where("hotel_id = ? AND type = ?", hotelID, orderType).First(&rule)
if result.Error != nil {
// 初始化规则
rule = model.NumberRule{HotelID: hotelID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
tx.Create(&rule)
}
rule.CurrentNo++
tx.Model(&rule).Update("current_no", rule.CurrentNo)
dateStr := time.Now().Format("20060102")
no = fmt.Sprintf("%s%s%06d", rule.Prefix, dateStr, rule.CurrentNo)
return nil
})
return no, err
}