Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fd8d46745 | |||
| c5a72ecbdf | |||
| c553237f15 | |||
| 02082df525 | |||
| 4c75f328c7 | |||
| 8628df5848 | |||
| 85cd262525 | |||
| e0bee00ff4 | |||
| d28aba863e | |||
| f928d5c6a3 | |||
| 3ab78dbf7a | |||
| 51cfe5fc6d |
@@ -5,6 +5,43 @@ 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.36] - 2026-06-13
|
||||
|
||||
### 修复
|
||||
- 修复数据库初始化时 license_key 索引超长导致启动失败的问题
|
||||
|
||||
## [1.0.35] - 2026-06-12
|
||||
|
||||
### 改进
|
||||
- Debug 版本窗口标题增加 [DEBUG] 后缀,便于与正式安装版区分
|
||||
|
||||
## [1.0.34] - 2026-06-12
|
||||
|
||||
### 修复
|
||||
- 修复 macOS 客户端打印时应用卡死、只能强制退出的问题
|
||||
|
||||
### 改进
|
||||
- 「关于我们」中「打开文档」「更新日志」等链接指向新域名 jiu.51yanmei.com
|
||||
|
||||
## [1.0.33] - 2026-06-12
|
||||
|
||||
### 改进
|
||||
- macOS 客户端插件集成从 CocoaPods 迁移至 Swift Package Manager,优化 macOS 构建流程
|
||||
|
||||
## [1.0.32] - 2026-06-11
|
||||
|
||||
### 修复
|
||||
- 修复库存可用量检查在数据库查询失败时误报所有商品库存不足的问题
|
||||
- 修复授权信息页面设备数量统计不准确的问题
|
||||
- 修复对账接口在数据库查询出错时静默返回空结果、掩盖异常的问题
|
||||
- 修复 license 验证缺少门店隔离过滤,提升多租户安全性
|
||||
- 官网联系入口调整为微信咨询(填写微信号后自动生效),隐藏尚未申请的备案号
|
||||
- 修复各接口分页大小未设上限,防止超大请求拖慢服务
|
||||
|
||||
### 改进
|
||||
- 后端启动不再执行拼音回填,已改为独立工具(`cmd/backfill-pinyin`),避免大库存量时延长启动时间
|
||||
- 数据库连接池参数(最大空闲连接数、最大打开连接数)改为配置项,支持按环境调整
|
||||
|
||||
## [1.0.31] - 2026-06-10
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 查询该往来单位最后一条财务记录的余额
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (软删除)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (只允许草稿状态)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import "time"
|
||||
type License struct {
|
||||
Base
|
||||
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
|
||||
LicenseKey string `gorm:"size:2048;uniqueIndex" json:"license_key"`
|
||||
LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"`
|
||||
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -73,3 +73,38 @@ func TestVerifyLicenseToken_InvalidFormat(t *testing.T) {
|
||||
_, err = VerifyLicenseToken("not-a-valid-token", pub)
|
||||
assert.ErrorIs(t, err, ErrInvalidLicenseToken)
|
||||
}
|
||||
|
||||
// TestLicenseTokenFitsColumnLimit verifies that a realistic (even worst-case)
|
||||
// license token fits within the VARCHAR(768) column limit imposed by the
|
||||
// InnoDB index constraint (768 chars × 4 bytes/char = 3072 bytes max).
|
||||
func TestLicenseTokenFitsColumnLimit(t *testing.T) {
|
||||
const columnLimit = 768
|
||||
|
||||
priv, _, err := GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use a large features map to simulate a worst-case payload.
|
||||
exp := time.Now().Add(365 * 24 * time.Hour).Unix()
|
||||
payload := LicensePayload{
|
||||
ShopID: 999999999,
|
||||
LicenseID: 999999999,
|
||||
Type: "lifetime",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 99,
|
||||
Features: map[string]any{
|
||||
"finance": true,
|
||||
"inventory": true,
|
||||
"reports": true,
|
||||
"export": true,
|
||||
"multi_shop": true,
|
||||
"api_access": true,
|
||||
},
|
||||
}
|
||||
|
||||
token, err := IssueLicenseToken(payload, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.LessOrEqual(t, len(token), columnLimit,
|
||||
"license token length %d exceeds VARCHAR(%d) column limit", len(token), columnLimit)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
CREATE TABLE IF NOT EXISTS `licenses` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`license_key` VARCHAR(2048) NOT NULL COMMENT 'Ed25519 signed token',
|
||||
`license_key` VARCHAR(768) NOT NULL COMMENT 'Ed25519 signed token',
|
||||
`device_id` VARCHAR(255) DEFAULT NULL COMMENT 'deprecated: use license_devices',
|
||||
`type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久',
|
||||
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `licenses` (
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_license_key` (`license_key`(255)),
|
||||
UNIQUE KEY `uk_license_key` (`license_key`),
|
||||
KEY `idx_shop_id` (`shop_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"provider": "岩美技术有限公司",
|
||||
"website": "https://jiu.yanmei.com",
|
||||
"website": "https://jiu.51yanmei.com",
|
||||
"email": "yammy2023@163.com",
|
||||
"phone": "",
|
||||
"wechat": "",
|
||||
"terms_url": "https://jiu.yanmei.com/terms/",
|
||||
"privacy_url": "https://jiu.yanmei.com/privacy/",
|
||||
"docs_url": "https://jiu.yanmei.com/docs/"
|
||||
"terms_url": "https://jiu.51yanmei.com/terms/",
|
||||
"privacy_url": "https://jiu.51yanmei.com/privacy/",
|
||||
"docs_url": "https://jiu.51yanmei.com/docs/"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
@@ -198,8 +200,14 @@ Future<void> printProductLabelImpl({
|
||||
),
|
||||
));
|
||||
|
||||
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
|
||||
final bytes = await doc.save();
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// 让当前帧完成,再打开 macOS 原生打印面板,防止 sheet 动画与 Flutter 渲染死锁。
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
await Printing.layoutPdf(
|
||||
name: '标签_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||
name: '标签_${_fileTs()}', onLayout: (_) async => bytes);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
@@ -439,8 +447,12 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
||||
),
|
||||
],
|
||||
));
|
||||
final bytes = await doc.save();
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
await Printing.layoutPdf(
|
||||
name: '入库单_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||
name: '入库单_${_fileTs()}', onLayout: (_) async => bytes);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
@@ -495,8 +507,12 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
],
|
||||
));
|
||||
try {
|
||||
final bytes = await doc.save();
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
await Printing.layoutPdf(
|
||||
name: '出库单_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||
name: '出库单_${_fileTs()}', onLayout: (_) async => bytes);
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -107,7 +108,7 @@ class _JiuAppState extends ConsumerState<JiuApp> {
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
return MaterialApp.router(
|
||||
title: '岩美',
|
||||
title: kDebugMode ? '岩美 [DEBUG]' : '岩美',
|
||||
theme: AppTheme.light(),
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
||||
@@ -1,46 +1,21 @@
|
||||
PODS:
|
||||
- file_picker (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- printing (1.0.0):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
file_picker:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
printing:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/printing/macos
|
||||
shared_preferences_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
7CC0DF083710AA9C43B56A42 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B35335B030AEE727449F09D5 /* Pods_Runner.framework */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -88,6 +89,7 @@
|
||||
B35335B030AEE727449F09D5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B6FABD6521DBD25CA93F2ABB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
D768E6B98F41FC79E2D5D343 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -103,6 +105,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
7CC0DF083710AA9C43B56A42 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -164,6 +167,7 @@
|
||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
@@ -231,6 +235,9 @@
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
33CC10EC2044A3C60003C045 /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -256,6 +263,9 @@
|
||||
|
||||
/* Begin PBXProject section */
|
||||
33CC10E52044A3C60003C045 /* Project object */ = {
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
@@ -796,6 +806,18 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "jiu_client.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
+658
-378
File diff suppressed because it is too large
Load Diff
+168
-17
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "酒库管理系统 — 项目 TODO",
|
||||
"updated_at": "2026-06-10T15:32:03.641Z"
|
||||
"updated_at": "2026-06-11T15:52:40.354Z"
|
||||
},
|
||||
"seq": 40,
|
||||
"seq": 50,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -24,7 +24,7 @@
|
||||
"id": 2,
|
||||
"title": "备案号为占位假值",
|
||||
"desc": "现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。",
|
||||
"level": "high",
|
||||
"level": "low",
|
||||
"tags": [
|
||||
"Web"
|
||||
],
|
||||
@@ -32,7 +32,7 @@
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null,
|
||||
"status": "open"
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -404,7 +404,7 @@
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null,
|
||||
"status": "open"
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
@@ -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,
|
||||
@@ -613,7 +613,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:57.979Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -628,7 +628,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:32:00.233Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -644,11 +644,162 @@
|
||||
"后端",
|
||||
"数据库"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:32:03.641Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"title": "CheckInventoryAvailability Scan错误未检查导致所有商品误报库存不足",
|
||||
"desc": "service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝",
|
||||
"level": "high",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:34.919Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"title": "finance ListRecords 和 inventory Logs handler 缺 pageSize 上限",
|
||||
"desc": "finance.go:36 和 inventory.go:130 漏加 ValidatePageSize,#37 未覆盖这两个 handler,可被任意用户触发全表扫描",
|
||||
"level": "mid",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:46.152Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"title": "ReconcileInventory 两条 Scan 错误未检查导致对账误判",
|
||||
"desc": "admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异",
|
||||
"level": "mid",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:46.205Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"title": "license Info 端点 device_count 统计错误且 ListDevices 错误被吞",
|
||||
"desc": "handler/license.go:67-70 两个bug:1.devs,_ 错误被_吞掉DB故障时deviceCount=0;2.ListDevices(shopID)包含该店所有license设备而非当前license",
|
||||
"level": "mid",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:46.257Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"title": "license Activate 幂等路径 Update 错误未检查",
|
||||
"desc": "service/license.go:70 s.db.Model(&existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道",
|
||||
"level": "low",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:59.016Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"title": "license Verify 加载 license 缺 shop_id 过滤(防御纵深)",
|
||||
"desc": "service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露",
|
||||
"level": "low",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:59.066Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"title": "util/response.go 的 RespondError 等辅助函数从未被调用(#34 实际未完成)",
|
||||
"desc": "#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移",
|
||||
"level": "low",
|
||||
"tier": 2,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:59.118Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"title": "清理 ErrDeviceMismatch 死代码(多设备迁移后残留)",
|
||||
"desc": "service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束",
|
||||
"level": "low",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T23:59:59.168Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"title": "修复 act_runner→Forgejo 任务状态上报丢失导致构建成功却标失败",
|
||||
"desc": "v1.0.27~v1.0.32 连续 6 个版本 CI 全部'失败',但日志显示所有 job 实际构建成功(🏁 Job succeeded),是 act_runner 最终状态上报丢失(Failed to write EOT),被 Forgejo 僵尸任务清理标为 failure。生产环境因此停留在 v1.0.26(6/8)。需排查 relay/GOAWAY race 或升级 act_runner/Forgejo。",
|
||||
"level": "high",
|
||||
"tier": 2,
|
||||
"tags": [
|
||||
"CI/CD"
|
||||
],
|
||||
"status": "done",
|
||||
"created_at": "2026-06-11T13:55:07.388Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"title": "License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引",
|
||||
"desc": "license.go 的 LicenseKey 是 size:2048+uniqueIndex,utf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。",
|
||||
"level": "mid",
|
||||
"tier": 3,
|
||||
"tags": [
|
||||
"后端",
|
||||
"数据库"
|
||||
],
|
||||
"status": "open",
|
||||
"created_at": "2026-06-11T15:52:40.353Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user