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
+157
View File
@@ -0,0 +1,157 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type ImportHandler struct {
db *gorm.DB
}
func NewImportHandler(db *gorm.DB) *ImportHandler {
return &ImportHandler{db: db}
}
// ImportProducts POST /api/v1/import/products
// 支持 .xlsx / .csv,列顺序:名称,系列,规格,单位,品牌,进价,售价,最低库存,备注
func (h *ImportHandler) ImportProducts(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
xl, err := excelize.OpenReader(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file: " + err.Error()})
return
}
sheetName := xl.GetSheetName(0)
rows, err := xl.GetRows(sheetName)
if err != nil || len(rows) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty or invalid sheet"})
return
}
var products []model.Product
var errRows []map[string]interface{}
for i, row := range rows[1:] { // 跳过表头
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
continue
}
p := model.Product{
TenantBase: model.TenantBase{HotelID: hotelID},
}
p.Name = cell(row, 0)
p.Series = cell(row, 1)
p.Spec = cell(row, 2)
p.Unit = cell(row, 3)
p.Brand = cell(row, 4)
p.Remark = cell(row, 8)
if p.Name == "" {
errRows = append(errRows, map[string]interface{}{"row": i + 2, "error": "name is empty"})
continue
}
products = append(products, p)
}
if len(products) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rows", "errors": errRows})
return
}
// 批量写入(upsert by hotel_id+name+spec
if err := h.db.CreateInBatches(&products, 100).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"imported": len(products),
"errors": errRows,
})
}
// ImportPartners POST /api/v1/import/partners
// 列顺序:名称,类型(supplier/customer),联系人,电话,地址,备注
func (h *ImportHandler) ImportPartners(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
xl, err := excelize.OpenReader(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file"})
return
}
rows, err := xl.GetRows(xl.GetSheetName(0))
if err != nil || len(rows) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty sheet"})
return
}
var partners []model.Partner
for _, row := range rows[1:] {
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
continue
}
t := cell(row, 1)
if t == "" {
t = "supplier"
}
partners = append(partners, model.Partner{
TenantBase: model.TenantBase{HotelID: hotelID},
Name: cell(row, 0),
Type: t,
Contact: cell(row, 2),
Phone: cell(row, 3),
Address: cell(row, 4),
Remark: cell(row, 5),
})
}
if err := h.db.CreateInBatches(&partners, 100).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"imported": len(partners)})
}
func cell(row []string, idx int) string {
if idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}