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
+68
View File
@@ -0,0 +1,68 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/service"
)
type AuthHandler struct {
svc *service.AuthService
}
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
return &AuthHandler{svc: svc}
}
// Login POST /api/v1/auth/login
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
HotelCode string `json:"hotel_code" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pair, user, err := h.svc.Login(req.HotelCode, req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"expires_in": pair.ExpiresIn,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"real_name": user.RealName,
"role": user.Role,
},
},
})
}
// Refresh POST /api/v1/auth/refresh
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pair, err := h.svc.RefreshTokens(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": pair})
}
+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])
}
+116
View File
@@ -0,0 +1,116 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type InventoryHandler struct {
db *gorm.DB
}
func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
return &InventoryHandler{db: db}
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.Inventory{}).Where("hotel_id = ?", hotelID)
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
query = query.Where("warehouse_id = ?", warehouseID)
}
if productID := c.Query("product_id"); productID != "" {
query = query.Where("product_id = ?", productID)
}
// 仅显示有库存
if c.Query("in_stock") == "1" {
query = query.Where("quantity > 0")
}
var total int64
query.Count(&total)
var inventory []model.Inventory
query.Preload("Product").Preload("Warehouse").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&inventory)
c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize})
}
// Logs GET /api/v1/inventory/logs
func (h *InventoryHandler) Logs(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.InventoryLog{}).Where("hotel_id = ?", hotelID)
if productID := c.Query("product_id"); productID != "" {
query = query.Where("product_id = ?", productID)
}
var total int64
query.Count(&total)
var logs []model.InventoryLog
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
}
// CreateCheck POST /api/v1/inventory/checks
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
operatorID := middleware.GetUserID(c)
var req model.InventoryCheck
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.HotelID = hotelID
req.OperatorID = operatorID
req.Status = "draft"
// 自动填入系统库存数量
for i := range req.Items {
req.Items[i].HotelID = hotelID
var inv model.Inventory
if err := h.db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotelID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil {
req.Items[i].SystemQty = inv.Quantity
}
}
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
}
// GetCheck GET /api/v1/inventory/checks/:id
func (h *InventoryHandler) GetCheck(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
Where("id = ? AND hotel_id = ?", c.Param("id"), hotelID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": check})
}
+71
View File
@@ -0,0 +1,71 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
)
type LicenseHandler struct {
svc *service.LicenseService
}
func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler {
return &LicenseHandler{svc: svc}
}
// Activate POST /api/v1/license/activate
func (h *LicenseHandler) Activate(c *gin.Context) {
var req struct {
LicenseKey string `json:"license_key" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
lic, err := h.svc.Activate(req.LicenseKey, req.DeviceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
}
// Verify GET /api/v1/license/verify
func (h *LicenseHandler) Verify(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
deviceID := c.Query("device_id")
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device_id required"})
return
}
lic, err := h.svc.Verify(hotelID, deviceID)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
}
// Deactivate POST /api/v1/license/deactivate
func (h *LicenseHandler) Deactivate(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var req struct {
DeviceID string `json:"device_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.Deactivate(hotelID, req.DeviceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deactivated"})
}
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type PartnerHandler struct {
db *gorm.DB
}
func NewPartnerHandler(db *gorm.DB) *PartnerHandler {
return &PartnerHandler{db: db}
}
func (h *PartnerHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.Partner{}).
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
if t := c.Query("type"); t != "" {
query = query.Where("FIND_IN_SET(?, type)", t)
}
if kw := c.Query("keyword"); kw != "" {
query = query.Where("name LIKE ? OR phone LIKE ?", "%"+kw+"%", "%"+kw+"%")
}
var total int64
query.Count(&total)
var partners []model.Partner
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners)
c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize})
}
func (h *PartnerHandler) Create(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var p model.Partner
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.HotelID = hotelID
if err := h.db.Create(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": p})
}
func (h *PartnerHandler) Update(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var p model.Partner
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
First(&p).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.HotelID = hotelID
h.db.Save(&p)
c.JSON(http.StatusOK, gin.H{"data": p})
}
func (h *PartnerHandler) Delete(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
now := timeNow()
result := h.db.Model(&model.Partner{}).
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
Update("deleted_at", now)
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
+114
View File
@@ -0,0 +1,114 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type ProductHandler struct {
db *gorm.DB
}
func NewProductHandler(db *gorm.DB) *ProductHandler {
return &ProductHandler{db: db}
}
// List GET /api/v1/products
func (h *ProductHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
keyword := c.Query("keyword")
categoryID := c.Query("category_id")
query := h.db.Model(&model.Product{}).
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
if keyword != "" {
query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
}
if categoryID != "" {
query = query.Where("category_id = ?", categoryID)
}
var total int64
query.Count(&total)
var products []model.Product
offset := (page - 1) * pageSize
query.Preload("Category").Offset(offset).Limit(pageSize).
Order("id DESC").Find(&products)
c.JSON(http.StatusOK, gin.H{
"data": products,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// Create POST /api/v1/products
func (h *ProductHandler) Create(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var product model.Product
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product.HotelID = hotelID
if err := h.db.Create(&product).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
}
// Update PUT /api/v1/products/:id
func (h *ProductHandler) Update(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
id := c.Param("id")
var product model.Product
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID).
First(&product).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product.HotelID = hotelID // 防止篡改
if err := h.db.Save(&product).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": product})
}
// Delete DELETE /api/v1/products/:id (软删除)
func (h *ProductHandler) Delete(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
id := c.Param("id")
now := timeNow()
result := h.db.Model(&model.Product{}).
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID).
Update("deleted_at", now)
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
+156
View File
@@ -0,0 +1,156 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
)
func timeNow() *time.Time {
t := time.Now()
return &t
}
type StockInHandler struct {
db *gorm.DB
stockSvc *service.StockService
}
func NewStockInHandler(db *gorm.DB, svc *service.StockService) *StockInHandler {
return &StockInHandler{db: db, stockSvc: svc}
}
// List GET /api/v1/stock-in/orders
func (h *StockInHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.StockInOrder{}).
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if startDate := c.Query("start_date"); startDate != "" {
query = query.Where("order_date >= ?", startDate)
}
if endDate := c.Query("end_date"); endDate != "" {
query = query.Where("order_date <= ?", endDate)
}
var total int64
query.Count(&total)
var orders []model.StockInOrder
query.Preload("Warehouse").Preload("Partner").Preload("Operator").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("id DESC").Find(&orders)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// Get GET /api/v1/stock-in/orders/:id
func (h *StockInHandler) Get(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var order model.StockInOrder
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
}
// Create POST /api/v1/stock-in/orders
func (h *StockInHandler) Create(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
operatorID := middleware.GetUserID(c)
var req model.StockInOrder
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.HotelID = hotelID
req.OperatorID = operatorID
req.Status = "draft"
// 生成单号
orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "stock_in")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
req.OrderNo = orderNo
// 计算总金额
var total float64
for i := range req.Items {
req.Items[i].HotelID = hotelID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
}
req.TotalAmount = total
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
}
// Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核)
func (h *StockInHandler) Submit(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
result := h.db.Model(&model.StockInOrder{}).
Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID).
Update("status", "pending")
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
}
// Approve PUT /api/v1/stock-in/orders/:id/approve
func (h *StockInHandler) Approve(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
reviewerID := middleware.GetUserID(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.stockSvc.ApproveStockIn(hotelID, id, reviewerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "approved"})
}
// Reject PUT /api/v1/stock-in/orders/:id/reject
func (h *StockInHandler) Reject(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
reviewerID := middleware.GetUserID(c)
now := timeNow()
result := h.db.Model(&model.StockInOrder{}).
Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID).
Updates(map[string]interface{}{
"status": "rejected",
"reviewer_id": reviewerID,
"reviewed_at": now,
})
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
}
+148
View File
@@ -0,0 +1,148 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
)
type StockOutHandler struct {
db *gorm.DB
stockSvc *service.StockService
}
func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler {
return &StockOutHandler{db: db, stockSvc: svc}
}
// List GET /api/v1/stock-out/orders
func (h *StockOutHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := h.db.Model(&model.StockOutOrder{}).
Where("hotel_id = ? AND deleted_at IS NULL", hotelID)
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if startDate := c.Query("start_date"); startDate != "" {
query = query.Where("order_date >= ?", startDate)
}
if endDate := c.Query("end_date"); endDate != "" {
query = query.Where("order_date <= ?", endDate)
}
var total int64
query.Count(&total)
var orders []model.StockOutOrder
query.Preload("Warehouse").Preload("Partner").Preload("Operator").
Offset((page - 1) * pageSize).Limit(pageSize).
Order("id DESC").Find(&orders)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// Get GET /api/v1/stock-out/orders/:id
func (h *StockOutHandler) Get(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var order model.StockOutOrder
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
}
// Create POST /api/v1/stock-out/orders
func (h *StockOutHandler) Create(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
operatorID := middleware.GetUserID(c)
var req model.StockOutOrder
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.HotelID = hotelID
req.OperatorID = operatorID
req.Status = "draft"
orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "stock_out")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
req.OrderNo = orderNo
var total float64
for i := range req.Items {
req.Items[i].HotelID = hotelID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
}
req.TotalAmount = total
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
}
// Submit PUT /api/v1/stock-out/orders/:id/submit
func (h *StockOutHandler) Submit(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
result := h.db.Model(&model.StockOutOrder{}).
Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID).
Update("status", "pending")
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "submitted"})
}
// Approve PUT /api/v1/stock-out/orders/:id/approve
func (h *StockOutHandler) Approve(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
reviewerID := middleware.GetUserID(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.stockSvc.ApproveStockOut(hotelID, id, reviewerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "approved"})
}
// Reject PUT /api/v1/stock-out/orders/:id/reject
func (h *StockOutHandler) Reject(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
reviewerID := middleware.GetUserID(c)
now := timeNow()
result := h.db.Model(&model.StockOutOrder{}).
Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID).
Updates(map[string]interface{}{
"status": "rejected",
"reviewer_id": reviewerID,
"reviewed_at": now,
})
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "rejected"})
}
+61
View File
@@ -0,0 +1,61 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
type WarehouseHandler struct {
db *gorm.DB
}
func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler {
return &WarehouseHandler{db: db}
}
func (h *WarehouseHandler) List(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var warehouses []model.Warehouse
h.db.Where("hotel_id = ? AND deleted_at IS NULL", hotelID).Find(&warehouses)
c.JSON(http.StatusOK, gin.H{"data": warehouses})
}
func (h *WarehouseHandler) Create(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var w model.Warehouse
if err := c.ShouldBindJSON(&w); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w.HotelID = hotelID
h.db.Create(&w)
c.JSON(http.StatusCreated, gin.H{"data": w})
}
func (h *WarehouseHandler) Update(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
var w model.Warehouse
if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
First(&w).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.ShouldBindJSON(&w)
w.HotelID = hotelID
h.db.Save(&w)
c.JSON(http.StatusOK, gin.H{"data": w})
}
func (h *WarehouseHandler) Delete(c *gin.Context) {
hotelID := middleware.GetHotelID(c)
now := timeNow()
h.db.Model(&model.Warehouse{}).
Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID).
Update("deleted_at", now)
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
+74
View File
@@ -0,0 +1,74 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/wangjia/jiu/backend/config"
)
type Claims struct {
UserID uint64 `json:"user_id"`
HotelID uint64 `json:"hotel_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
const (
CtxUserID = "user_id"
CtxHotelID = "hotel_id"
CtxRole = "role"
)
func JWT() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(config.C.JWT.Secret), nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxHotelID, claims.HotelID)
c.Set(CtxRole, claims.Role)
c.Next()
}
}
// AdminOnly 仅管理员可访问
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(CtxRole)
if role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
c.Next()
}
}
// GetHotelID 从 context 中安全获取 hotel_id
func GetHotelID(c *gin.Context) uint64 {
v, _ := c.Get(CtxHotelID)
id, _ := v.(uint64)
return id
}
// GetUserID 从 context 中安全获取 user_id
func GetUserID(c *gin.Context) uint64 {
v, _ := c.Get(CtxUserID)
id, _ := v.(uint64)
return id
}
+50
View File
@@ -0,0 +1,50 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// JSON 类型,用于 custom_fields
type JSON map[string]interface{}
func (j JSON) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
b, err := json.Marshal(j)
return string(b), err
}
func (j *JSON) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
var bytes []byte
switch v := value.(type) {
case string:
bytes = []byte(v)
case []byte:
bytes = v
default:
return fmt.Errorf("cannot scan type %T into JSON", value)
}
return json.Unmarshal(bytes, j)
}
// Base 公共字段
type Base struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `gorm:"index" json:"-"`
}
// TenantBase 含租户隔离的公共字段
type TenantBase struct {
Base
HotelID uint64 `gorm:"not null;index" json:"hotel_id"`
}
+30
View File
@@ -0,0 +1,30 @@
package model
import "time"
type FinanceRecord struct {
Base
HotelID uint64 `gorm:"not null;index" json:"hotel_id"`
PartnerID *uint64 `json:"partner_id"`
Type string `gorm:"type:enum('receivable','payable','receipt','payment')" json:"type"`
Amount float64 `gorm:"type:decimal(14,2)" json:"amount"`
Balance float64 `gorm:"type:decimal(14,2)" json:"balance"`
RefType string `gorm:"size:30" json:"ref_type"`
RefID *uint64 `json:"ref_id"`
OperatorID uint64 `gorm:"not null" json:"operator_id"`
RecordDate time.Time `gorm:"type:date" json:"record_date"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"`
}
type NumberRule struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
HotelID uint64 `gorm:"not null;uniqueIndex:uk_hotel_type" json:"hotel_id"`
Type string `gorm:"size:30;uniqueIndex:uk_hotel_type" json:"type"`
Prefix string `gorm:"size:20;default:''" json:"prefix"`
CurrentNo int `gorm:"default:0" json:"current_no"`
DateFormat string `gorm:"size:20;default:'YYYYMMDD'" json:"date_format"`
UpdatedAt time.Time `json:"updated_at"`
}
+10
View File
@@ -0,0 +1,10 @@
package model
type Hotel struct {
Base
Name string `gorm:"size:100;not null" json:"name"`
Code string `gorm:"size:50;uniqueIndex" json:"code"`
Address string `gorm:"size:255" json:"address"`
Phone string `gorm:"size:30" json:"phone"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
}
+15
View File
@@ -0,0 +1,15 @@
package model
import "time"
type License struct {
Base
HotelID uint64 `gorm:"not null;index" json:"hotel_id"`
LicenseKey string `gorm:"size:255;uniqueIndex" json:"license_key"`
DeviceID string `gorm:"size:255" json:"device_id"`
Type string `gorm:"type:enum('trial','annual','lifetime');default:'trial'" json:"type"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive bool `gorm:"default:true" json:"is_active"`
Features JSON `gorm:"type:json" json:"features,omitempty"`
ActivatedAt *time.Time `json:"activated_at"`
}
+16
View File
@@ -0,0 +1,16 @@
package model
type Partner struct {
TenantBase
Code string `gorm:"size:50" json:"code"`
Name string `gorm:"size:200;not null" json:"name"`
Type string `gorm:"type:set('supplier','customer');default:'supplier'" json:"type"`
Contact string `gorm:"size:50" json:"contact"`
Phone string `gorm:"size:30" json:"phone"`
Address string `gorm:"size:255" json:"address"`
BankAccount string `gorm:"size:100" json:"bank_account"`
CreditLimit float64 `gorm:"type:decimal(12,2)" json:"credit_limit"`
Balance float64 `gorm:"type:decimal(12,2);default:0" json:"balance"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
}
+27
View File
@@ -0,0 +1,27 @@
package model
type ProductCategory struct {
TenantBase
Name string `gorm:"size:100;not null" json:"name"`
ParentID *uint64 `json:"parent_id"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
type Product struct {
TenantBase
Code string `gorm:"size:50" json:"code"`
Barcode string `gorm:"size:100" json:"barcode"`
Name string `gorm:"size:200;not null" json:"name"`
Series string `gorm:"size:100" json:"series"`
Spec string `gorm:"size:100" json:"spec"`
Unit string `gorm:"size:20" json:"unit"`
CategoryID *uint64 `json:"category_id"`
Brand string `gorm:"size:100" json:"brand"`
PurchasePrice float64 `gorm:"type:decimal(12,2)" json:"purchase_price"`
SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"`
MinStock int `gorm:"default:0" json:"min_stock"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
}
+136
View File
@@ -0,0 +1,136 @@
package model
import "time"
// -------- 入库单 --------
type StockInOrder struct {
TenantBase
OrderNo string `gorm:"size:50;uniqueIndex:uk_hotel_order_no" json:"order_no"`
Type string `gorm:"size:30;default:'purchase'" json:"type"`
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
PartnerID *uint64 `json:"partner_id"`
OperatorID uint64 `gorm:"not null" json:"operator_id"`
ReviewerID *uint64 `json:"reviewer_id"`
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
OrderDate time.Time `gorm:"type:date" json:"order_date"`
TotalAmount float64 `gorm:"type:decimal(14,2);default:0" json:"total_amount"`
ReviewedAt *time.Time `json:"reviewed_at"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
Items []StockInItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
}
type StockInItem struct {
Base
OrderID uint64 `gorm:"not null;index" json:"order_id"`
HotelID uint64 `gorm:"not null" json:"hotel_id"`
ProductID uint64 `gorm:"not null" json:"product_id"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
UnitPrice float64 `gorm:"type:decimal(12,2);default:0" json:"unit_price"`
TotalPrice float64 `gorm:"type:decimal(14,2);default:0" json:"total_price"`
BatchNo string `gorm:"size:50" json:"batch_no"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:255" json:"remark"`
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
}
// -------- 出库单 --------
type StockOutOrder struct {
TenantBase
OrderNo string `gorm:"size:50;uniqueIndex:uk_hotel_order_no" json:"order_no"`
Type string `gorm:"size:30;default:'sale'" json:"type"`
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
PartnerID *uint64 `json:"partner_id"`
OperatorID uint64 `gorm:"not null" json:"operator_id"`
ReviewerID *uint64 `json:"reviewer_id"`
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
OrderDate time.Time `gorm:"type:date" json:"order_date"`
TotalAmount float64 `gorm:"type:decimal(14,2);default:0" json:"total_amount"`
ReviewedAt *time.Time `json:"reviewed_at"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
Items []StockOutItem `gorm:"foreignKey:OrderID" json:"items,omitempty"`
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
}
type StockOutItem struct {
Base
OrderID uint64 `gorm:"not null;index" json:"order_id"`
HotelID uint64 `gorm:"not null" json:"hotel_id"`
ProductID uint64 `gorm:"not null" json:"product_id"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
UnitPrice float64 `gorm:"type:decimal(12,2);default:0" json:"unit_price"`
TotalPrice float64 `gorm:"type:decimal(14,2);default:0" json:"total_price"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:255" json:"remark"`
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
}
// -------- 实时库存 --------
type Inventory struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
HotelID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"hotel_id"`
WarehouseID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"warehouse_id"`
ProductID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"product_id"`
Quantity float64 `gorm:"type:decimal(12,3);default:0" json:"quantity"`
UpdatedAt time.Time `json:"updated_at"`
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
}
// -------- 库存流水 --------
type InventoryLog struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
HotelID uint64 `gorm:"not null;index:idx_hotel_product" json:"hotel_id"`
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
ProductID uint64 `gorm:"not null;index:idx_hotel_product" json:"product_id"`
Direction string `gorm:"type:enum('in','out')" json:"direction"`
Quantity float64 `gorm:"type:decimal(12,3)" json:"quantity"`
QtyBefore float64 `gorm:"type:decimal(12,3)" json:"qty_before"`
QtyAfter float64 `gorm:"type:decimal(12,3)" json:"qty_after"`
RefType string `gorm:"size:30" json:"ref_type"`
RefID uint64 `json:"ref_id"`
OperatorID *uint64 `json:"operator_id"`
CreatedAt time.Time `json:"created_at"`
}
// -------- 库存盘点 --------
type InventoryCheck struct {
TenantBase
CheckNo string `gorm:"size:50;uniqueIndex:uk_hotel_check_no" json:"check_no"`
WarehouseID uint64 `gorm:"not null" json:"warehouse_id"`
OperatorID uint64 `gorm:"not null" json:"operator_id"`
Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"`
CheckDate time.Time `gorm:"type:date" json:"check_date"`
Remark string `gorm:"size:500" json:"remark"`
Items []InventoryCheckItem `gorm:"foreignKey:CheckID" json:"items,omitempty"`
}
type InventoryCheckItem struct {
Base
CheckID uint64 `gorm:"not null;index" json:"check_id"`
HotelID uint64 `gorm:"not null" json:"hotel_id"`
ProductID uint64 `gorm:"not null" json:"product_id"`
SystemQty float64 `gorm:"type:decimal(12,3)" json:"system_qty"`
ActualQty float64 `gorm:"type:decimal(12,3)" json:"actual_qty"`
DiffQty float64 `gorm:"->" json:"diff_qty"` // generated column,只读
Remark string `gorm:"size:255" json:"remark"`
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
}
+21
View File
@@ -0,0 +1,21 @@
package model
type User struct {
TenantBase
Username string `gorm:"size:50;uniqueIndex:uk_hotel_username" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
RealName string `gorm:"size:50" json:"real_name"`
Phone string `gorm:"size:30" json:"phone"`
Role string `gorm:"type:enum('admin','operator');default:'operator'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
}
type OAuthProvider struct {
Base
UserID uint64 `gorm:"not null;index" json:"user_id"`
Provider string `gorm:"type:enum('wechat','google','apple')" json:"provider"`
ProviderUserID string `gorm:"size:255" json:"provider_user_id"`
AccessToken string `gorm:"type:text" json:"-"`
RefreshToken string `gorm:"type:text" json:"-"`
}
+9
View File
@@ -0,0 +1,9 @@
package model
type Warehouse struct {
TenantBase
Name string `gorm:"size:100;not null" json:"name"`
Location string `gorm:"size:200" json:"location"`
IsDefault bool `gorm:"default:false" json:"is_default"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
}
+115
View File
@@ -0,0 +1,115 @@
package router
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/handler"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
)
func Setup(r *gin.Engine, db *gorm.DB) {
// 服务层
authSvc := service.NewAuthService(db)
licenseSvc := service.NewLicenseService(db)
stockSvc := service.NewStockService(db)
// 处理器
authH := handler.NewAuthHandler(authSvc)
licenseH := handler.NewLicenseHandler(licenseSvc)
productH := handler.NewProductHandler(db)
warehouseH := handler.NewWarehouseHandler(db)
partnerH := handler.NewPartnerHandler(db)
stockInH := handler.NewStockInHandler(db, stockSvc)
stockOutH := handler.NewStockOutHandler(db, stockSvc)
inventoryH := handler.NewInventoryHandler(db)
importH := handler.NewImportHandler(db)
v1 := r.Group("/api/v1")
// 公开路由(无需登录)
auth := v1.Group("/auth")
{
auth.POST("/login", authH.Login)
auth.POST("/refresh", authH.Refresh)
}
// 需要 JWT 的路由
api := v1.Group("")
api.Use(middleware.JWT())
{
// 许可证
license := api.Group("/license")
{
license.POST("/activate", licenseH.Activate)
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
}
// 商品
products := api.Group("/products")
{
products.GET("", productH.List)
products.POST("", productH.Create)
products.PUT("/:id", productH.Update)
products.DELETE("/:id", productH.Delete)
}
// 仓库
warehouses := api.Group("/warehouses")
{
warehouses.GET("", warehouseH.List)
warehouses.POST("", warehouseH.Create)
warehouses.PUT("/:id", warehouseH.Update)
warehouses.DELETE("/:id", warehouseH.Delete)
}
// 往来单位
partners := api.Group("/partners")
{
partners.GET("", partnerH.List)
partners.POST("", partnerH.Create)
partners.PUT("/:id", partnerH.Update)
partners.DELETE("/:id", partnerH.Delete)
}
// 入库
stockIn := api.Group("/stock-in")
{
stockIn.GET("/orders", stockInH.List)
stockIn.GET("/orders/:id", stockInH.Get)
stockIn.POST("/orders", stockInH.Create)
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
}
// 出库
stockOut := api.Group("/stock-out")
{
stockOut.GET("/orders", stockOutH.List)
stockOut.GET("/orders/:id", stockOutH.Get)
stockOut.POST("/orders", stockOutH.Create)
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
}
// 库存
inventory := api.Group("/inventory")
{
inventory.GET("", inventoryH.List)
inventory.GET("/logs", inventoryH.Logs)
inventory.POST("/checks", inventoryH.CreateCheck)
inventory.GET("/checks/:id", inventoryH.GetCheck)
}
// 导入
imp := api.Group("/import")
{
imp.POST("/products", importH.ImportProducts)
imp.POST("/partners", importH.ImportPartners)
}
}
}
+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
}