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:
@@ -0,0 +1,10 @@
|
||||
version: "1.1.1"
|
||||
build_number: 2
|
||||
force_update: false
|
||||
release_notes: "修复了离线模式问题,优化状态栏显示"
|
||||
download_urls:
|
||||
macos: ""
|
||||
windows: ""
|
||||
ios: ""
|
||||
android: ""
|
||||
web: ""
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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), "..", "..")
|
||||
}
|
||||
@@ -29,6 +29,14 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
financeH := handler.NewFinanceHandler(db)
|
||||
numberRuleH := handler.NewNumberRuleHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 版本信息(无需认证,用于客户端更新检查)
|
||||
r.GET("/version", handler.GetVersion)
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 公开路由(无需登录)
|
||||
@@ -38,13 +46,14 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
auth.POST("/refresh", authH.Refresh)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
api := v1.Group("")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(), middleware.ReadOnly())
|
||||
{
|
||||
// 许可证
|
||||
license := api.Group("/license")
|
||||
{
|
||||
license.GET("/info", licenseH.Info)
|
||||
license.POST("/activate", licenseH.Activate)
|
||||
license.GET("/verify", licenseH.Verify)
|
||||
license.POST("/deactivate", licenseH.Deactivate)
|
||||
@@ -83,6 +92,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
stockIn.GET("/orders", stockInH.List)
|
||||
stockIn.GET("/orders/:id", stockInH.Get)
|
||||
stockIn.POST("/orders", stockInH.Create)
|
||||
stockIn.PUT("/orders/:id", stockInH.Update)
|
||||
stockIn.DELETE("/orders/:id", stockInH.Delete)
|
||||
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
|
||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||
@@ -94,6 +105,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
stockOut.GET("/orders", stockOutH.List)
|
||||
stockOut.GET("/orders/:id", stockOutH.Get)
|
||||
stockOut.POST("/orders", stockOutH.Create)
|
||||
stockOut.PUT("/orders/:id", stockOutH.Update)
|
||||
stockOut.DELETE("/orders/:id", stockOutH.Delete)
|
||||
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
|
||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||
@@ -109,8 +122,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||
}
|
||||
|
||||
// 用户管理
|
||||
// 用户管理(仅管理员)
|
||||
users := api.Group("/users")
|
||||
users.Use(middleware.AdminOnly())
|
||||
{
|
||||
users.GET("", userH.List)
|
||||
users.POST("", userH.Create)
|
||||
|
||||
@@ -82,6 +82,16 @@ func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License,
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// ShopInfo 返回门店当前授权信息(取最新一条有效许可证)
|
||||
func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
|
||||
var lic model.License
|
||||
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
||||
Order("id DESC").First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// Deactivate 解绑设备(换机时使用)
|
||||
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
||||
return s.db.Model(&model.License{}).
|
||||
|
||||
@@ -62,11 +62,12 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
return errors.New("order is not in pending status")
|
||||
}
|
||||
|
||||
// 预检库存
|
||||
// 预检库存(FOR UPDATE 加锁,防止并发审核超卖)
|
||||
for _, item := range order.Items {
|
||||
var inv model.Inventory
|
||||
if err := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
return fmt.Errorf("product %d not in inventory", item.ProductID)
|
||||
}
|
||||
if inv.Quantity < item.Quantity {
|
||||
@@ -96,8 +97,9 @@ func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, product
|
||||
direction string, qty float64, refID uint64, refType string, operatorID uint64) error {
|
||||
|
||||
var inv model.Inventory
|
||||
result := tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, productID).First(&inv)
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, productID).First(&inv)
|
||||
|
||||
qtyBefore := inv.Quantity
|
||||
var qtyAfter float64
|
||||
@@ -137,12 +139,13 @@ func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, product
|
||||
return tx.Create(&log).Error
|
||||
}
|
||||
|
||||
// GenerateOrderNo 生成单号(事务安全)
|
||||
// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号)
|
||||
func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) {
|
||||
var no string
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var rule model.NumberRule
|
||||
result := tx.Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
|
||||
if result.Error != nil {
|
||||
// 初始化规则
|
||||
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
|
||||
|
||||
@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS `products` (
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_category` (`category_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`),
|
||||
UNIQUE KEY `uk_product_code` (`shop_id`, `code`),
|
||||
FULLTEXT KEY `ft_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user