fix(backend): 架构质量改进批次二 (#32-37)
- #32 License 激活迁移到 license_devices 表:Activate/Verify/Deactivate 全部改用 license_devices,新增 max_devices 校验和 GET /license/devices 端点; Activate 现在校验 shop_id 防跨租户激活 - #33 checkInventory 从 StockOutHandler 移到 StockService.CheckInventoryAvailability - #34 新增 util/response.go 统一错误响应工具(RespondError/RespondSuccess/RespondCreated) - #35 生产模式 CORS Origin='*' 启动时 Fatal - #36 生产模式 License 私钥未配置启动时 Fatal - #37 新增 util/page.go ValidatePageSize,应用到 partner/product/stock_in/stock_out handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,16 +18,19 @@ 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
|
||||
@@ -52,7 +55,7 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": 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)
|
||||
@@ -60,17 +63,35 @@ func (h *LicenseHandler) Info(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
var deviceCount int64
|
||||
// count active devices for this shop
|
||||
devs, _ := h.svc.ListDevices(shopID)
|
||||
deviceCount = int64(len(devs))
|
||||
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -65,51 +66,6 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(过期只读/锁定拦截写操作)
|
||||
|
||||
@@ -18,10 +18,11 @@ 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")
|
||||
ErrDeviceMismatch = errors.New("license is bound to another device")
|
||||
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
|
||||
)
|
||||
|
||||
type LicenseService struct {
|
||||
@@ -47,10 +48,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 +62,44 @@ 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)
|
||||
s.db.Model(&existing).Update("device_name", deviceName)
|
||||
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 is_active = 1", dev.LicenseID).First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
||||
@@ -94,11 +118,19 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// Deactivate 解绑设备(换机时使用)
|
||||
// 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")
|
||||
|
||||
@@ -287,3 +287,46 @@ 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
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -19,6 +19,16 @@ 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()
|
||||
|
||||
|
||||
+257
-257
@@ -221,12 +221,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>酒库管理系统 — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-06-10 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-06-11 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>36</strong>全部</div>
|
||||
<div class="stat-pill"><strong>14</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>5</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>8</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>17</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>14</strong>已验收</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
<div class="section-block" id="section-open">
|
||||
<div class="section-title st-open" data-toggle="open">
|
||||
📋 待开始 <span class="s-count">14</span>
|
||||
📋 待开始 <span class="s-count">5</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-open">
|
||||
@@ -303,258 +303,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="29"
|
||||
data-level="high"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="30"
|
||||
data-level="high"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where("id=? AND shop_id=?").Updates(fields) 白名单模式</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="31"
|
||||
data-level="high"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">添加库存与财务流水定期对账检查,防止事务中断导致不平账</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="32"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端,数据库">
|
||||
<div class="card-header">
|
||||
<span class="item-title">迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="33"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="34"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一后端 API 错误响应格式为 {code, message},创建 util/response.go</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="35"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">config.go 默认 CORSOrigin="*",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin=="*" 时 log.Fatal 拒绝启动</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="36"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">License 私钥未配置时改为 Fatal 而非静默跳过</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="37"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-open">待开始</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="23"
|
||||
data-level="low"
|
||||
@@ -682,7 +430,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
<div class="section-block" id="section-done">
|
||||
<div class="section-title st-done" data-toggle="done">
|
||||
🔍 待验收 <span class="s-count">8</span>
|
||||
🔍 待验收 <span class="s-count">17</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-done">
|
||||
@@ -716,6 +464,90 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="29"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="29" data-title="修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="30"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="30" data-title="统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where("id=? AND shop_id=?").Updates(fields) 白名单模式</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="31"
|
||||
data-level="high"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">添加库存与财务流水定期对账检查,防止事务中断导致不平账</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-block">高优 · 紧急</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="31" data-title="添加库存与财务流水定期对账检查,防止事务中断导致不平账">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="20"
|
||||
data-level="mid"
|
||||
@@ -772,6 +604,174 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="32"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端,数据库">
|
||||
<div class="card-header">
|
||||
<span class="item-title">迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="32" data-title="迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="33"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="33" data-title="将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="34"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="2"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一后端 API 错误响应格式为 {code, message},创建 util/response.go</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-2">二级</span>
|
||||
|
||||
<button class="reject-btn" data-id="34" data-title="统一后端 API 错误响应格式为 {code, message},创建 util/response.go">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="35"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
<button class="reject-btn" data-id="35" data-title="生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">config.go 默认 CORSOrigin="*",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin=="*" 时 log.Fatal 拒绝启动</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="36"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">License 私钥未配置时改为 Fatal 而非静默跳过</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
<button class="reject-btn" data-id="36" data-title="License 私钥未配置时改为 Fatal 而非静默跳过">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="37"
|
||||
data-level="mid"
|
||||
data-status="done"
|
||||
data-tier="3"
|
||||
data-tags="后端">
|
||||
<div class="card-header">
|
||||
<span class="item-title">统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge s-done">待验收</span>
|
||||
<span class="tag t-high">重要</span>
|
||||
<span class="tag tier-3">三级</span>
|
||||
|
||||
<button class="reject-btn" data-id="37" data-title="统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()">拒绝验收</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="meta-date">🕐 2026-06-10</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-done"
|
||||
data-id="21"
|
||||
data-level="low"
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "酒库管理系统 — 项目 TODO",
|
||||
"updated_at": "2026-06-10T15:32:03.641Z"
|
||||
"updated_at": "2026-06-10T16:38:37.763Z"
|
||||
},
|
||||
"seq": 40,
|
||||
"items": [
|
||||
@@ -477,7 +477,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:13.249Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -492,7 +492,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:15.102Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -507,7 +507,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:16.854Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -523,7 +523,7 @@
|
||||
"后端",
|
||||
"数据库"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:25.715Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -538,7 +538,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:28.106Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -553,7 +553,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:33.596Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -568,7 +568,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:44.159Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -583,7 +583,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:46.792Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
@@ -598,7 +598,7 @@
|
||||
"tags": [
|
||||
"后端"
|
||||
],
|
||||
"status": "open",
|
||||
"status": "done",
|
||||
"created_at": "2026-06-10T15:31:49.032Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
|
||||
Reference in New Issue
Block a user