Compare commits

...

6 Commits

Author SHA1 Message Date
wangjia 85cd262525 chore: release v1.0.32
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-windows (push) Successful in 1m51s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m28s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Failing after 2m58s
2026-06-11 18:09:49 +08:00
wangjia e0bee00ff4 fix: 批量改进 (#2/#23/#38/#39/#40/#47)
- Web: 隐藏 ICP 备案号(条件渲染),联系入口改为微信咨询(条件渲染,配置 wechat 字段后生效)
- backend: 拼音回填抽取为独立 cmd/backfill-pinyin 工具,不再在启动时运行
- backend: DB 连接池参数(max_idle_conns/max_open_conns)改为配置可控,默认 10/100
- backend: Inventory 反范式字段添加注释说明快照语义
- backend: 全量 handler 采用 util.RespondSuccess/RespondCreated 统一响应格式(51 处)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:33:43 +08:00
wangjia d28aba863e fix(backend): license service 小修 (#45-46-48)
- #45 Activate 幂等路径 Update 加错误检查,失败时返回 error 而非静默成功
- #46 Verify 加载 license 加 shop_id 过滤(防御纵深)
- #48 删除 ErrDeviceMismatch 死代码(多设备迁移后已无单设备约束)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:06:15 +08:00
wangjia f928d5c6a3 fix(backend): 架构质量改进批次三 (#41-44)
- #41 CheckInventoryAvailability Scan 加错误检查,DB 故障不再误报库存不足
- #42 finance ListRecords 和 inventory Logs 补加 pageSize 上限(500)
- #43 ReconcileInventory 两条 Scan 加错误检查,DB 故障返回 500 而非误导性空结果
- #44 license Info 改用 CountDevices(licenseID) 统计当前 license 绑定数,修复跨 license 计数偏差

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:21:42 +08:00
wangjia 3ab78dbf7a fix(backend): 架构质量改进批次二 (#32-37)
- #32 License 激活迁移到 license_devices 表:Activate/Verify/Deactivate 全部改用
  license_devices,新增 max_devices 校验和 GET /license/devices 端点;
  Activate 现在校验 shop_id 防跨租户激活
- #33 checkInventory 从 StockOutHandler 移到 StockService.CheckInventoryAvailability
- #34 新增 util/response.go 统一错误响应工具(RespondError/RespondSuccess/RespondCreated)
- #35 生产模式 CORS Origin='*' 启动时 Fatal
- #36 生产模式 License 私钥未配置启动时 Fatal
- #37 新增 util/page.go ValidatePageSize,应用到 partner/product/stock_in/stock_out handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:38:49 +08:00
wangjia 51cfe5fc6d fix(backend): 修复库存 TOCTOU 竞态 + handler 白名单更新 + 对账接口
#29 ApproveStockOut:FOR UPDATE 锁定批次后再内存汇总总量,
     消除预检 SUM 与加锁之间的竞态窗口,减少一次 DB 往返

#30 partner/warehouse/user/product_attr/product_option Update 方法:
     - 绑定到独立 req struct,防止请求体覆盖记录 ID
     - 改用 db.Model.Where("shop_id=?").Updates(map) 白名单更新,
       数据库层强制 shop_id 隔离约束

#31 新增 GET /api/v1/admin/reconcile:对比 inventories 当前库存
     与 inventory_logs 流水净量,返回差异行,用于发现不平账异常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:21:48 +08:00
32 changed files with 910 additions and 572 deletions
+14
View File
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.32] - 2026-06-11
### 修复
- 修复库存可用量检查在数据库查询失败时误报所有商品库存不足的问题
- 修复授权信息页面设备数量统计不准确的问题
- 修复对账接口在数据库查询出错时静默返回空结果、掩盖异常的问题
- 修复 license 验证缺少门店隔离过滤,提升多租户安全性
- 官网联系入口调整为微信咨询(填写微信号后自动生效),隐藏尚未申请的备案号
- 修复各接口分页大小未设上限,防止超大请求拖慢服务
### 改进
- 后端启动不再执行拼音回填,已改为独立工具(`cmd/backfill-pinyin`),避免大库存量时延长启动时间
- 数据库连接池参数(最大空闲连接数、最大打开连接数)改为配置项,支持按环境调整
## [1.0.31] - 2026-06-10
### 修复
+57
View File
@@ -0,0 +1,57 @@
// backfill-pinyin is a one-time migration tool that fills name_pinyin and
// name_initials for products that have empty values.
// Usage: go run ./cmd/backfill-pinyin
package main
import (
"log"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/spf13/viper"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file, relying on env vars")
}
dsn := viper.GetString("database.dsn")
if dsn == "" {
log.Fatal("DATABASE_DSN is required")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
var products []model.Product
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
if len(products) == 0 {
log.Println("No products to backfill.")
return
}
for i := range products {
full, initials := util.ToPinyin(products[i].Name)
db.Model(&products[i]).Updates(map[string]interface{}{
"name_pinyin": full,
"name_initials": initials,
})
}
log.Printf("backfill-pinyin: updated %d products", len(products))
}
+5 -1
View File
@@ -22,7 +22,9 @@ type ServerConfig struct {
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
DSN string `mapstructure:"dsn"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
}
type JWTConfig struct {
@@ -71,6 +73,8 @@ func Load() {
viper.SetDefault("server.cors_origin", "*")
viper.SetDefault("jwt.access_expire_min", 60)
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("storage.upload_dir", "./uploads/images")
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081")
+95
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -58,3 +59,97 @@ func (h *AdminHandler) ClearData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"cleared": cleared})
}
// ReconcileInventory 对账:检查 inventories 表当前库存与 inventory_logs 流水之和是否吻合。
// 返回所有差异行,正常情况下 data 为空数组。
// 支持可选 ?shop_id=N 参数(superadmin 可指定任意门店)。
func (h *AdminHandler) ReconcileInventory(c *gin.Context) {
// superadmin 路由,可通过参数指定 shop;不指定则对所有门店执行
var shopFilter *uint64
if s := c.Query("shop_id"); s != "" {
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid shop_id"})
return
}
shopFilter = &v
}
_ = middleware.GetShopID // superadmin 不依赖 JWT shop_id
type row struct {
ShopID uint64 `json:"shop_id"`
WarehouseID uint64 `json:"warehouse_id"`
ProductID uint64 `json:"product_id"`
InvQty float64 `json:"inv_qty"`
LogQty float64 `json:"log_qty"`
Diff float64 `json:"diff"`
}
// inventories 侧:当前各(shop,warehouse,product)库存总量
invQuery := h.db.Table("inventories").
Select("shop_id, warehouse_id, product_id, COALESCE(SUM(quantity),0) AS inv_qty").
Where("deleted_at IS NULL").
Group("shop_id, warehouse_id, product_id")
if shopFilter != nil {
invQuery = invQuery.Where("shop_id = ?", *shopFilter)
}
// inventory_logs 侧:同维度流水净量(in 为正,out 为负)
logQuery := h.db.Table("inventory_logs").
Select("shop_id, warehouse_id, product_id, " +
"COALESCE(SUM(CASE WHEN direction='in' THEN quantity ELSE -quantity END),0) AS log_qty").
Group("shop_id, warehouse_id, product_id")
if shopFilter != nil {
logQuery = logQuery.Where("shop_id = ?", *shopFilter)
}
// 全外连接找差异
type invRow struct {
ShopID uint64 `gorm:"column:shop_id"`
WarehouseID uint64 `gorm:"column:warehouse_id"`
ProductID uint64 `gorm:"column:product_id"`
InvQty float64 `gorm:"column:inv_qty"`
}
type logRow struct {
ShopID uint64 `gorm:"column:shop_id"`
WarehouseID uint64 `gorm:"column:warehouse_id"`
ProductID uint64 `gorm:"column:product_id"`
LogQty float64 `gorm:"column:log_qty"`
}
var invRows []invRow
var logRows []logRow
if err := invQuery.Scan(&invRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "inventory query failed: " + err.Error()})
return
}
if err := logQuery.Scan(&logRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "log query failed: " + err.Error()})
return
}
// 合并为 map 再求差
type key struct{ S, W, P uint64 }
m := map[key]*row{}
for _, r := range invRows {
k := key{r.ShopID, r.WarehouseID, r.ProductID}
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID, InvQty: r.InvQty}
}
for _, r := range logRows {
k := key{r.ShopID, r.WarehouseID, r.ProductID}
if _, ok := m[k]; !ok {
m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID}
}
m[k].LogQty = r.LogQty
}
diffs := make([]row, 0)
for _, v := range m {
v.Diff = v.InvQty - v.LogQty
if v.Diff < -0.001 || v.Diff > 0.001 {
diffs = append(diffs, *v)
}
}
c.JSON(http.StatusOK, gin.H{"data": diffs, "total": len(diffs)})
}
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type AuthHandler struct {
@@ -63,7 +64,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
util.RespondSuccess(c, result)
}
// Refresh POST /api/v1/auth/refresh
@@ -82,5 +83,5 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"data": pair})
util.RespondSuccess(c, pair)
}
+3 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type FinanceHandler struct {
@@ -33,9 +34,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if q.PageSize <= 0 {
q.PageSize = 50
}
q.PageSize = util.ValidatePageSize(q.PageSize, 50, 500)
if q.Page <= 0 {
q.Page = 1
}
@@ -189,7 +188,7 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
ORDER BY total_amount DESC
`, shopID).Scan(&rows)
c.JSON(http.StatusOK, gin.H{"data": rows})
util.RespondSuccess(c, rows)
}
// partnerLastBalance 查询该往来单位最后一条财务记录的余额
+4 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type InventoryHandler struct {
@@ -128,6 +129,7 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 500)
query := h.db.Model(&model.InventoryLog{}).Where("shop_id = ?", shopID)
@@ -176,7 +178,7 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// GetCheck GET /api/v1/inventory/checks/:id
@@ -189,7 +191,7 @@ func (h *InventoryHandler) GetCheck(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": check})
util.RespondSuccess(c, check)
}
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
+34 -11
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type LicenseHandler struct {
@@ -18,21 +19,24 @@ func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler {
// Activate POST /api/v1/license/activate
func (h *LicenseHandler) Activate(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
LicenseKey string `json:"license_key" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
}
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)
lic, err := h.svc.Activate(shopID, req.LicenseKey, req.DeviceID, req.DeviceName, req.Platform)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
util.RespondSuccess(c, lic)
}
// Verify GET /api/v1/license/verify
@@ -49,28 +53,47 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
util.RespondSuccess(c, lic)
}
// Info GET /api/v1/license/info — 当前门店授权概况(无需 device_id
// Info GET /api/v1/license/info — 当前门店授权概况
func (h *LicenseHandler) Info(c *gin.Context) {
shopID := middleware.GetShopID(c)
lic, err := h.svc.ShopInfo(shopID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": nil})
util.RespondSuccess(c, nil)
return
}
deviceCount, err := h.svc.CountDevices(lic.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
phase := middleware.CalcLicensePhase(lic.ExpiresAt)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"id": lic.ID,
"type": lic.Type,
"is_active": lic.IsActive,
"max_devices": lic.MaxDevices,
"expires_at": lic.ExpiresAt,
"phase": phase,
"id": lic.ID,
"type": lic.Type,
"is_active": lic.IsActive,
"max_devices": lic.MaxDevices,
"device_count": deviceCount,
"expires_at": lic.ExpiresAt,
"phase": phase,
}})
}
// Devices GET /api/v1/license/devices — 已绑定设备列表
func (h *LicenseHandler) Devices(c *gin.Context) {
shopID := middleware.GetShopID(c)
devs, err := h.svc.ListDevices(shopID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, devs)
}
// Deactivate POST /api/v1/license/deactivate
func (h *LicenseHandler) Deactivate(c *gin.Context) {
shopID := middleware.GetShopID(c)
+32 -28
View File
@@ -25,16 +25,22 @@ func TestLicenseHandler_Activate_Success(t *testing.T) {
LicenseKey: "LHACT-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHACT-BBBBB-CCCCC-DDDDD",
"device_id": "device-123",
"device_name": "Test Machine",
"platform": "windows",
})
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "device-123", data["device_id"])
// Verify device was recorded in license_devices
var dev model.LicenseDevice
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-123").First(&dev).Error)
assert.Equal(t, "Test Machine", dev.DeviceName)
}
func TestLicenseHandler_Activate_MissingFields(t *testing.T) {
@@ -71,7 +77,7 @@ func TestLicenseHandler_Activate_NotFound(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) {
func TestLicenseHandler_Activate_DeviceLimitExceeded(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LH004")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
@@ -79,12 +85,13 @@ func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) {
r := setupProtectedRouter(db)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD",
DeviceID: "existing-device",
IsActive: true,
ShopID: shop.ID, LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 1,
}
require.NoError(t, db.Create(lic).Error)
// Fill the single allowed slot
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "existing-device",
}).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHBND-BBBBB-CCCCC-DDDDD",
@@ -102,10 +109,7 @@ func TestLicenseHandler_Activate_Expired(t *testing.T) {
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
@@ -136,18 +140,15 @@ func TestLicenseHandler_Verify_Success(t *testing.T) {
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD",
DeviceID: "my-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device",
}).Error)
w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=my-device", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "my-device", data["device_id"])
}
func TestLicenseHandler_Verify_MissingDeviceID(t *testing.T) {
@@ -181,13 +182,12 @@ func TestLicenseHandler_Verify_Expired(t *testing.T) {
expiry := time.Now().Add(-1 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD",
DeviceID: "expired-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device",
}).Error)
w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=expired-device", token, nil)
assert.Equal(t, http.StatusForbidden, w.Code)
@@ -210,18 +210,22 @@ func TestLicenseHandler_Deactivate_Success(t *testing.T) {
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD",
DeviceID: "deactivate-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "deactivate-device",
}).Error)
w := makeRequest(r, "POST", "/api/v1/license/deactivate", token, map[string]interface{}{
"device_id": "deactivate-device",
})
assert.Equal(t, http.StatusOK, w.Code)
// Verify device was removed
var count int64
db.Model(&model.LicenseDevice{}).Where("shop_id = ? AND device_id = ?", shop.ID, "deactivate-device").Count(&count)
assert.Equal(t, int64(0), count)
}
func TestLicenseHandler_Deactivate_MissingDeviceID(t *testing.T) {
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type NumberRuleHandler struct {
@@ -43,7 +44,7 @@ func (h *NumberRuleHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": rules})
util.RespondSuccess(c, rules)
}
// Update PUT /api/v1/number-rules/:id
@@ -82,5 +83,5 @@ func (h *NumberRuleHandler) Update(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": rule})
util.RespondSuccess(c, rule)
}
+33 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type PartnerHandler struct {
@@ -24,6 +25,7 @@ func (h *PartnerHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.Partner{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -68,7 +70,7 @@ func (h *PartnerHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": p})
util.RespondCreated(c, p)
}
func (h *PartnerHandler) Update(c *gin.Context) {
@@ -79,13 +81,39 @@ func (h *PartnerHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(&p); err != nil {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
Code string `json:"code"`
Contact string `json:"contact"`
Phone string `json:"phone"`
Address string `json:"address"`
BankAccount string `json:"bank_account"`
CreditLimit float64 `json:"credit_limit"`
Status string `json:"status"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.ShopID = shopID
h.db.Save(&p)
c.JSON(http.StatusOK, gin.H{"data": p})
if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"name": req.Name,
"type": req.Type,
"code": req.Code,
"contact": req.Contact,
"phone": req.Phone,
"address": req.Address,
"bank_account": req.BankAccount,
"credit_limit": req.CreditLimit,
"status": req.Status,
"remark": req.Remark,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.db.Where("id = ? AND shop_id = ?", p.ID, shopID).First(&p)
util.RespondSuccess(c, p)
}
func (h *PartnerHandler) Delete(c *gin.Context) {
+7 -6
View File
@@ -31,6 +31,7 @@ func (h *ProductHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
keyword := c.Query("keyword")
categoryID := c.Query("category_id")
@@ -103,7 +104,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
util.RespondCreated(c, product)
}
// Update PUT /api/v1/products/:id
@@ -154,7 +155,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
// 重新读取完整数据返回
h.db.Preload("Category").First(&product, product.ID)
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
}
// Detail GET /api/v1/products/:id/detail
@@ -177,7 +178,7 @@ func (h *ProductHandler) Detail(c *gin.Context) {
h.db.Model(&product).Update("public_id", product.PublicID)
}
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
}
// QRCode GET /api/v1/products/:id/qrcode
@@ -247,7 +248,7 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
if len(attrUpdates) > 0 {
h.db.Model(&product).Updates(attrUpdates)
}
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -276,13 +277,13 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
// Race condition: try to find the record created by another request
if h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, req.Name, req.Series, req.Spec).First(&product).Error == nil {
c.JSON(http.StatusOK, gin.H{"data": product})
util.RespondSuccess(c, product)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
util.RespondCreated(c, product)
}
// Delete DELETE /api/v1/products/:id (软删除)
+25 -28
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
// ProductAttrHandler 处理商品属性字典(产地/保质期/储存方式/描述文档)
@@ -25,7 +26,7 @@ func (h *ProductAttrHandler) ListOrigins(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductOriginOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
@@ -49,7 +50,7 @@ func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
@@ -68,11 +69,10 @@ func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteOrigin(c *gin.Context) {
@@ -87,7 +87,7 @@ func (h *ProductAttrHandler) ListShelfLives(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductShelfLifeOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
@@ -111,7 +111,7 @@ func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
@@ -130,11 +130,10 @@ func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteShelfLife(c *gin.Context) {
@@ -149,7 +148,7 @@ func (h *ProductAttrHandler) ListStorages(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductStorageOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
@@ -173,7 +172,7 @@ func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
@@ -192,11 +191,10 @@ func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteStorage(c *gin.Context) {
@@ -211,7 +209,7 @@ func (h *ProductAttrHandler) ListDescriptionDocs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductDescriptionDoc, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
@@ -235,7 +233,7 @@ func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
@@ -254,11 +252,10 @@ func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Title = req.Title
item.Content = req.Content
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"title": req.Title, "content": req.Content, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductAttrHandler) DeleteDescriptionDoc(c *gin.Context) {
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductImageHandler struct {
@@ -105,7 +106,7 @@ func (h *ProductImageHandler) Upload(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, gin.H{"data": pi})
util.RespondCreated(c, pi)
}
// Delete DELETE /api/v1/products/:id/images/:image_id
+19 -22
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type ProductOptionHandler struct {
@@ -24,7 +25,7 @@ func (h *ProductOptionHandler) ListNames(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductNameOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateName(c *gin.Context) {
@@ -48,7 +49,7 @@ func (h *ProductOptionHandler) CreateName(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
@@ -67,11 +68,10 @@ func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
@@ -86,7 +86,7 @@ func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSeriesOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
@@ -110,7 +110,7 @@ func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
@@ -129,11 +129,10 @@ func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
@@ -148,7 +147,7 @@ func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductSpecOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
util.RespondSuccess(c, items)
}
func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
@@ -174,7 +173,7 @@ func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
util.RespondCreated(c, item)
}
func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
@@ -194,12 +193,10 @@ func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Quantity = req.Quantity
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"code": req.Code, "name": req.Name, "quantity": req.Quantity, "remark": req.Remark,
})
util.RespondSuccess(c, item)
}
func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) {
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
func timeNow() *time.Time {
@@ -33,6 +34,7 @@ func (h *StockInHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.StockInOrder{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -70,7 +72,7 @@ func (h *StockInHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
util.RespondSuccess(c, order)
}
// Create POST /api/v1/stock-in/orders
@@ -112,7 +114,7 @@ func (h *StockInHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态)
+6 -50
View File
@@ -1,7 +1,6 @@
package handler
import (
"fmt"
"net/http"
"strconv"
@@ -11,6 +10,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type StockOutHandler struct {
@@ -27,6 +27,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 200)
query := h.db.Model(&model.StockOutOrder{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID)
@@ -62,54 +63,9 @@ func (h *StockOutHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": order})
util.RespondSuccess(c, order)
}
// checkInventory validates that warehouse has enough stock for each item (SUM aggregate).
// warehouseID is the order's warehouse; items are the stock-out line items.
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
if len(items) == 0 {
return nil
}
// Collect product IDs
productIDs := make([]uint64, 0, len(items))
for _, item := range items {
productIDs = append(productIDs, item.ProductID)
}
type inventorySum struct {
ProductID uint64
Total float64
}
var sums []inventorySum
h.db.Model(&model.Inventory{}).
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
shopID, warehouseID, productIDs).
Group("product_id").Scan(&sums)
// Build map for quick lookup
sumMap := make(map[uint64]float64, len(sums))
for _, s := range sums {
sumMap[s.ProductID] = s.Total
}
for _, item := range items {
have := sumMap[item.ProductID]
if have < item.Quantity {
// Get product name for a clearer error message
var p model.Product
h.db.Where("id = ?", item.ProductID).First(&p)
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}
return nil
}
// Create POST /api/v1/stock-out/orders
func (h *StockOutHandler) Create(c *gin.Context) {
@@ -127,7 +83,7 @@ func (h *StockOutHandler) Create(c *gin.Context) {
// 状态只允许 draft 或 pending;直接提交审核时校验库存
if req.Status == "pending" {
if err := h.checkInventory(shopID, req.WarehouseID, req.Items); err != nil {
if err := h.stockSvc.CheckInventoryAvailability(shopID, req.WarehouseID, req.Items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -154,7 +110,7 @@ func (h *StockOutHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": req})
util.RespondCreated(c, req)
}
// Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态)
@@ -235,7 +191,7 @@ func (h *StockOutHandler) Submit(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"})
return
}
if err := h.checkInventory(shopID, order.WarehouseID, order.Items); err != nil {
if err := h.stockSvc.CheckInventoryAvailability(shopID, order.WarehouseID, order.Items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+13 -8
View File
@@ -9,6 +9,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type UserHandler struct {
@@ -25,7 +26,7 @@ func (h *UserHandler) List(c *gin.Context) {
users := make([]model.User, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
Order("id ASC").Find(&users)
c.JSON(http.StatusOK, gin.H{"data": users})
util.RespondSuccess(c, users)
}
// Create POST /api/v1/users
@@ -64,7 +65,7 @@ func (h *UserHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
return
}
c.JSON(http.StatusCreated, gin.H{"data": u})
util.RespondCreated(c, u)
}
// Update PUT /api/v1/users/:id
@@ -83,20 +84,24 @@ func (h *UserHandler) Update(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.RealName != "" {
u.RealName = req.RealName
updates["real_name"] = req.RealName
}
if req.Phone != "" {
u.Phone = req.Phone
updates["phone"] = req.Phone
}
if req.Role != "" {
u.Role = req.Role
updates["role"] = req.Role
}
if req.IsActive != nil {
u.IsActive = *req.IsActive
updates["is_active"] = *req.IsActive
}
h.db.Save(&u)
c.JSON(http.StatusOK, gin.H{"data": u})
if len(updates) > 0 {
h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates)
}
h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u)
util.RespondSuccess(c, u)
}
// ResetPassword PUT /api/v1/users/:id/reset-password
+22 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type WarehouseHandler struct {
@@ -22,7 +23,7 @@ func (h *WarehouseHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
warehouses := make([]model.Warehouse, 0)
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
c.JSON(http.StatusOK, gin.H{"data": warehouses})
util.RespondSuccess(c, warehouses)
}
func (h *WarehouseHandler) Create(c *gin.Context) {
@@ -34,7 +35,7 @@ func (h *WarehouseHandler) Create(c *gin.Context) {
}
w.ShopID = shopID
h.db.Create(&w)
c.JSON(http.StatusCreated, gin.H{"data": w})
util.RespondCreated(c, w)
}
func (h *WarehouseHandler) Update(c *gin.Context) {
@@ -45,10 +46,25 @@ func (h *WarehouseHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.ShouldBindJSON(&w)
w.ShopID = shopID
h.db.Save(&w)
c.JSON(http.StatusOK, gin.H{"data": w})
var req struct {
Name string `json:"name"`
Location string `json:"location"`
IsDefault bool `json:"is_default"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&w).Where("shop_id = ?", shopID).Updates(map[string]interface{}{
"name": req.Name,
"location": req.Location,
"is_default": req.IsDefault,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.db.Where("id = ? AND shop_id = ?", w.ID, shopID).First(&w)
util.RespondSuccess(c, w)
}
func (h *WarehouseHandler) Delete(c *gin.Context) {
+13 -10
View File
@@ -91,16 +91,19 @@ type Inventory struct {
StockInItemID *uint64 `json:"stock_in_item_id"`
InventoryCheckID *uint64 `json:"inventory_check_id"`
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
ProductCode string `gorm:"size:50" json:"product_code"`
ProductName string `gorm:"size:200" json:"product_name"`
Series string `gorm:"size:100" json:"series"`
Spec string `gorm:"size:100" json:"spec"`
Unit string `gorm:"size:20" json:"unit"`
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
BatchNo string `gorm:"size:50" json:"batch_no"`
SupplierName string `gorm:"size:200" json:"supplier_name"`
// Snapshot fields: copied from product/warehouse/stock-in at approval time.
// They reflect the state at the moment of stock-in and are NOT updated when the
// referenced product or warehouse record is later modified.
ProductCode string `gorm:"size:50" json:"product_code"`
ProductName string `gorm:"size:200" json:"product_name"`
Series string `gorm:"size:100" json:"series"`
Spec string `gorm:"size:100" json:"spec"`
Unit string `gorm:"size:20" json:"unit"`
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
ProductionDate *Date `gorm:"type:date" json:"production_date"`
BatchNo string `gorm:"size:50" json:"batch_no"`
SupplierName string `gorm:"size:200" json:"supplier_name"`
Remark string `gorm:"size:500" json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+2
View File
@@ -76,6 +76,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
license.POST("/activate", licenseH.Activate)
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
license.GET("/devices", licenseH.Devices)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
@@ -252,6 +253,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
superAdmin.Use(middleware.SuperAdminOnly())
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/reconcile", adminH.ReconcileInventory)
superAdmin.GET("/errors", errorReportH.List)
superAdmin.GET("/feedback", feedbackH.List)
superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus)
+61 -21
View File
@@ -18,10 +18,10 @@ import (
)
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")
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseInactive = errors.New("license is inactive")
ErrLicenseExpired = errors.New("license has expired")
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
)
type LicenseService struct {
@@ -47,10 +47,12 @@ func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string
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) {
// Activate 激活许可证绑定设备到 license_devices 表。
// 若该设备已绑定,则更新 last_seen_at(幂等)。
// 若是新设备,则校验是否超出 max_devices 上限。
func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceName, platform string) (*model.License, error) {
var lic model.License
if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil {
if err := s.db.Where("license_key = ? AND shop_id = ?", licenseKey, shopID).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if !lic.IsActive {
@@ -59,23 +61,46 @@ func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License,
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
// 若已绑定设备,校验是否一致
if lic.DeviceID != "" && lic.DeviceID != deviceID {
return nil, ErrDeviceMismatch
var existing model.LicenseDevice
err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
if err == nil {
// Device already bound — just touch last_seen_at (handled by autoUpdateTime)
if err := s.db.Model(&existing).Update("device_name", deviceName).Error; err != nil {
return nil, err
}
return &lic, nil
}
now := time.Now()
lic.DeviceID = deviceID
lic.ActivatedAt = &now
s.db.Save(&lic)
// New device — enforce max_devices
var count int64
s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
if int(count) >= lic.MaxDevices {
return nil, ErrDeviceLimitExceed
}
dev := model.LicenseDevice{
LicenseID: lic.ID,
ShopID: shopID,
DeviceID: deviceID,
DeviceName: deviceName,
Platform: platform,
}
if err := s.db.Create(&dev).Error; err != nil {
return nil, err
}
return &lic, nil
}
// Verify 验证(客户端启动时调用)
// Verify 验证设备许可证(客户端启动时调用)
// 通过 license_devices 表查找设备,再加载对应的许可证做有效性检查。
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
var dev model.LicenseDevice
if err := s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).First(&dev).Error; err != nil {
return nil, ErrLicenseNotFound
}
var lic model.License
if err := s.db.Where("shop_id = ? AND device_id = ? AND is_active = 1", shopID, deviceID).
First(&lic).Error; err != nil {
if err := s.db.Where("id = ? AND shop_id = ? AND is_active = 1", dev.LicenseID, shopID).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
@@ -94,11 +119,26 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
return &lic, nil
}
// Deactivate 解绑设备(换机时使用)
// CountDevices 返回指定 license 下已绑定设备数。
func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) {
var count int64
err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", licenseID).Count(&count).Error
return count, err
}
// ListDevices 列出许可证下所有已绑定设备。
func (s *LicenseService) ListDevices(shopID uint64) ([]model.LicenseDevice, error) {
var devs []model.LicenseDevice
if err := s.db.Where("shop_id = ?", shopID).Order("activated_at DESC").Find(&devs).Error; err != nil {
return nil, err
}
return devs, nil
}
// Deactivate 解绑设备(从 license_devices 删除该条记录)。
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
return s.db.Model(&model.License{}).
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Delete(&model.LicenseDevice{}).Error
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
+79 -57
View File
@@ -15,71 +15,106 @@ func TestLicenseService_Activate_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC001")
// 创建许可证
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "AAAAA-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("AAAAA-BBBBB-CCCCC-DDDDD", "device-001")
result, err := svc.Activate(shop.ID, "AAAAA-BBBBB-CCCCC-DDDDD", "device-001", "Test PC", "windows")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "device-001", result.DeviceID)
assert.NotNil(t, result.ActivatedAt)
// Verify device record was created
var dev model.LicenseDevice
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-001").First(&dev).Error)
assert.Equal(t, "Test PC", dev.DeviceName)
assert.Equal(t, "windows", dev.Platform)
}
func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
func TestLicenseService_Activate_SameDeviceIdempotent(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC002")
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "EEEEE-FFFFF-GGGGG-HHHHH",
DeviceID: "existing-device",
IsActive: true,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
// Pre-bind the device
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "same-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("EEEEE-FFFFF-GGGGG-HHHHH", "new-device")
// Re-activating same device should succeed (idempotent)
result, err := svc.Activate(shop.ID, "EEEEE-FFFFF-GGGGG-HHHHH", "same-device", "Updated Name", "windows")
assert.Error(t, err)
assert.Equal(t, ErrDeviceMismatch, err)
assert.Nil(t, result)
require.NoError(t, err)
require.NotNil(t, result)
// Still only one device record
var count int64
db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
assert.Equal(t, int64(1), count)
}
func TestLicenseService_Activate_SameDevice(t *testing.T) {
func TestLicenseService_Activate_DeviceLimitExceeded(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC003")
lic := &model.License{
ShopID: shop.ID,
ShopID: shop.ID,
LicenseKey: "IIIII-JJJJJ-KKKKK-LLLLL",
DeviceID: "same-device",
IsActive: true,
MaxDevices: 2,
}
require.NoError(t, db.Create(lic).Error)
// Fill up the device limit
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-1"}).Error)
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-2"}).Error)
svc := NewLicenseService(db)
// 同一设备重新激活应该成功
result, err := svc.Activate("IIIII-JJJJJ-KKKKK-LLLLL", "same-device")
result, err := svc.Activate(shop.ID, "IIIII-JJJJJ-KKKKK-LLLLL", "dev-3", "", "")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "same-device", result.DeviceID)
assert.Error(t, err)
assert.Equal(t, ErrDeviceLimitExceed, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004")
svc := NewLicenseService(db)
result, err := svc.Activate("NONEX-ISTEN-TTTTT-LICCC", "device-001")
result, err := svc.Activate(shop.ID, "NONEX-ISTEN-TTTTT-LICCC", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_WrongShop(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004B")
otherShop := testutil.CreateTestShop(db, "LIC004C")
lic := &model.License{
ShopID: shop.ID, LicenseKey: "OTHSH-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
// otherShop cannot activate a license belonging to shop
result, err := svc.Activate(otherShop.ID, "OTHSH-BBBBB-CCCCC-DDDDD", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
@@ -88,20 +123,16 @@ func TestLicenseService_Activate_NotFound(t *testing.T) {
func TestLicenseService_Activate_Inactive(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC004")
shop := testutil.CreateTestShop(db, "LIC005")
// 先创建激活的许可证,再禁用(避免 GORM 零值跳过问题)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP",
IsActive: true,
ShopID: shop.ID, LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP", IsActive: true, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
// 禁用
require.NoError(t, db.Model(lic).Update("is_active", false).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("MMMMM-NNNNN-OOOOO-PPPPP", "device-001")
result, err := svc.Activate(shop.ID, "MMMMM-NNNNN-OOOOO-PPPPP", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseInactive, err)
@@ -110,20 +141,16 @@ func TestLicenseService_Activate_Inactive(t *testing.T) {
func TestLicenseService_Activate_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC005")
shop := testutil.CreateTestShop(db, "LIC006")
// 已过期
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("QQQQQ-RRRRR-SSSSS-TTTTT", "device-001")
result, err := svc.Activate(shop.ID, "QQQQQ-RRRRR-SSSSS-TTTTT", "device-001", "", "")
assert.Error(t, err)
assert.Equal(t, ErrLicenseExpired, err)
@@ -132,40 +159,37 @@ func TestLicenseService_Activate_Expired(t *testing.T) {
func TestLicenseService_Verify_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC006")
shop := testutil.CreateTestShop(db, "LIC007")
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX",
DeviceID: "my-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "my-device")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "my-device", result.DeviceID)
assert.Equal(t, lic.ID, result.ID)
}
func TestLicenseService_Verify_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC007")
shop := testutil.CreateTestShop(db, "LIC008")
// 已过期
expiry := time.Now().Add(-1 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB",
DeviceID: "expired-device",
IsActive: true,
ExpiresAt: &expiry,
ShopID: shop.ID, LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB", IsActive: true, ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "expired-device")
@@ -177,7 +201,7 @@ func TestLicenseService_Verify_Expired(t *testing.T) {
func TestLicenseService_Verify_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC008")
shop := testutil.CreateTestShop(db, "LIC009")
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "nonexistent-device")
@@ -189,17 +213,15 @@ func TestLicenseService_Verify_NotFound(t *testing.T) {
func TestLicenseService_Verify_NoExpiry(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LIC009")
shop := testutil.CreateTestShop(db, "LIC010")
// 永久许可证(无过期时间)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF",
DeviceID: "lifetime-device",
IsActive: true,
ExpiresAt: nil,
ShopID: shop.ID, LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF", IsActive: true, ExpiresAt: nil,
}
require.NoError(t, db.Create(lic).Error)
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "lifetime-device",
}).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(shop.ID, "lifetime-device")
+56 -12
View File
@@ -163,12 +163,17 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
productID := itemCopy.ProductID
needed := itemCopy.Quantity
// 1. 预检:SUM 是否充足
var totalQty float64
tx.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
// 1. FOR UPDATE 锁定批次后再汇总,消除 TOCTOU 窗口
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&totalQty)
Order("created_at ASC").Find(&batches)
var totalQty float64
for _, b := range batches {
totalQty += b.Quantity
}
if totalQty < needed {
return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f",
ErrInsufficientStock, productID, totalQty, needed)
@@ -176,13 +181,7 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
qtyBefore := totalQty
// 2. FIFO 扣减批次
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, warehouseID, productID).
Order("created_at ASC").Find(&batches)
// 2. FIFO 扣减
remaining := needed
for i := range batches {
if remaining <= 0 {
@@ -288,3 +287,48 @@ func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string,
})
return no, err
}
// CheckInventoryAvailability validates that the warehouse has sufficient stock for each item.
// Returns an error describing the first shortage encountered.
func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, items []model.StockOutItem) error {
if len(items) == 0 {
return nil
}
productIDs := make([]uint64, 0, len(items))
for _, item := range items {
productIDs = append(productIDs, item.ProductID)
}
type inventorySum struct {
ProductID uint64
Total float64
}
var sums []inventorySum
if err := s.db.Model(&model.Inventory{}).
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
shopID, warehouseID, productIDs).
Group("product_id").Scan(&sums).Error; err != nil {
return err
}
sumMap := make(map[uint64]float64, len(sums))
for _, s := range sums {
sumMap[s.ProductID] = s.Total
}
for _, item := range items {
have := sumMap[item.ProductID]
if have < item.Quantity {
var p model.Product
s.db.Where("id = ?", item.ProductID).First(&p)
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}
return nil
}
+9
View File
@@ -0,0 +1,9 @@
package util
// ValidatePageSize clamps pageSize into [1, maxSize], returning defaultSize when out of range.
func ValidatePageSize(pageSize, defaultSize, maxSize int) int {
if pageSize < 1 || pageSize > maxSize {
return defaultSize
}
return pageSize
}
+22
View File
@@ -0,0 +1,22 @@
package util
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RespondError writes a structured error response: {"code": code, "message": msg}.
func RespondError(c *gin.Context, status int, code, msg string) {
c.JSON(status, gin.H{"code": code, "message": msg})
}
// RespondSuccess writes {"data": data} with HTTP 200.
func RespondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"data": data})
}
// RespondCreated writes {"data": data} with HTTP 201.
func RespondCreated(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, gin.H{"data": data})
}
+12 -21
View File
@@ -12,13 +12,22 @@ import (
"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/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")
}
if config.C.License.Ed25519PrivateKey == "" {
log.Fatal("license.ed25519_private_key is required in production — store the key in Bitwarden and inject via env LICENSE_ED25519PRIVATEKEY")
}
}
// 初始化数据库
db := initDB()
@@ -28,9 +37,6 @@ func main() {
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
autoMigrate(db)
// 回填存量商品的拼音索引(一次性,已有值的跳过)
backfillPinyin(db)
// 启动 Gin
gin.SetMode(config.C.Server.Mode)
r := gin.New()
@@ -80,8 +86,8 @@ func initDB() *gorm.DB {
}
sqlDB, _ := db.DB()
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetMaxIdleConns(config.C.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(config.C.Database.MaxOpenConns)
return db
}
@@ -122,18 +128,3 @@ func autoMigrate(db *gorm.DB) {
log.Println("AutoMigrate completed")
}
func backfillPinyin(db *gorm.DB) {
var products []model.Product
db.Where("name_pinyin = '' OR name_pinyin IS NULL").Find(&products)
if len(products) == 0 {
return
}
for i := range products {
full, initials := util.ToPinyin(products[i].Name)
db.Model(&products[i]).Updates(map[string]interface{}{
"name_pinyin": full,
"name_initials": initials,
})
}
log.Printf("backfillPinyin: updated %d products", len(products))
}
+257 -257
View File
@@ -221,12 +221,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header>
<div class="wrap">
<h1>酒库管理系统 — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-10 · 真相源 todo/todo.json</div>
<div class="header-meta">生成于 2026-06-11 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>36</strong>全部</div>
<div class="stat-pill"><strong>14</strong>待开始</div>
<div class="stat-pill"><strong>5</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>8</strong>待验收</div>
<div class="stat-pill"><strong>17</strong>待验收</div>
<div class="stat-pill"><strong>14</strong>已验收</div>
</div>
</div>
@@ -269,7 +269,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="section-block" id="section-open">
<div class="section-title st-open" data-toggle="open">
📋 待开始 <span class="s-count">14</span>
📋 待开始 <span class="s-count">5</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-open">
@@ -303,258 +303,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-open"
data-id="29"
data-level="high"
data-status="open"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="30"
data-level="high"
data-status="open"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&amp;object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&amp;x).Where(&quot;id=? AND shop_id=?&quot;).Updates(fields) 白名单模式</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="31"
data-level="high"
data-status="open"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">添加库存与财务流水定期对账检查,防止事务中断导致不平账</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="32"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-title">迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="33"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="34"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一后端 API 错误响应格式为 {code, message},创建 util/response.go</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="35"
data-level="mid"
data-status="open"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">config.go 默认 CORSOrigin=&quot;*&quot;,注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==&quot;*&quot; 时 log.Fatal 拒绝启动</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="36"
data-level="mid"
data-status="open"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">License 私钥未配置时改为 Fatal 而非静默跳过</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="37"
data-level="mid"
data-status="open"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
</div>
</div>
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="23"
data-level="low"
@@ -682,7 +430,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">8</span>
🔍 待验收 <span class="s-count">17</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
@@ -716,6 +464,90 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-done"
data-id="29"
data-level="high"
data-status="done"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="29" data-title="修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行">拒绝验收</button>
</div>
</div>
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="30"
data-level="high"
data-status="done"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="30" data-title="统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖">拒绝验收</button>
</div>
</div>
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&amp;object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&amp;x).Where(&quot;id=? AND shop_id=?&quot;).Updates(fields) 白名单模式</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="31"
data-level="high"
data-status="done"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">添加库存与财务流水定期对账检查,防止事务中断导致不平账</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-block">高优 · 紧急</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="31" data-title="添加库存与财务流水定期对账检查,防止事务中断导致不平账">拒绝验收</button>
</div>
</div>
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="20"
data-level="mid"
@@ -772,6 +604,174 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-done"
data-id="32"
data-level="mid"
data-status="done"
data-tier="2"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-title">迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="32" data-title="迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段">拒绝验收</button>
</div>
</div>
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="33"
data-level="mid"
data-status="done"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="33" data-title="将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层">拒绝验收</button>
</div>
</div>
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="34"
data-level="mid"
data-status="done"
data-tier="2"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一后端 API 错误响应格式为 {code, message},创建 util/response.go</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
<button class="reject-btn" data-id="34" data-title="统一后端 API 错误响应格式为 {code, message},创建 util/response.go">拒绝验收</button>
</div>
</div>
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="35"
data-level="mid"
data-status="done"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="35" data-title="生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*">拒绝验收</button>
</div>
</div>
<div class="item-desc">config.go 默认 CORSOrigin=&quot;*&quot;,注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==&quot;*&quot; 时 log.Fatal 拒绝启动</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="36"
data-level="mid"
data-status="done"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">License 私钥未配置时改为 Fatal 而非静默跳过</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="36" data-title="License 私钥未配置时改为 Fatal 而非静默跳过">拒绝验收</button>
</div>
</div>
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="37"
data-level="mid"
data-status="done"
data-tier="3"
data-tags="后端">
<div class="card-header">
<span class="item-title">统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="37" data-title="统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()">拒绝验收</button>
</div>
</div>
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-10</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="21"
data-level="low"
+10 -10
View File
@@ -1,7 +1,7 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-10T15:32:03.641Z"
"updated_at": "2026-06-10T16:38:37.763Z"
},
"seq": 40,
"items": [
@@ -477,7 +477,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:13.249Z",
"done": false,
"completed_at": null,
@@ -492,7 +492,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:15.102Z",
"done": false,
"completed_at": null,
@@ -507,7 +507,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:16.854Z",
"done": false,
"completed_at": null,
@@ -523,7 +523,7 @@
"后端",
"数据库"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:25.715Z",
"done": false,
"completed_at": null,
@@ -538,7 +538,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:28.106Z",
"done": false,
"completed_at": null,
@@ -553,7 +553,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:33.596Z",
"done": false,
"completed_at": null,
@@ -568,7 +568,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:44.159Z",
"done": false,
"completed_at": null,
@@ -583,7 +583,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:46.792Z",
"done": false,
"completed_at": null,
@@ -598,7 +598,7 @@
"tags": [
"后端"
],
"status": "open",
"status": "done",
"created_at": "2026-06-10T15:31:49.032Z",
"done": false,
"completed_at": null,
+3 -2
View File
@@ -2,9 +2,10 @@
"name": "岩美酒库管理系统",
"tagline": "为酒行与酒店设计的库存管理平台",
"copyright": "© 2026 岩美科技 · 保留所有权利",
"icp": "沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号",
"icp": "",
"support": {
"email": "yammy2023@163.com"
"email": "yammy2023@163.com",
"wechat": ""
},
"appUrl": "/app/",
"appBaseUrl": "https://jiu.51yanmei.com",
+2 -1
View File
@@ -5,6 +5,7 @@
<img src="/assets/logo-full.svg" alt="岩美" />
<p>为酒行与酒店设计的库存、审核、财务一体化管理平台。</p>
<div class="footer-contact">
{% if site.support.wechat %}<div>客服微信:{{ site.support.wechat }}</div>{% endif %}
<div><a href="mailto:{{ site.support.email }}">{{ site.support.email }}</a></div>
{% if site.support.phone %}<div>{{ site.support.phone }}</div>{% endif %}
</div>
@@ -42,7 +43,7 @@
</div>
<div class="footer-bottom">
<div>{{ site.copyright }}</div>
<div>{{ site.icp }}</div>
{% if site.icp %}<div>{{ site.icp }}</div>{% endif %}
</div>
</div>
</footer>
+3 -3
View File
@@ -555,7 +555,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>单门店 · 最多 3 用户</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>全部模块开放</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>Web 端 + 移动端</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>邮件技术支持</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>微信技术支持</li>
</ul>
<a href="/register/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
</div>
@@ -585,7 +585,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>专属实施与培训服务</li>
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>7×24 专属客户经理</li>
</ul>
<a href="mailto:{{ site.support.email }}" class="btn btn-secondary w-full justify-center mt-auto">联系销售</a>
{% if site.support.wechat %}<a href="#" class="btn btn-secondary w-full justify-center mt-auto">微信咨询:{{ site.support.wechat }}</a>{% else %}<a href="mailto:{{ site.support.email }}" class="btn btn-secondary w-full justify-center mt-auto">联系销售</a>{% endif %}
</div>
</div>
</div>
@@ -639,7 +639,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
</div>
<div class="d-flex flex-col gap-12 items-start">
<a href="/register/" class="btn btn-primary btn-lg">立即开通试用<i data-lucide="arrow-right" class="icon"></i></a>
<a href="mailto:{{ site.support.email }}?subject=咨询岩美连锁版" class="btn btn-secondary btn-lg"><i data-lucide="mail" class="icon"></i>邮件咨询</a>
{% if site.support.wechat %}<a href="#" class="btn btn-secondary btn-lg"><i data-lucide="message-circle" class="icon"></i>微信咨询:{{ site.support.wechat }}</a>{% else %}<a href="mailto:{{ site.support.email }}?subject=咨询岩美连锁版" class="btn btn-secondary btn-lg"><i data-lucide="mail" class="icon"></i>邮件咨询</a>{% endif %}
</div>
</div>
</div>