feat: 自动更新、系统设置、安全修复

后端:
- 新增 GET /version 版本检查端点(version.go + version.yaml)
- 新增 GET /license/info 接口,返回门店授权信息
- 修复 GenerateOrderNo 并发重复单号:事务内加 FOR UPDATE 行锁
- 修复 ApproveStockOut 超卖竞态:预检和库存更新均加 FOR UPDATE
- 修复 Product Create 并发 code 冲突:加重试逻辑,schema 加 UNIQUE KEY
- 修复 Product Update 全字段覆盖:改用 selective Updates()
- 挂载 ReadOnly 中间件(全局)+ AdminOnly(用户管理路由)
- version.go 配置缺失时返回 500 而非静默降级

前端:
- 新增自动更新检测(update_provider.dart)+ shell 更新 banner/弹窗
- 新增系统设置"关于"标签页:版本、授权、开发信息、意见反馈
- 新增离线缓存:所有 AsyncNotifierProvider 支持断网浏览历史数据
- 新增门店信息弹窗(点击左上角 logo 或右上角门店号触发)
- 提取 AppConfig 统一管理 BASE_URL,支持 --dart-define 注入
- update_provider.dart 加 kIsWeb 保护,修复 Web 平台崩溃
- dev.sh 新增 stop 命令,修复 stop 误杀前端进程问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-13 00:28:08 +08:00
parent 53a60d230f
commit bb4f17cf7a
44 changed files with 3384 additions and 710 deletions
+11
View File
@@ -52,6 +52,17 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": lic})
}
// Info GET /api/v1/license/info — 当前门店授权概况(无需 device_id)
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})
return
}
c.JSON(http.StatusOK, gin.H{"data": lic})
}
// Deactivate POST /api/v1/license/deactivate
func (h *LicenseHandler) Deactivate(c *gin.Context) {
shopID := middleware.GetShopID(c)
+51 -5
View File
@@ -1,6 +1,8 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -64,8 +66,34 @@ func (h *ProductHandler) Create(c *gin.Context) {
}
product.ShopID = shopID
if err := h.db.Create(&product).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// Auto-generate product code if not provided (e.g. P001, P002)
// Retry up to 5 times on duplicate key to handle concurrent creates
if product.Code == "" {
var count int64
h.db.Model(&model.Product{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID).
Count(&count)
product.Code = fmt.Sprintf("P%03d", count+1)
}
var createErr error
for attempt := 0; attempt < 5; attempt++ {
if createErr = h.db.Create(&product).Error; createErr == nil {
break
}
if !errors.Is(createErr, gorm.ErrDuplicatedKey) {
break
}
// Duplicate code: try next slot
var count int64
h.db.Model(&model.Product{}).
Where("shop_id = ? AND deleted_at IS NULL", shopID).
Count(&count)
product.ID = 0
product.Code = fmt.Sprintf("P%03d", count+int64(attempt)+2)
}
if createErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": product})
@@ -83,16 +111,34 @@ func (h *ProductHandler) Update(c *gin.Context) {
return
}
if err := c.ShouldBindJSON(&product); err != nil {
var req model.Product
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product.ShopID = shopID // 防止篡改
if err := h.db.Save(&product).Error; err != nil {
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
if err := h.db.Model(&product).Updates(map[string]interface{}{
"code": req.Code,
"barcode": req.Barcode,
"name": req.Name,
"series": req.Series,
"spec": req.Spec,
"unit": req.Unit,
"category_id": req.CategoryID,
"brand": req.Brand,
"purchase_price": req.PurchasePrice,
"sale_price": req.SalePrice,
"min_stock": req.MinStock,
"remark": req.Remark,
"custom_fields": req.CustomFields,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 重新读取完整数据返回
h.db.Preload("Category").First(&product, product.ID)
c.JSON(http.StatusOK, gin.H{"data": product})
}
+74 -1
View File
@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"time"
@@ -93,12 +94,15 @@ func (h *StockInHandler) Create(c *gin.Context) {
}
req.OrderNo = orderNo
// 计算总金额
// 计算总金额;自动生成批次号
var total float64
for i := range req.Items {
req.Items[i].ShopID = shopID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
if req.Items[i].BatchNo == "" {
req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1)
}
}
req.TotalAmount = total
@@ -109,6 +113,75 @@ func (h *StockInHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": req})
}
// Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态)
func (h *StockInHandler) Update(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockInOrder
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可修改"})
return
}
var req model.StockInOrder
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil {
return err
}
var total float64
for i := range req.Items {
req.Items[i].ShopID = shopID
req.Items[i].OrderID = order.ID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
if req.Items[i].BatchNo == "" {
req.Items[i].BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1)
}
}
updates := map[string]interface{}{
"warehouse_id": req.WarehouseID,
"partner_id": req.PartnerID,
"order_date": req.OrderDate,
"remark": req.Remark,
"total_amount": total,
}
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
if len(req.Items) > 0 {
if err := tx.Create(&req.Items).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated"})
}
// Delete DELETE /api/v1/stock-in/orders/:id (只允许草稿状态)
func (h *StockInHandler) Delete(c *gin.Context) {
shopID := middleware.GetShopID(c)
now := timeNow()
result := h.db.Model(&model.StockInOrder{}).
Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
Update("deleted_at", now)
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可删除"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核)
func (h *StockInHandler) Submit(c *gin.Context) {
shopID := middleware.GetShopID(c)
+66
View File
@@ -136,6 +136,72 @@ func (h *StockOutHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": req})
}
// Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态)
func (h *StockOutHandler) Update(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockOutOrder
if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可修改"})
return
}
var req model.StockOutOrder
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockOutItem{}).Error; err != nil {
return err
}
var total float64
for i := range req.Items {
req.Items[i].ShopID = shopID
req.Items[i].OrderID = order.ID
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
total += req.Items[i].TotalPrice
}
updates := map[string]interface{}{
"warehouse_id": req.WarehouseID,
"partner_id": req.PartnerID,
"order_date": req.OrderDate,
"remark": req.Remark,
"total_amount": total,
}
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
if len(req.Items) > 0 {
if err := tx.Create(&req.Items).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated"})
}
// Delete DELETE /api/v1/stock-out/orders/:id (只允许草稿状态)
func (h *StockOutHandler) Delete(c *gin.Context) {
shopID := middleware.GetShopID(c)
now := timeNow()
result := h.db.Model(&model.StockOutOrder{}).
Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID).
Update("deleted_at", now)
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "出库单不存在或不可删除"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Submit PUT /api/v1/stock-out/orders/:id/submit
func (h *StockOutHandler) Submit(c *gin.Context) {
shopID := middleware.GetShopID(c)
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
type versionConfig struct {
Version string `yaml:"version"`
BuildNumber int `yaml:"build_number"`
ForceUpdate bool `yaml:"force_update"`
ReleaseNotes string `yaml:"release_notes"`
DownloadURLs map[string]string `yaml:"download_urls"`
}
// GetVersion GET /version
func GetVersion(c *gin.Context) {
cfg, err := loadVersionConfig()
if err != nil {
log.Printf("[version] failed to load version config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "version config unavailable"})
return
}
c.JSON(http.StatusOK, gin.H{
"version": cfg.Version,
"build_number": cfg.BuildNumber,
"force_update": cfg.ForceUpdate,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
})
}
func loadVersionConfig() (*versionConfig, error) {
// 查找 config/version.yaml,相对于可执行文件或源码目录
candidates := []string{
"config/version.yaml",
filepath.Join(sourceDir(), "config/version.yaml"),
}
for _, path := range candidates {
data, err := os.ReadFile(path)
if err != nil {
continue
}
var cfg versionConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
return nil, os.ErrNotExist
}
// sourceDir 返回当前源文件所在目录的上两级(backend 根目录)
func sourceDir() string {
_, filename, _, ok := runtime.Caller(0)
if !ok {
return "."
}
// handler/ → internal/ → backend/
return filepath.Join(filepath.Dir(filename), "..", "..")
}