diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6568baa..49827dd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -50,8 +50,6 @@ func main() { } tenantSvc := service.NewTenantService(db) authSvc := service.NewAuthService(db, jm, tenantSvc) - skinSvc := service.NewSkinService(db) - orderSvc := service.NewOrderService(db) userSvc := service.NewUserService(db, tenantSvc) callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) @@ -60,19 +58,15 @@ func main() { if err := authSvc.EnsureAdmin(); err != nil { log.Fatalf("ensure admin: %v", err) } - if err := skinSvc.SeedCatalog(); err != nil { - log.Printf("seed skins: %v", err) - } openlog.Init(cfg.OpenAPIDebug) h := &router.Handlers{ Auth: handler.NewAuthHandler(authSvc), - Skin: handler.NewSkinHandler(skinSvc), - Order: handler.NewOrderHandler(orderSvc), + Dashboard: handler.NewDashboardHandler(fulfillmentSvc), User: handler.NewUserHandler(userSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), - SourceOpen: handler.NewOpenHandler(orderSvc), + SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), JWT: jm, Tenant: tenantSvc, @@ -88,7 +82,7 @@ func main() { r := router.Setup(h) addr := ":" + cfg.Port - log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr) + log.Printf("游戏皮肤供货平台 API 启动: http://localhost%s", addr) log.Printf("数据库: PostgreSQL") log.Printf("默认管理员: admin / admin123") log.Printf("开放接口鉴权: X-App-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)") diff --git a/backend/internal/database/migrations/002_merchant_fee_features.sql b/backend/internal/database/migrations/002_merchant_fee_features.sql new file mode 100644 index 0000000..690e524 --- /dev/null +++ b/backend/internal/database/migrations/002_merchant_fee_features.sql @@ -0,0 +1,22 @@ +ALTER TABLE users ALTER COLUMN role SET DEFAULT 'merchant'; +UPDATE users SET role = 'merchant' WHERE role = 'distributor'; +DROP INDEX IF EXISTS idx_users_invite_code; +DROP INDEX IF EXISTS idx_users_parent_id; +ALTER TABLE users DROP COLUMN IF EXISTS invite_code; +ALTER TABLE users DROP COLUMN IF EXISTS parent_id; + +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS features TEXT NOT NULL DEFAULT 'products,orders,wallet,api,callbacks'; +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS fee_rate_bp BIGINT NOT NULL DEFAULT 0; +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS fee_fixed_amount BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE skins DROP COLUMN IF EXISTS commission; + +DROP INDEX IF EXISTS idx_orders_distributor_id; +ALTER TABLE orders DROP COLUMN IF EXISTS distributor_id; +ALTER TABLE orders DROP COLUMN IF EXISTS commission_amt; + +ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS base_amount BIGINT NOT NULL DEFAULT 0; +ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS fee_rate_bp BIGINT NOT NULL DEFAULT 0; +ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS fee_fixed_amount BIGINT NOT NULL DEFAULT 0; +ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS service_fee_amount BIGINT NOT NULL DEFAULT 0; +UPDATE fulfillment_orders SET base_amount = amount WHERE base_amount = 0; diff --git a/backend/internal/database/migrations/003_drop_legacy_tables.sql b/backend/internal/database/migrations/003_drop_legacy_tables.sql new file mode 100644 index 0000000..54dd340 --- /dev/null +++ b/backend/internal/database/migrations/003_drop_legacy_tables.sql @@ -0,0 +1,9 @@ +-- 手续费改为"百分比或固定"二选一:新增 fee_type 列。 +ALTER TABLE merchants ADD COLUMN IF NOT EXISTS fee_type VARCHAR(16) NOT NULL DEFAULT 'rate'; +ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS fee_type VARCHAR(16) NOT NULL DEFAULT 'rate'; + +-- 清理旧演示链路遗留表:skins / orders / ship_logs。 +-- 这些表属于旧的"皮肤源头"演示模型,已被商户履约模型(merchant_products / fulfillment_orders)取代。 +DROP TABLE IF EXISTS ship_logs; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS skins; diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index acb725b..cdcef7e 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -330,25 +330,39 @@ func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) { } type createMerchantReq struct { - Code string `json:"code" binding:"required"` - Name string `json:"name" binding:"required"` - ContactName string `json:"contact_name"` - ContactInfo string `json:"contact_info"` - OwnerUserID uint `json:"owner_user_id" binding:"required"` + Code string `json:"code" binding:"required"` + Name string `json:"name" binding:"required"` + ContactName string `json:"contact_name"` + ContactInfo string `json:"contact_info"` + OwnerUserID uint `json:"owner_user_id"` + OwnerUsername string `json:"owner_username"` + OwnerPassword string `json:"owner_password"` + OwnerNickname string `json:"owner_nickname"` + Features string `json:"features"` + FeeType string `json:"fee_type"` + FeeRateBP int64 `json:"fee_rate_bp"` + FeeFixedAmount int64 `json:"fee_fixed_amount"` } func (h *MerchantHandler) CreateMerchant(c *gin.Context) { var req createMerchantReq if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误:code、name、owner_user_id 必填") + response.BadRequest(c, "参数错误:code、name 必填") return } merchant, err := h.merchantSvc.CreateMerchant(service.CreateMerchantInput{ - Code: req.Code, - Name: req.Name, - ContactName: req.ContactName, - ContactInfo: req.ContactInfo, - OwnerUserID: req.OwnerUserID, + Code: req.Code, + Name: req.Name, + ContactName: req.ContactName, + ContactInfo: req.ContactInfo, + OwnerUserID: req.OwnerUserID, + OwnerUsername: req.OwnerUsername, + OwnerPassword: req.OwnerPassword, + OwnerNickname: req.OwnerNickname, + Features: req.Features, + FeeType: req.FeeType, + FeeRateBP: req.FeeRateBP, + FeeFixedAmount: req.FeeFixedAmount, }, middleware.GetUserID(c)) if err != nil { response.BadRequest(c, err.Error()) @@ -357,6 +371,40 @@ func (h *MerchantHandler) CreateMerchant(c *gin.Context) { response.OK(c, merchant) } +type updateMerchantSettingsReq struct { + Name *string `json:"name"` + Status *string `json:"status"` + ContactName *string `json:"contact_name"` + ContactInfo *string `json:"contact_info"` + Features *string `json:"features"` + FeeType *string `json:"fee_type"` + FeeRateBP *int64 `json:"fee_rate_bp"` + FeeFixedAmount *int64 `json:"fee_fixed_amount"` +} + +func (h *MerchantHandler) UpdateMerchantSettings(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req updateMerchantSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.merchantSvc.UpdateMerchantSettings(uint(id), service.UpdateMerchantSettingsInput{ + Name: req.Name, + Status: req.Status, + ContactName: req.ContactName, + ContactInfo: req.ContactInfo, + Features: req.Features, + FeeType: req.FeeType, + FeeRateBP: req.FeeRateBP, + FeeFixedAmount: req.FeeFixedAmount, + }, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) { var req addMemberReq if err := c.ShouldBindJSON(&req); err != nil { diff --git a/backend/internal/handler/open.go b/backend/internal/handler/open.go index e754042..4419757 100644 --- a/backend/internal/handler/open.go +++ b/backend/internal/handler/open.go @@ -11,13 +11,13 @@ import ( "github.com/gin-gonic/gin" ) -// OpenHandler 皮肤源头开放接口 +// OpenHandler 皮肤源头开放接口(上游 SourceOpen),基于 FulfillmentOrder 履约模型。 type OpenHandler struct { - orderSvc *service.OrderService + fulfillmentSvc *service.FulfillmentService } -func NewOpenHandler(orderSvc *service.OrderService) *OpenHandler { - return &OpenHandler{orderSvc: orderSvc} +func NewOpenHandler(fulfillmentSvc *service.FulfillmentService) *OpenHandler { + return &OpenHandler{fulfillmentSvc: fulfillmentSvc} } // QueryOrder GET /api/open/v1/orders/:order_no @@ -29,7 +29,7 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) { } openlog.Info(c, "query_order start order_no=%s", orderNo) - data, err := h.orderSvc.QueryOpenOrder(orderNo) + data, err := h.fulfillmentSvc.QueryOpenOrder(orderNo) if err != nil { openlog.Warn(c, "query_order fail order_no=%s err=%s", orderNo, err.Error()) if err.Error() == "订单不存在" { @@ -90,7 +90,7 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) { shippedAt = &t } - result, err := h.orderSvc.HandleShipNotify(service.ShipNotifyInput{ + result, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{ OrderNo: req.OrderNo, ShipStatus: req.ShipStatus, ProviderOrderNo: req.ProviderOrderNo, diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index 1a6b309..233eb3e 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -202,6 +202,9 @@ func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H { "name": order.ProductName, }, "quantity": order.Quantity, + "base_amount": order.BaseAmount, + "fee_type": order.FeeType, + "service_fee_amount": order.ServiceFeeAmount, "amount": order.Amount, "currency": order.Currency, "buyer_reference": order.BuyerReference, diff --git a/backend/internal/handler/order.go b/backend/internal/handler/order.go index cfe59d3..18deb41 100644 --- a/backend/internal/handler/order.go +++ b/backend/internal/handler/order.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "affiliate_dash/internal/middleware" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/response" @@ -11,126 +9,20 @@ import ( "github.com/gin-gonic/gin" ) -type OrderHandler struct { - svc *service.OrderService +// DashboardHandler 仪表盘统计接口。 +type DashboardHandler struct { + svc *service.FulfillmentService } -func NewOrderHandler(svc *service.OrderService) *OrderHandler { - return &OrderHandler{svc: svc} +func NewDashboardHandler(svc *service.FulfillmentService) *DashboardHandler { + return &DashboardHandler{svc: svc} } -func (h *OrderHandler) List(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - q := service.OrderListQuery{ - MerchantID: middleware.GetMerchantID(c), - Page: page, - Size: size, - Status: c.Query("status"), - } - // 分销商只能看自己的订单 - if middleware.GetRole(c) == model.RoleDistributor { - id := middleware.GetUserID(c) - q.DistributorID = &id - } else if d := c.Query("distributor_id"); d != "" { - id, _ := strconv.ParseUint(d, 10, 64) - uid := uint(id) - q.DistributorID = &uid - } - list, total, err := h.svc.List(q) - if err != nil { - response.ServerError(c, err.Error()) - return - } - response.Page(c, list, total, page, size) -} - -type createOrderReq struct { - SkinID uint `json:"skin_id" binding:"required"` - BuyerName string `json:"buyer_name"` - Remark string `json:"remark"` - Status string `json:"status"` // 管理员可指定初始状态(联调造异常单) - DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商 -} - -func (h *OrderHandler) Create(c *gin.Context) { - var req createOrderReq - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误:请选择商品") - return - } - distributorID := middleware.GetUserID(c) - status := "" - // 管理员可指定分销商、任意初始状态(方便联调) - if middleware.GetRole(c) == model.RoleAdmin { - if req.DistributorID != nil && *req.DistributorID > 0 { - distributorID = *req.DistributorID - } - if d := c.Query("distributor_id"); d != "" { - id, _ := strconv.ParseUint(d, 10, 64) - distributorID = uint(id) - } - status = req.Status - } - if req.BuyerName == "" { - req.BuyerName = "测试买家" - } - order, err := h.svc.Create(service.CreateOrderInput{ - MerchantID: middleware.GetMerchantID(c), - SkinID: req.SkinID, - DistributorID: distributorID, - BuyerName: req.BuyerName, - Remark: req.Remark, - Status: status, - }) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.OK(c, order) -} - -type orderStatusReq struct { - Status string `json:"status" binding:"required"` -} - -func (h *OrderHandler) UpdateStatus(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var req orderStatusReq - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误") - return - } - if err := h.svc.UpdateStatus(middleware.GetMerchantID(c), uint(id), req.Status); err != nil { - response.BadRequest(c, err.Error()) - return - } - response.OK(c, nil) -} - -func (h *OrderHandler) Dashboard(c *gin.Context) { - stats, err := h.svc.Dashboard(middleware.GetMerchantID(c)) +func (h *DashboardHandler) Dashboard(c *gin.Context) { + stats, err := h.svc.Dashboard(middleware.GetMerchantID(c), middleware.GetRole(c) == model.RoleAdmin) if err != nil { response.ServerError(c, err.Error()) return } response.OK(c, stats) } - -// ListShipLogs 发货推送记录(管理端) -func (h *OrderHandler) ListShipLogs(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{ - MerchantID: middleware.GetMerchantID(c), - Page: page, - Size: size, - OrderNo: c.Query("order_no"), - ShipStatus: c.Query("ship_status"), - }) - if err != nil { - response.ServerError(c, err.Error()) - return - } - response.Page(c, list, total, page, size) -} diff --git a/backend/internal/handler/skin.go b/backend/internal/handler/skin.go deleted file mode 100644 index 638e44b..0000000 --- a/backend/internal/handler/skin.go +++ /dev/null @@ -1,128 +0,0 @@ -package handler - -import ( - "strconv" - - "affiliate_dash/internal/middleware" - "affiliate_dash/internal/model" - "affiliate_dash/internal/pkg/response" - "affiliate_dash/internal/service" - - "github.com/gin-gonic/gin" -) - -type SkinHandler struct { - svc *service.SkinService -} - -func NewSkinHandler(svc *service.SkinService) *SkinHandler { - return &SkinHandler{svc: svc} -} - -func (h *SkinHandler) List(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - q := service.SkinListQuery{ - MerchantID: middleware.GetMerchantID(c), - Page: page, - Size: size, - Keyword: c.Query("keyword"), - Game: c.Query("game"), - Category: c.Query("category"), - } - if s := c.Query("status"); s != "" { - v, _ := strconv.Atoi(s) - q.Status = &v - } - list, total, err := h.svc.List(q) - if err != nil { - response.ServerError(c, err.Error()) - return - } - response.Page(c, list, total, page, size) -} - -func (h *SkinHandler) Get(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - skin, err := h.svc.Get(middleware.GetMerchantID(c), uint(id)) - if err != nil { - response.NotFound(c, err.Error()) - return - } - response.OK(c, skin) -} - -type skinCreateReq struct { - Name string `json:"name" binding:"required"` - SKU string `json:"sku" binding:"required"` - Game string `json:"game"` - Category string `json:"category"` - CoverURL string `json:"cover_url"` - Price float64 `json:"price"` - CostPrice float64 `json:"cost_price"` - Commission float64 `json:"commission"` - Stock int `json:"stock"` - Status int `json:"status"` - Description string `json:"description"` -} - -func (h *SkinHandler) Create(c *gin.Context) { - var req skinCreateReq - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误:名称与 sku 必填") - return - } - status := req.Status - if status != 1 { - // 未传或非 1 时:创建默认上架;若传 0 仍默认上架(下架请创建后更新) - status = 1 - } - if req.Game == "" { - req.Game = "和平精英" - } - skin := &model.Skin{ - MerchantID: middleware.GetMerchantID(c), - Name: req.Name, - SKU: req.SKU, - Game: req.Game, - Category: req.Category, - CoverURL: req.CoverURL, - Price: req.Price, - CostPrice: req.CostPrice, - Commission: req.Commission, - Stock: req.Stock, - Status: status, - Description: req.Description, - } - if err := h.svc.Create(skin); err != nil { - response.ServerError(c, err.Error()) - return - } - response.OK(c, skin) -} - -func (h *SkinHandler) Update(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - var updates map[string]interface{} - if err := c.ShouldBindJSON(&updates); err != nil { - response.BadRequest(c, "参数错误") - return - } - delete(updates, "id") - delete(updates, "created_at") - delete(updates, "updated_at") - if err := h.svc.Update(middleware.GetMerchantID(c), uint(id), updates); err != nil { - response.BadRequest(c, err.Error()) - return - } - response.OK(c, nil) -} - -func (h *SkinHandler) Delete(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.svc.Delete(middleware.GetMerchantID(c), uint(id)); err != nil { - response.BadRequest(c, err.Error()) - return - } - response.OK(c, nil) -} diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index b44cf6b..2fe4b3d 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -45,7 +45,6 @@ type createUserReq struct { Password string `json:"password" binding:"required,min=6"` Nickname string `json:"nickname"` Role string `json:"role"` - ParentID *uint `json:"parent_id"` } func (h *UserHandler) Create(c *gin.Context) { @@ -54,7 +53,7 @@ func (h *UserHandler) Create(c *gin.Context) { response.BadRequest(c, "参数错误") return } - user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, req.ParentID, middleware.GetMerchantID(c)) + user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, middleware.GetMerchantID(c)) if err != nil { response.BadRequest(c, err.Error()) return diff --git a/backend/internal/middleware/feature.go b/backend/internal/middleware/feature.go new file mode 100644 index 0000000..5759125 --- /dev/null +++ b/backend/internal/middleware/feature.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "errors" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RequireMerchantFeature 校验当前商户是否开通指定功能。 +func RequireMerchantFeature(db *gorm.DB, features ...string) gin.HandlerFunc { + return func(c *gin.Context) { + merchantID := GetMerchantID(c) + if merchantID == 0 { + response.Forbidden(c, "当前请求未绑定商户") + c.Abort() + return + } + var merchant model.Merchant + if err := db.Select("id", "status", "features").First(&merchant, merchantID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + response.Forbidden(c, "商户不存在") + } else { + response.ServerError(c, err.Error()) + } + c.Abort() + return + } + if merchant.Status != model.MerchantStatusActive { + response.Forbidden(c, "商户已禁用") + c.Abort() + return + } + if !service.MerchantHasFeature(merchant.Features, features...) { + response.Forbidden(c, "商户功能未开通") + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index eeb4ea6..d152081 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -10,11 +10,21 @@ const ( MerchantStatusActive = "active" MerchantStatusDisabled = "disabled" + MerchantFeatureProducts = "products" + MerchantFeatureOrders = "orders" + MerchantFeatureWallet = "wallet" + MerchantFeatureAPI = "api" + MerchantFeatureCallbacks = "callbacks" + DefaultMerchantFeatures = "products,orders,wallet,api,callbacks" + MemberRoleOwner = "owner" MemberRoleOperator = "operator" MemberRoleFinance = "finance" MemberRoleViewer = "viewer" + FeeTypeRate = "rate" // 手续费按百分比 + FeeTypeFixed = "fixed" // 手续费按固定金额 + APIClientStatusActive = "active" APIClientStatusDisabled = "disabled" @@ -63,6 +73,22 @@ type Merchant struct { Status string `gorm:"size:16;not null;default:active;index" json:"status"` ContactName string `gorm:"size:64" json:"contact_name"` ContactInfo string `gorm:"size:128" json:"contact_info"` + Features string `gorm:"type:text;not null;default:'products,orders,wallet,api,callbacks'" json:"features"` + + // 手续费按"百分比或固定"二选一:fee_type=rate 用 fee_rate_bp,fee_type=fixed 用 fee_fixed_amount。 + FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"` + FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"` + FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"` +} + +func (m *Merchant) BeforeCreate(tx *gorm.DB) error { + if m.Features == "" { + m.Features = DefaultMerchantFeatures + } + if m.FeeType == "" { + m.FeeType = FeeTypeRate + } + return nil } // MerchantMember 将系统账号和商户权限分离,账号可以属于多个商户。 @@ -179,6 +205,11 @@ type FulfillmentOrder struct { ProductSKU string `gorm:"size:96;not null" json:"product_sku"` ProductName string `gorm:"size:160;not null" json:"product_name"` Quantity int64 `gorm:"not null;default:1" json:"quantity"` + BaseAmount int64 `gorm:"not null;default:0" json:"base_amount"` + FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"` + FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"` + FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"` + ServiceFeeAmount int64 `gorm:"not null;default:0" json:"service_fee_amount"` Amount int64 `gorm:"not null" json:"amount"` Currency string `gorm:"size:12;not null;default:CNY" json:"currency"` PaymentStatus string `gorm:"size:16;not null;default:pending;index" json:"payment_status"` diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 1144f21..46b8449 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -8,8 +8,8 @@ import ( // 用户角色 const ( - RoleAdmin = "admin" // 管理员 - RoleDistributor = "distributor" // 分销商 + RoleAdmin = "admin" // 平台管理员 + RoleMerchant = "merchant" // 商户账号 ) // User 系统用户 @@ -22,92 +22,6 @@ type User struct { Username string `gorm:"uniqueIndex;size:64;not null" json:"username"` PasswordHash string `gorm:"size:255;not null" json:"-"` Nickname string `gorm:"size:64" json:"nickname"` - Role string `gorm:"size:32;not null;default:distributor" json:"role"` + Role string `gorm:"size:32;not null;default:merchant" json:"role"` Status int `gorm:"default:1" json:"status"` // 1启用 0禁用 - InviteCode string `gorm:"uniqueIndex;size:32" json:"invite_code"` - ParentID *uint `gorm:"index" json:"parent_id"` // 上级分销商 -} - -// Skin 游戏皮肤商品 -type Skin struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - MerchantID uint `gorm:"index;uniqueIndex:idx_skins_merchant_sku;not null;default:0" json:"merchant_id"` - Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名) - SKU string `gorm:"uniqueIndex:idx_skins_merchant_sku;size:64;not null" json:"sku"` // 英文固定标识 - Game string `gorm:"size:64;index" json:"game"` // 所属游戏 - Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/... - CoverURL string `gorm:"size:512" json:"cover_url"` - Price float64 `gorm:"not null;default:0" json:"price"` // 售价 - CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价 - Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1 - Stock int `gorm:"default:0" json:"stock"` // -1 无限 - Status int `gorm:"default:1" json:"status"` // 1上架 0下架 - Description string `gorm:"type:text" json:"description"` -} - -// Order 订单 -type Order struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"` - OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` - SkinID uint `gorm:"index;not null" json:"skin_id"` - Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"` - DistributorID uint `gorm:"index;not null" json:"distributor_id"` - Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"` - BuyerName string `gorm:"size:64" json:"buyer_name"` - Amount float64 `gorm:"not null" json:"amount"` - CommissionAmt float64 `gorm:"default:0" json:"commission_amt"` - Status string `gorm:"size:32;default:pending;index" json:"status"` - Remark string `gorm:"size:255" json:"remark"` - - // 发货相关(上游皮肤源头对接) - ProviderOrderNo string `gorm:"size:64;index" json:"provider_order_no"` // 上游单号 - ShippedAt *time.Time `json:"shipped_at"` // 发货成功时间 - ShipFailReason string `gorm:"size:512" json:"ship_fail_reason"` // 最近一次失败原因 - - GameChannel string `gorm:"size:64" json:"game_channel"` // 账号区服(安卓/IOS-微信/QQ) - GameUID string `gorm:"size:128" json:"game_uid"` // 游戏角色UUID - RoleName string `gorm:"size:64" json:"role_name"` // 角色名 - PayScore int `gorm:"default:0" json:"pay_score"` // 消耗积分 -} - -// 订单状态 -const ( - OrderStatusPending = "pending" // 待支付 - OrderStatusPaid = "paid" // 已支付,可发货 - OrderStatusDelivering = "delivering" // 发货中 - OrderStatusDelivered = "delivered" // 已交付 - OrderStatusShipFailed = "ship_failed" // 发货失败(可重试) - OrderStatusCancelled = "cancelled" // 已取消 -) - -// 上游推送的发货状态 -const ( - ShipNotifySuccess = "success" - ShipNotifyFailed = "failed" - ShipNotifyProcessing = "processing" -) - -// ShipLog 发货推送记录(上游回调留痕) -type ShipLog struct { - ID uint `gorm:"primarykey" json:"id"` - CreatedAt time.Time `json:"created_at"` - - MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"` - OrderNo string `gorm:"size:64;index;not null" json:"order_no"` - OrderID uint `gorm:"index" json:"order_id"` - ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing - ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"` - FailReason string `gorm:"size:512" json:"fail_reason"` - Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON - ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态 - Message string `gorm:"size:255" json:"message"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 92b113b..a12973d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -14,8 +14,7 @@ import ( type Handlers struct { Auth *handler.AuthHandler - Skin *handler.SkinHandler - Order *handler.OrderHandler + Dashboard *handler.DashboardHandler User *handler.UserHandler Open *handler.OpenV1Handler SourceOpen *handler.OpenHandler @@ -72,64 +71,52 @@ func Setup(h *Handlers) *gin.Engine { Debug: h.OpenAPIDebug, })) { - clientOpen.GET("/products", middleware.RequireAPIScope("products:read"), h.Open.ListProducts) - clientOpen.POST("/orders", middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder) - clientOpen.GET("/orders/:order_no", middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder) - clientOpen.POST("/orders/:order_no/cancel", middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) - clientOpen.POST("/orders/:order_no/ship-notify", middleware.RequireAPIScope("fulfillment:write"), h.Open.ShipNotify) - clientOpen.GET("/wallet", middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) + clientOpen.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireAPIScope("products:read"), h.Open.ListProducts) + clientOpen.POST("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder) + clientOpen.GET("/orders/:order_no", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder) + clientOpen.POST("/orders/:order_no/cancel", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) + clientOpen.POST("/orders/:order_no/ship-notify", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("fulfillment:write"), h.Open.ShipNotify) + clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) } auth := api.Group("") auth.Use(middleware.Auth(h.JWT)) auth.Use(middleware.Tenant(h.Tenant)) { - auth.GET("/auth/profile", h.Auth.Profile) - auth.GET("/dashboard", h.Order.Dashboard) + auth.GET("/auth/profile", h.Auth.Profile) + auth.GET("/dashboard", h.Dashboard.Dashboard) - // 皮肤 - auth.GET("/skins", h.Skin.List) - auth.GET("/skins/:id", h.Skin.Get) - auth.POST("/skins", middleware.RequireRole(model.RoleAdmin), h.Skin.Create) - auth.PUT("/skins/:id", middleware.RequireRole(model.RoleAdmin), h.Skin.Update) - auth.DELETE("/skins/:id", middleware.RequireRole(model.RoleAdmin), h.Skin.Delete) - - // 订单 - auth.GET("/orders", h.Order.List) - auth.POST("/orders", h.Order.Create) - auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus) - - // 新商户后台:不依赖旧皮肤/分销商模型。 + // 商户后台:商户就是平台下游客户,成员只代表该商户内部员工。 merchant := auth.Group("/merchant") { merchant.GET("", h.Merchant.Current) - merchant.GET("/products", h.Merchant.ListProducts) - merchant.POST("/products", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct) - merchant.PATCH("/products/:id", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct) - merchant.GET("/orders", h.Merchant.ListOrders) - merchant.GET("/wallet", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet) - merchant.GET("/wallet/ledger", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger) - merchant.POST("/wallet/adjust", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet) - merchant.GET("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListAPIClients) - merchant.POST("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient) - merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus) - merchant.GET("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks) - merchant.POST("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback) - merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus) + merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts) + merchant.POST("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct) + merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct) + merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders) + merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet) + merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger) + merchant.POST("/wallet/adjust", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet) + merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListAPIClients) + merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient) + merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus) + merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks) + merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback) + merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus) merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers) merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember) } - // 用户 / 分销商 / 发货记录(仅管理员) + // 用户 / 发货记录 / 平台商户(仅管理员) admin := auth.Group("") admin.Use(middleware.RequireRole(model.RoleAdmin)) { admin.GET("/users", h.User.List) admin.POST("/users", h.User.Create) - admin.PATCH("/users/:id/status", h.User.UpdateStatus) - admin.GET("/ship-logs", h.Order.ListShipLogs) - admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) + admin.PATCH("/users/:id/status", h.User.UpdateStatus) + admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) admin.POST("/platform/merchants", h.Merchant.CreateMerchant) + admin.PATCH("/platform/merchants/:id", h.Merchant.UpdateMerchantSettings) admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) } } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 6a306af..dde9b44 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -20,8 +20,6 @@ func TestSetupDoesNotPanic(t *testing.T) { } tenantSvc := service.NewTenantService(db) authSvc := service.NewAuthService(db, jwt.NewManager("test-jwt"), tenantSvc) - skinSvc := service.NewSkinService(db) - orderSvc := service.NewOrderService(db) userSvc := service.NewUserService(db, tenantSvc) callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) @@ -34,11 +32,10 @@ func TestSetupDoesNotPanic(t *testing.T) { }() _ = Setup(&Handlers{ Auth: handler.NewAuthHandler(authSvc), - Skin: handler.NewSkinHandler(skinSvc), - Order: handler.NewOrderHandler(orderSvc), + Dashboard: handler.NewDashboardHandler(fulfillmentSvc), User: handler.NewUserHandler(userSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), - SourceOpen: handler.NewOpenHandler(orderSvc), + SourceOpen: handler.NewOpenHandler(fulfillmentSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), JWT: jwt.NewManager("test-jwt"), Tenant: tenantSvc, diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index f3dd56f..8ebd2e6 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -2,9 +2,6 @@ package service import ( "errors" - "fmt" - "math/rand" - "time" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/jwt" @@ -63,9 +60,8 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User Username: username, PasswordHash: string(hash), Nickname: nickname, - Role: model.RoleDistributor, + Role: model.RoleMerchant, Status: 1, - InviteCode: generateInviteCode(), } if user.Nickname == "" { user.Nickname = username @@ -116,7 +112,6 @@ func (s *AuthService) EnsureAdmin() error { Nickname: "管理员", Role: model.RoleAdmin, Status: 1, - InviteCode: "ADMIN001", } if err := s.db.Create(admin).Error; err != nil { return err @@ -126,8 +121,3 @@ func (s *AuthService) EnsureAdmin() error { } return nil } - -func generateInviteCode() string { - r := rand.New(rand.NewSource(time.Now().UnixNano())) - return fmt.Sprintf("D%06d", r.Intn(1000000)) -} diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index ee7aab2..d24670c 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -79,6 +79,13 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat return err } + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", in.MerchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商户不存在或已禁用") + } + return err + } var product model.MerchantProduct if err := tx.Preload("Product").Clauses(clause.Locking{Strength: "UPDATE"}). Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive). @@ -97,7 +104,15 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount { return errors.New("订单金额超出范围") } - totalAmount := product.PriceAmount * in.Quantity + baseAmount := product.PriceAmount * in.Quantity + serviceFee, err := calculateServiceFee(baseAmount, merchant.FeeType, merchant.FeeRateBP, merchant.FeeFixedAmount) + if err != nil { + return err + } + if serviceFee > math.MaxInt64-baseAmount { + return errors.New("订单金额超出范围") + } + totalAmount := baseAmount + serviceFee var wallet model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). @@ -120,6 +135,11 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat ProductSKU: product.SKU, ProductName: fallbackName(product.DisplayName, product.Product.Name), Quantity: in.Quantity, + BaseAmount: baseAmount, + FeeType: merchant.FeeType, + FeeRateBP: merchant.FeeRateBP, + FeeFixedAmount: merchant.FeeFixedAmount, + ServiceFeeAmount: serviceFee, Amount: totalAmount, Currency: product.Currency, PaymentStatus: model.PaymentStatusPaid, @@ -141,7 +161,7 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat ReferenceType: "fulfillment_order", ReferenceNo: order.OrderNo, IdempotencyKey: &idempotencyKey, - Note: "开放接口下单扣款", + Note: "开放接口下单扣款(含平台手续费)", }).Error; err != nil { return err } @@ -493,6 +513,31 @@ func newFulfillmentOrderNo() string { return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "") } +// calculateServiceFee 按"百分比或固定"二选一计算手续费: +// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算 +// - feeType=fixed:直接取 feeFixedAmount +// +// 二者互斥,不会叠加。 +func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) { + if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 { + return 0, errors.New("订单金额或手续费配置无效") + } + switch feeType { + case model.FeeTypeFixed: + return feeFixedAmount, nil + case model.FeeTypeRate, "": + if feeRateBP > 10000 { + return 0, errors.New("手续费比例不能超过 10000 BP") + } + if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP { + return 0, errors.New("手续费金额超出范围") + } + return baseAmount * feeRateBP / 10000, nil + default: + return 0, errors.New("无效的手续费类型") + } +} + func CanFulfill(order *model.FulfillmentOrder) (bool, string) { if order.PaymentStatus != model.PaymentStatusPaid { return false, "订单未支付或已退款" @@ -518,8 +563,11 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { "client_order_no": order.ClientOrderNo, "product_sku": order.ProductSKU, "quantity": order.Quantity, + "base_amount": order.BaseAmount, + "fee_type": order.FeeType, + "service_fee_amount": order.ServiceFeeAmount, "amount": order.Amount, - "currency": order.Currency, + "currency": order.Currency, "payment_status": order.PaymentStatus, "fulfillment_status": order.FulfillmentStatus, "can_fulfill": canFulfill, @@ -528,3 +576,358 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { "failure_reason": order.FailureReason, } } + +// ----- 仪表盘统计 ----- + +// DashboardStats 仪表盘聚合指标。 +type DashboardStats struct { + ProductCount int64 `json:"product_count"` + MerchantCount int64 `json:"merchant_count"` + OrderCount int64 `json:"order_count"` + TotalSales float64 `json:"total_sales"` + TotalFees float64 `json:"total_fees"` + PendingOrderCount int64 `json:"pending_order_count"` +} + +// Dashboard 汇总商户维度的商品、商户、订单与金额统计。 +func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) { + stats := &DashboardStats{} + s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID).Count(&stats.ProductCount) + if isPlatformAdmin { + s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.MerchantCount) + } else { + stats.MerchantCount = 1 + } + s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID).Count(&stats.OrderCount) + s.db.Model(&model.FulfillmentOrder{}). + Where("merchant_id = ? AND fulfillment_status = ?", merchantID, model.FulfillmentStatusPending). + Count(&stats.PendingOrderCount) + s.db.Model(&model.FulfillmentOrder{}). + Where("merchant_id = ?", merchantID). + Where("payment_status = ?", model.PaymentStatusPaid). + Select("COALESCE(SUM(amount),0) / 100.0").Scan(&stats.TotalSales) + s.db.Model(&model.FulfillmentOrder{}). + Where("merchant_id = ?", merchantID). + Where("payment_status = ?", model.PaymentStatusPaid). + Select("COALESCE(SUM(service_fee_amount),0) / 100.0").Scan(&stats.TotalFees) + return stats, nil +} + +// ----- 上游 SourceOpen 接口(基于 FulfillmentOrder)----- + +// OpenOrderQuery 开放接口订单查询结果。 +type OpenOrderQuery struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + CanShip bool `json:"can_ship"` + CannotShipReason string `json:"cannot_ship_reason,omitempty"` + Product *OpenOrderProduct `json:"product,omitempty"` + BuyerName string `json:"buyer_name"` + Amount float64 `json:"amount"` + ProviderOrderNo string `json:"provider_order_no,omitempty"` + CreatedAt time.Time `json:"created_at"` + ShippedAt *time.Time `json:"shipped_at"` + ShipFailReason string `json:"ship_fail_reason,omitempty"` + GameChannel string `json:"game_channel,omitempty"` + GameUID string `json:"game_uid,omitempty"` + RoleName string `json:"role_name,omitempty"` + PayScore int `json:"pay_score,omitempty"` +} + +// OpenOrderProduct 开放接口返回的商品快照。 +type OpenOrderProduct struct { + Name string `json:"name"` + SKU string `json:"sku"` + Game string `json:"game"` +} + +// ShipNotifyInput 上游发货结果推送。 +type ShipNotifyInput struct { + OrderNo string + ShipStatus string // success / failed / processing + ProviderOrderNo string + ShippedAt *time.Time + FailReason string + RawPayload string + GameChannel *string + GameUID *string + RoleName *string + PayScore *int +} + +// ShipNotifyResult 上游推送处理结果。 +type ShipNotifyResult struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + Message string `json:"message"` +} + +// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。 +func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) { + var order model.FulfillmentOrder + err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("订单不存在") + } + if err != nil { + return nil, err + } + return &order, nil +} + +// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。 +func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) { + if orderNo == "" { + return nil, errors.New("订单号不能为空") + } + order, err := s.GetByOrderNo(orderNo) + if err != nil { + return nil, err + } + canShip, reason := CanFulfill(order) + out := &OpenOrderQuery{ + OrderNo: order.OrderNo, + Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), + CanShip: canShip, + CannotShipReason: reason, + BuyerName: order.BuyerReference, + Amount: float64(order.Amount) / 100.0, + ProviderOrderNo: order.ProviderOrderNo, + CreatedAt: order.CreatedAt, + ShippedAt: order.DeliveredAt, + ShipFailReason: order.FailureReason, + } + if order.MerchantProduct != nil { + game := "" + if order.MerchantProduct.Product != nil { + game = order.MerchantProduct.Product.Category + } + out.Product = &OpenOrderProduct{ + Name: order.ProductName, + SKU: order.ProductSKU, + Game: game, + } + } + // 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。 + extractGameFields(order.RequestData, out) + extractGameFields(order.ResultData, out) + return out, nil +} + +// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 +func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { + if in.OrderNo == "" { + return nil, errors.New("订单号不能为空") + } + var nextStatus string + switch in.ShipStatus { + case "success": + nextStatus = model.FulfillmentStatusSucceeded + case "failed": + nextStatus = model.FulfillmentStatusFailed + case "processing": + nextStatus = model.FulfillmentStatusProcessing + default: + return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing") + } + + order, err := s.GetByOrderNo(in.OrderNo) + if err != nil { + return nil, err + } + + // 已履约成功:success 推送幂等成功 + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.ShipStatus == "success" { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已履约成功,幂等忽略") + return &ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), + Message: "订单已交付,幂等成功", + }, nil + } + + // 已取消不允许再推 + if order.FulfillmentStatus == model.FulfillmentStatusCancelled { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已取消,拒绝更新") + return nil, errors.New("订单已取消,无法更新发货状态") + } + + now := time.Now() + shippedAt := in.ShippedAt + if shippedAt == nil && in.ShipStatus == "success" { + shippedAt = &now + } + + updates := map[string]interface{}{ + "fulfillment_status": nextStatus, + } + var msg string + switch in.ShipStatus { + case "success": + // 仅 pending / failed / processing 可转为 succeeded + if order.FulfillmentStatus != model.FulfillmentStatusPending && + order.FulfillmentStatus != model.FulfillmentStatusFailed && + order.FulfillmentStatus != model.FulfillmentStatusProcessing { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许标记发货成功") + return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus) + } + updates["delivered_at"] = shippedAt + updates["failure_reason"] = "" + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + msg = "发货成功,订单已交付" + case "failed": + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略失败推送") + return nil, errors.New("订单已交付,不能标记发货失败") + } + updates["failure_reason"] = in.FailReason + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + msg = "已记录发货失败" + case "processing": + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "订单已交付,忽略发货中推送") + return &ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: fulfillmentStatusToLegacyStatus(order.FulfillmentStatus), + Message: "订单已交付,忽略 processing", + }, nil + } + if order.FulfillmentStatus != model.FulfillmentStatusPending && + order.FulfillmentStatus != model.FulfillmentStatusFailed && + order.FulfillmentStatus != model.FulfillmentStatusProcessing { + _ = s.writeShipNotifyAudit(order, in, order.FulfillmentStatus, "当前状态不允许进入发货中") + return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.FulfillmentStatus) + } + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + msg = "订单已标记为发货中" + } + + // 游戏相关字段与推送结果合并写入 ResultData,便于后续查询还原(新模型无独立列)。 + updates["result_data"] = buildShipNotifyResultData(order.ResultData, in, shippedAt) + + if err := s.db.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { + return nil, err + } + _ = s.writeShipNotifyAudit(order, in, nextStatus, msg) + + return &ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: fulfillmentStatusToLegacyStatus(nextStatus), + Message: msg, + }, nil +} + +// writeShipNotifyAudit 将上游推送留痕写入 AuditLog(替代旧的 ShipLog 表)。 +func (s *FulfillmentService) writeShipNotifyAudit(order *model.FulfillmentOrder, in ShipNotifyInput, resultStatus, message string) error { + metadata := map[string]interface{}{ + "ship_status": in.ShipStatus, + "provider_order_no": in.ProviderOrderNo, + "fail_reason": in.FailReason, + "result_status": resultStatus, + "message": message, + "payload": in.RawPayload, + } + return writeAudit(s.db, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, metadata) +} + +// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。 +func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string { + m := map[string]interface{}{} + if existing != "" && json.Valid([]byte(existing)) { + _ = json.Unmarshal([]byte(existing), &m) + } + m["ship_status"] = in.ShipStatus + if in.ProviderOrderNo != "" { + m["provider_order_no"] = in.ProviderOrderNo + } + if in.FailReason != "" { + m["fail_reason"] = in.FailReason + } + if shippedAt != nil { + m["shipped_at"] = shippedAt.Format(time.RFC3339) + } + if in.GameChannel != nil { + m["game_channel"] = *in.GameChannel + } + if in.GameUID != nil { + m["game_uid"] = *in.GameUID + } + if in.RoleName != nil { + m["role_name"] = *in.RoleName + } + if in.PayScore != nil { + m["pay_score"] = *in.PayScore + } + raw, err := json.Marshal(m) + if err != nil { + return existing + } + return string(raw) +} + +// extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 +func extractGameFields(raw string, out *OpenOrderQuery) { + if raw == "" || !json.Valid([]byte(raw)) { + return + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return + } + if out.GameChannel == "" { + if v, ok := m["game_channel"].(string); ok { + out.GameChannel = v + } + } + if out.GameUID == "" { + if v, ok := m["game_uid"].(string); ok { + out.GameUID = v + } + } + if out.RoleName == "" { + if v, ok := m["role_name"].(string); ok { + out.RoleName = v + } + } + if out.PayScore == 0 { + if v, ok := toInt(m["pay_score"]); ok { + out.PayScore = v + } + } +} + +func toInt(v interface{}) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + } + return 0, false +} + +// fulfillmentStatusToLegacyStatus 将新的履约状态映射为上游兼容的旧状态字符串。 +func fulfillmentStatusToLegacyStatus(status string) string { + switch status { + case model.FulfillmentStatusPending: + return "paid" + case model.FulfillmentStatusProcessing: + return "delivering" + case model.FulfillmentStatusSucceeded: + return "delivered" + case model.FulfillmentStatusFailed: + return "ship_failed" + case model.FulfillmentStatusCancelled: + return "cancelled" + default: + return status + } +} diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index e57f13c..8cca1dc 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -117,6 +117,80 @@ func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { } } +func TestFulfillmentCreateOrderAppliesMerchantFeeRate(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-rate", 1000, 5, 200) + if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ + "fee_type": model.FeeTypeRate, + "fee_rate_bp": int64(250), + }).Error; err != nil { + t.Fatalf("update merchant fee: %v", err) + } + svc := NewFulfillmentService(db, nil) + + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 21, + ClientOrderNo: "client-fee-rate", + SKU: product.SKU, + Quantity: 2, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + // baseAmount = 200 * 2 = 400; rate 250BP = 400 * 250 / 10000 = 10; total = 410 + if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 10 || created.Order.Amount != 410 { + t.Fatalf("unexpected rate fee snapshot: %+v", created.Order) + } + if created.Order.FeeType != model.FeeTypeRate || created.Order.FeeRateBP != 250 { + t.Fatalf("unexpected rate fee config snapshot: %+v", created.Order) + } + var wallet model.WalletAccount + if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + t.Fatalf("query wallet: %v", err) + } + if wallet.AvailableBalance != 590 { + t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) + } +} + +func TestFulfillmentCreateOrderAppliesMerchantFeeFixed(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-fee-fixed", 1000, 5, 200) + if err := db.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(map[string]interface{}{ + "fee_type": model.FeeTypeFixed, + "fee_fixed_amount": int64(30), + }).Error; err != nil { + t.Fatalf("update merchant fee: %v", err) + } + svc := NewFulfillmentService(db, nil) + + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 22, + ClientOrderNo: "client-fee-fixed", + SKU: product.SKU, + Quantity: 2, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + // baseAmount = 400; fixed fee = 30; total = 430 + if created.Order.BaseAmount != 400 || created.Order.ServiceFeeAmount != 30 || created.Order.Amount != 430 { + t.Fatalf("unexpected fixed fee snapshot: %+v", created.Order) + } + if created.Order.FeeType != model.FeeTypeFixed || created.Order.FeeFixedAmount != 30 { + t.Fatalf("unexpected fixed fee config snapshot: %+v", created.Order) + } + var wallet model.WalletAccount + if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + t.Fatalf("query wallet: %v", err) + } + if wallet.AvailableBalance != 570 { + t.Fatalf("wallet should debit total amount, got %d", wallet.AvailableBalance) + } +} + func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300) diff --git a/backend/internal/service/legacy_tenant_test.go b/backend/internal/service/legacy_tenant_test.go index 7e5da82..1cc05ba 100644 --- a/backend/internal/service/legacy_tenant_test.go +++ b/backend/internal/service/legacy_tenant_test.go @@ -1,58 +1,11 @@ package service import ( - "strings" "testing" "affiliate_dash/internal/model" ) -func TestLegacyOrderCreateRejectsDistributorOutsideMerchant(t *testing.T) { - db := newServiceTestDB(t) - merchant := model.Merchant{Code: "legacy-merchant", Name: "旧后台商户", Status: model.MerchantStatusActive} - if err := db.Create(&merchant).Error; err != nil { - t.Fatalf("create merchant: %v", err) - } - inside := model.User{Username: "inside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "INSIDE"} - outside := model.User{Username: "outside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "OUTSIDE"} - if err := db.Create(&inside).Error; err != nil { - t.Fatalf("create inside user: %v", err) - } - if err := db.Create(&outside).Error; err != nil { - t.Fatalf("create outside user: %v", err) - } - if err := db.Create(&model.MerchantMember{ - MerchantID: merchant.ID, - UserID: inside.ID, - Role: model.MemberRoleOperator, - Status: 1, - }).Error; err != nil { - t.Fatalf("create member: %v", err) - } - skin := model.Skin{ - MerchantID: merchant.ID, - Name: "旧皮肤", - SKU: "legacy-skin", - Price: 10, - Stock: -1, - Status: 1, - } - if err := db.Create(&skin).Error; err != nil { - t.Fatalf("create skin: %v", err) - } - - _, err := NewOrderService(db).Create(CreateOrderInput{ - MerchantID: merchant.ID, - SkinID: skin.ID, - DistributorID: outside.ID, - BuyerName: "买家", - Status: model.OrderStatusPaid, - }) - if err == nil || !strings.Contains(err.Error(), "不属于当前商户") { - t.Fatalf("expected tenant boundary error, got %v", err) - } -} - func TestUserListFiltersByMerchantMembership(t *testing.T) { db := newServiceTestDB(t) merchantA := model.Merchant{Code: "user-merchant-a", Name: "商户 A", Status: model.MerchantStatusActive} @@ -63,8 +16,8 @@ func TestUserListFiltersByMerchantMembership(t *testing.T) { if err := db.Create(&merchantB).Error; err != nil { t.Fatalf("create merchant b: %v", err) } - userA := model.User{Username: "user-a", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERA"} - userB := model.User{Username: "user-b", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERB"} + userA := model.User{Username: "user-a", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} + userB := model.User{Username: "user-b", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} if err := db.Create(&userA).Error; err != nil { t.Fatalf("create user a: %v", err) } @@ -83,7 +36,7 @@ func TestUserListFiltersByMerchantMembership(t *testing.T) { MerchantID: merchantA.ID, Page: 1, Size: 20, - Role: model.RoleDistributor, + Role: model.RoleMerchant, Status: &active, }) if err != nil { diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index e9abb0a..c59e4d6 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -11,6 +11,7 @@ import ( "affiliate_dash/internal/model" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -28,38 +29,60 @@ func NewMerchantService(db *gorm.DB, codec *SecretCodec, tenant *TenantService) } type CreateMerchantInput struct { - Code string - Name string - ContactName string - ContactInfo string - OwnerUserID uint + Code string + Name string + ContactName string + ContactInfo string + OwnerUserID uint + OwnerUsername string + OwnerPassword string + OwnerNickname string + Features string + FeeType string + FeeRateBP int64 + FeeFixedAmount int64 } func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uint) (*model.Merchant, error) { in.Code = strings.ToLower(strings.TrimSpace(in.Code)) in.Name = strings.TrimSpace(in.Name) + in.Features = NormalizeMerchantFeatures(in.Features) if !merchantCodePattern.MatchString(in.Code) { return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符") } if in.Name == "" { return nil, errors.New("商户名称不能为空") } - if in.OwnerUserID == 0 { + if in.OwnerUserID == 0 && strings.TrimSpace(in.OwnerUsername) == "" { return nil, errors.New("商户负责人不能为空") } + if in.FeeRateBP < 0 || in.FeeRateBP > 10000 { + return nil, errors.New("手续费比例需在 0-10000 BP 之间") + } + if in.FeeFixedAmount < 0 { + return nil, errors.New("固定手续费不能小于零") + } + feeType := in.FeeType + if feeType == "" { + feeType = model.FeeTypeRate + } + if feeType != model.FeeTypeRate && feeType != model.FeeTypeFixed { + return nil, errors.New("手续费类型仅支持 rate 或 fixed") + } merchant := &model.Merchant{ - Code: in.Code, - Name: in.Name, - Status: model.MerchantStatusActive, - ContactName: in.ContactName, - ContactInfo: in.ContactInfo, + Code: in.Code, + Name: in.Name, + Status: model.MerchantStatusActive, + ContactName: in.ContactName, + ContactInfo: in.ContactInfo, + Features: in.Features, + FeeType: feeType, + FeeRateBP: in.FeeRateBP, + FeeFixedAmount: in.FeeFixedAmount, } err := s.db.Transaction(func(tx *gorm.DB) error { - var owner model.User - if err := tx.First(&owner, in.OwnerUserID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("商户负责人不存在") - } + owner, err := s.resolveOrCreateOwner(tx, in) + if err != nil { return err } if owner.Status != 1 { @@ -88,6 +111,121 @@ func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uin return merchant, nil } +func (s *MerchantService) resolveOrCreateOwner(tx *gorm.DB, in CreateMerchantInput) (*model.User, error) { + if in.OwnerUserID != 0 { + var owner model.User + if err := tx.First(&owner, in.OwnerUserID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("商户负责人不存在") + } + return nil, err + } + return &owner, nil + } + username := strings.TrimSpace(in.OwnerUsername) + password := strings.TrimSpace(in.OwnerPassword) + nickname := strings.TrimSpace(in.OwnerNickname) + if username == "" || len(username) < 3 { + return nil, errors.New("负责人用户名至少 3 位") + } + if len(password) < 6 { + return nil, errors.New("负责人密码至少 6 位") + } + if nickname == "" { + nickname = username + } + var count int64 + if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("负责人用户名已存在") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + owner := &model.User{ + Username: username, + PasswordHash: string(hash), + Nickname: nickname, + Role: model.RoleMerchant, + Status: 1, + } + if err := tx.Create(owner).Error; err != nil { + return nil, err + } + return owner, nil +} + +type UpdateMerchantSettingsInput struct { + Name *string + Status *string + ContactName *string + ContactInfo *string + Features *string + FeeType *string + FeeRateBP *int64 + FeeFixedAmount *int64 +} + +func (s *MerchantService) UpdateMerchantSettings(merchantID uint, in UpdateMerchantSettingsInput, actorUserID uint) error { + updates := map[string]interface{}{} + if in.Name != nil { + name := strings.TrimSpace(*in.Name) + if name == "" { + return errors.New("商户名称不能为空") + } + updates["name"] = name + } + if in.Status != nil { + if *in.Status != model.MerchantStatusActive && *in.Status != model.MerchantStatusDisabled { + return errors.New("无效的商户状态") + } + updates["status"] = *in.Status + } + if in.ContactName != nil { + updates["contact_name"] = strings.TrimSpace(*in.ContactName) + } + if in.ContactInfo != nil { + updates["contact_info"] = strings.TrimSpace(*in.ContactInfo) + } + if in.Features != nil { + updates["features"] = NormalizeMerchantFeatures(*in.Features) + } + if in.FeeRateBP != nil { + if *in.FeeRateBP < 0 || *in.FeeRateBP > 10000 { + return errors.New("手续费比例需在 0-10000 BP 之间") + } + updates["fee_rate_bp"] = *in.FeeRateBP + } + if in.FeeFixedAmount != nil { + if *in.FeeFixedAmount < 0 { + return errors.New("固定手续费不能小于零") + } + updates["fee_fixed_amount"] = *in.FeeFixedAmount + } + if in.FeeType != nil { + if *in.FeeType != model.FeeTypeRate && *in.FeeType != model.FeeTypeFixed { + return errors.New("手续费类型仅支持 rate 或 fixed") + } + updates["fee_type"] = *in.FeeType + } + if len(updates) == 0 { + return errors.New("没有可更新字段") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("商户不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.settings.update", "merchant", fmt.Sprint(merchantID), updates) + }) +} + func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.Merchant{}) diff --git a/backend/internal/service/order.go b/backend/internal/service/order.go deleted file mode 100644 index 3bd32b4..0000000 --- a/backend/internal/service/order.go +++ /dev/null @@ -1,480 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "time" - - "affiliate_dash/internal/model" - - "gorm.io/gorm" -) - -type OrderService struct { - db *gorm.DB -} - -func NewOrderService(db *gorm.DB) *OrderService { - return &OrderService{db: db} -} - -type OrderListQuery struct { - MerchantID uint - Page int - Size int - Status string - DistributorID *uint -} - -type CreateOrderInput struct { - MerchantID uint - SkinID uint - DistributorID uint - BuyerName string - Remark string - // Status 可选:pending(默认)/ paid(联调测试可直接创建可发货订单) - Status string -} - -func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) { - if q.Page < 1 { - q.Page = 1 - } - if q.Size < 1 || q.Size > 100 { - q.Size = 20 - } - tx := s.db.Model(&model.Order{}) - if q.MerchantID != 0 { - tx = tx.Where("merchant_id = ?", q.MerchantID) - } - if q.Status != "" { - tx = tx.Where("status = ?", q.Status) - } - if q.DistributorID != nil { - tx = tx.Where("distributor_id = ?", *q.DistributorID) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var list []model.Order - err := tx.Preload("Skin").Preload("Distributor"). - Order("id DESC"). - Offset((q.Page - 1) * q.Size).Limit(q.Size). - Find(&list).Error - return list, total, err -} - -func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) { - var skin model.Skin - if in.MerchantID == 0 { - return nil, errors.New("商户不能为空") - } - if err := s.db.Where("id = ? AND merchant_id = ?", in.SkinID, in.MerchantID).First(&skin).Error; err != nil { - return nil, errors.New("皮肤不存在") - } - if skin.Status != 1 { - return nil, errors.New("皮肤已下架") - } - if skin.Stock == 0 { - return nil, errors.New("库存不足") - } - var memberCount int64 - if err := s.db.Model(&model.MerchantMember{}). - Where("merchant_id = ? AND user_id = ? AND status = ?", in.MerchantID, in.DistributorID, 1). - Count(&memberCount).Error; err != nil { - return nil, err - } - if memberCount == 0 { - return nil, errors.New("分销商不属于当前商户") - } - - status := model.OrderStatusPending - switch in.Status { - case model.OrderStatusPending, - model.OrderStatusPaid, - model.OrderStatusDelivering, - model.OrderStatusDelivered, - model.OrderStatusShipFailed, - model.OrderStatusCancelled: - status = in.Status - case "": - // default pending - default: - return nil, errors.New("无效的订单状态") - } - - order := &model.Order{ - MerchantID: in.MerchantID, - OrderNo: generateOrderNo(), - SkinID: in.SkinID, - DistributorID: in.DistributorID, - BuyerName: in.BuyerName, - Amount: skin.Price, - CommissionAmt: skin.Price * skin.Commission, - Status: status, - Remark: in.Remark, - } - - err := s.db.Transaction(func(tx *gorm.DB) error { - if skin.Stock > 0 { - res := tx.Model(&model.Skin{}). - Where("id = ? AND stock > 0", skin.ID). - Update("stock", gorm.Expr("stock - 1")) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errors.New("库存不足") - } - } - return tx.Create(order).Error - }) - if err != nil { - return nil, err - } - // 带回商品信息,方便前端展示 sku / 订单号联调 - _ = s.db.Preload("Skin").First(order, order.ID).Error - return order, nil -} - -func (s *OrderService) UpdateStatus(merchantID, id uint, status string) error { - allowed := map[string]bool{ - model.OrderStatusPending: true, - model.OrderStatusPaid: true, - model.OrderStatusDelivering: true, - model.OrderStatusDelivered: true, - model.OrderStatusShipFailed: true, - model.OrderStatusCancelled: true, - } - if !allowed[status] { - return errors.New("无效的订单状态") - } - res := s.db.Model(&model.Order{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errors.New("订单不存在") - } - return nil -} - -// ----- 开放接口:皮肤源头对接 ----- - -// OpenOrderQuery 开放接口订单查询结果 -type OpenOrderQuery struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - CanShip bool `json:"can_ship"` - CannotShipReason string `json:"cannot_ship_reason,omitempty"` - Product *OpenOrderProduct `json:"product,omitempty"` - BuyerName string `json:"buyer_name"` - Amount float64 `json:"amount"` - ProviderOrderNo string `json:"provider_order_no,omitempty"` - CreatedAt time.Time `json:"created_at"` - ShippedAt *time.Time `json:"shipped_at"` - ShipFailReason string `json:"ship_fail_reason,omitempty"` - GameChannel string `json:"game_channel,omitempty"` - GameUID string `json:"game_uid,omitempty"` - RoleName string `json:"role_name,omitempty"` - PayScore int `json:"pay_score,omitempty"` -} - -type OpenOrderProduct struct { - Name string `json:"name"` - SKU string `json:"sku"` - Game string `json:"game"` -} - -// ShipNotifyInput 上游发货结果推送 -type ShipNotifyInput struct { - OrderNo string - ShipStatus string // success / failed / processing - ProviderOrderNo string - ShippedAt *time.Time - FailReason string - RawPayload string - GameChannel *string - GameUID *string - RoleName *string - PayScore *int -} - -type ShipNotifyResult struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - Message string `json:"message"` -} - -func (s *OrderService) GetByOrderNo(orderNo string) (*model.Order, error) { - var order model.Order - err := s.db.Preload("Skin").Where("order_no = ?", orderNo).First(&order).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("订单不存在") - } - return nil, err - } - return &order, nil -} - -// QueryOpenOrder 供上游查询:商品信息 + 是否可发货 -func (s *OrderService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) { - if orderNo == "" { - return nil, errors.New("订单号不能为空") - } - order, err := s.GetByOrderNo(orderNo) - if err != nil { - return nil, err - } - canShip, reason := evaluateCanShip(order) - out := &OpenOrderQuery{ - OrderNo: order.OrderNo, - Status: order.Status, - CanShip: canShip, - CannotShipReason: reason, - BuyerName: order.BuyerName, - Amount: order.Amount, - ProviderOrderNo: order.ProviderOrderNo, - CreatedAt: order.CreatedAt, - ShippedAt: order.ShippedAt, - ShipFailReason: order.ShipFailReason, - GameChannel: order.GameChannel, - GameUID: order.GameUID, - RoleName: order.RoleName, - PayScore: order.PayScore, - } - if order.Skin != nil { - out.Product = &OpenOrderProduct{ - Name: order.Skin.Name, - SKU: order.Skin.SKU, - Game: order.Skin.Game, - } - } - return out, nil -} - -func evaluateCanShip(order *model.Order) (bool, string) { - switch order.Status { - case model.OrderStatusPaid, model.OrderStatusShipFailed: - return true, "" - case model.OrderStatusPending: - return false, "订单未支付" - case model.OrderStatusDelivering: - return false, "订单发货中" - case model.OrderStatusDelivered: - return false, "订单已发货完成" - case model.OrderStatusCancelled: - return false, "订单已取消" - default: - return false, "当前状态不可发货: " + order.Status - } -} - -// HandleShipNotify 处理上游发货结果推送(幂等) -func (s *OrderService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { - if in.OrderNo == "" { - return nil, errors.New("订单号不能为空") - } - switch in.ShipStatus { - case model.ShipNotifySuccess, model.ShipNotifyFailed, model.ShipNotifyProcessing: - default: - return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing") - } - - order, err := s.GetByOrderNo(in.OrderNo) - if err != nil { - return nil, err - } - - // 已交付:success 推送幂等成功 - if order.Status == model.OrderStatusDelivered && in.ShipStatus == model.ShipNotifySuccess { - _ = s.appendShipLog(order, in, order.Status, "订单已是已交付状态,幂等忽略") - return &ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: order.Status, - Message: "订单已交付,幂等成功", - }, nil - } - - // 已取消不允许再推成功 - if order.Status == model.OrderStatusCancelled { - _ = s.appendShipLog(order, in, order.Status, "订单已取消,拒绝更新") - return nil, errors.New("订单已取消,无法更新发货状态") - } - - now := time.Now() - shippedAt := in.ShippedAt - if shippedAt == nil && in.ShipStatus == model.ShipNotifySuccess { - shippedAt = &now - } - - updates := map[string]interface{}{} - var nextStatus string - var msg string - - switch in.ShipStatus { - case model.ShipNotifySuccess: - // 仅 paid / ship_failed / delivering 可转为 delivered - if order.Status != model.OrderStatusPaid && - order.Status != model.OrderStatusShipFailed && - order.Status != model.OrderStatusDelivering { - _ = s.appendShipLog(order, in, order.Status, "当前状态不允许标记发货成功") - return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.Status) - } - nextStatus = model.OrderStatusDelivered - updates["status"] = nextStatus - updates["shipped_at"] = shippedAt - updates["ship_fail_reason"] = "" - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "发货成功,订单已交付" - case model.ShipNotifyFailed: - if order.Status == model.OrderStatusDelivered { - _ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略失败推送") - return nil, errors.New("订单已交付,不能标记发货失败") - } - nextStatus = model.OrderStatusShipFailed - updates["status"] = nextStatus - updates["ship_fail_reason"] = in.FailReason - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "已记录发货失败" - case model.ShipNotifyProcessing: - if order.Status == model.OrderStatusDelivered { - _ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略发货中推送") - return &ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: order.Status, - Message: "订单已交付,忽略 processing", - }, nil - } - if order.Status != model.OrderStatusPaid && - order.Status != model.OrderStatusShipFailed && - order.Status != model.OrderStatusDelivering { - _ = s.appendShipLog(order, in, order.Status, "当前状态不允许进入发货中") - return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.Status) - } - nextStatus = model.OrderStatusDelivering - updates["status"] = nextStatus - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - msg = "订单已标记为发货中" - } - - if in.GameChannel != nil { - updates["game_channel"] = *in.GameChannel - } - if in.GameUID != nil { - updates["game_uid"] = *in.GameUID - } - if in.RoleName != nil { - updates["role_name"] = *in.RoleName - } - if in.PayScore != nil { - updates["pay_score"] = *in.PayScore - } - - if err := s.db.Model(&model.Order{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { - return nil, err - } - _ = s.appendShipLog(order, in, nextStatus, msg) - - return &ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: nextStatus, - Message: msg, - }, nil -} - -func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, resultStatus, message string) error { - payload := in.RawPayload - if payload == "" { - b, _ := json.Marshal(in) - payload = string(b) - } - log := &model.ShipLog{ - MerchantID: order.MerchantID, - OrderNo: order.OrderNo, - OrderID: order.ID, - ShipStatus: in.ShipStatus, - ProviderOrderNo: in.ProviderOrderNo, - FailReason: in.FailReason, - Payload: payload, - ResultStatus: resultStatus, - Message: message, - } - return s.db.Create(log).Error -} - -type ShipLogListQuery struct { - MerchantID uint - Page int - Size int - OrderNo string - ShipStatus string -} - -func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64, error) { - if q.Page < 1 { - q.Page = 1 - } - if q.Size < 1 || q.Size > 100 { - q.Size = 20 - } - tx := s.db.Model(&model.ShipLog{}) - if q.MerchantID != 0 { - tx = tx.Where("merchant_id = ?", q.MerchantID) - } - if q.OrderNo != "" { - tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%") - } - if q.ShipStatus != "" { - tx = tx.Where("ship_status = ?", q.ShipStatus) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var list []model.ShipLog - err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error - return list, total, err -} - -type DashboardStats struct { - SkinCount int64 `json:"skin_count"` - DistributorCount int64 `json:"distributor_count"` - OrderCount int64 `json:"order_count"` - TotalSales float64 `json:"total_sales"` - TotalCommission float64 `json:"total_commission"` - PendingOrderCount int64 `json:"pending_order_count"` -} - -func (s *OrderService) Dashboard(merchantID uint) (*DashboardStats, error) { - stats := &DashboardStats{} - s.db.Model(&model.Skin{}).Where("merchant_id = ?", merchantID).Count(&stats.SkinCount) - s.db.Model(&model.User{}). - Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). - Where("merchant_members.merchant_id = ? AND users.role = ?", merchantID, model.RoleDistributor). - Count(&stats.DistributorCount) - s.db.Model(&model.Order{}).Where("merchant_id = ?", merchantID).Count(&stats.OrderCount) - s.db.Model(&model.Order{}).Where("merchant_id = ? AND status = ?", merchantID, model.OrderStatusPending).Count(&stats.PendingOrderCount) - s.db.Model(&model.Order{}). - Where("merchant_id = ?", merchantID). - Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). - Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales) - s.db.Model(&model.Order{}). - Where("merchant_id = ?", merchantID). - Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). - Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission) - return stats, nil -} - -func generateOrderNo() string { - return fmt.Sprintf("O%s%04d", time.Now().Format("20060102150405"), time.Now().Nanosecond()%10000) -} diff --git a/backend/internal/service/skin.go b/backend/internal/service/skin.go deleted file mode 100644 index 7a4649a..0000000 --- a/backend/internal/service/skin.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "errors" - - "affiliate_dash/internal/model" - - "gorm.io/gorm" -) - -type SkinService struct { - db *gorm.DB -} - -func NewSkinService(db *gorm.DB) *SkinService { - return &SkinService{db: db} -} - -type SkinListQuery struct { - MerchantID uint - Page int - Size int - Keyword string - Game string - Category string - Status *int -} - -func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) { - if q.Page < 1 { - q.Page = 1 - } - if q.Size < 1 || q.Size > 100 { - q.Size = 20 - } - tx := s.db.Model(&model.Skin{}) - if q.MerchantID != 0 { - tx = tx.Where("merchant_id = ?", q.MerchantID) - } - if q.Keyword != "" { - like := "%" + q.Keyword + "%" - tx = tx.Where("name LIKE ? OR sku LIKE ?", like, like) - } - if q.Game != "" { - tx = tx.Where("game = ?", q.Game) - } - if q.Category != "" { - tx = tx.Where("category = ?", q.Category) - } - if q.Status != nil { - tx = tx.Where("status = ?", *q.Status) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var list []model.Skin - err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error - return list, total, err -} - -func (s *SkinService) Get(merchantID, id uint) (*model.Skin, error) { - var skin model.Skin - tx := s.db.Where("id = ?", id) - if merchantID != 0 { - tx = tx.Where("merchant_id = ?", merchantID) - } - if err := tx.First(&skin).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("皮肤不存在") - } - return nil, err - } - return &skin, nil -} - -func (s *SkinService) Create(skin *model.Skin) error { - return s.db.Create(skin).Error -} - -func (s *SkinService) Update(merchantID, id uint, updates map[string]interface{}) error { - res := s.db.Model(&model.Skin{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errors.New("皮肤不存在") - } - return nil -} - -func (s *SkinService) Delete(merchantID, id uint) error { - res := s.db.Where("merchant_id = ?", merchantID).Delete(&model.Skin{}, id) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errors.New("皮肤不存在") - } - return nil -} - -// SeedCatalog 按 sku 幂等导入商品目录(已存在则跳过) -func (s *SkinService) SeedCatalog() error { - var merchant model.Merchant - if err := s.db.Where("code = ?", "self-operated").First(&merchant).Error; err != nil { - return err - } - // 清理早期无 sku 的演示数据,避免唯一索引冲突 - _ = s.db.Where("merchant_id = ? AND (sku = ? OR sku IS NULL)", merchant.ID, "").Delete(&model.Skin{}).Error - - for _, item := range peaceEliteCatalog { - var existing model.Skin - err := s.db.Where("merchant_id = ? AND sku = ?", merchant.ID, item.SKU).First(&existing).Error - if err == nil { - // 已存在:仅同步默认佣金为 0(不改价格等业务字段) - if existing.Commission != 0 { - _ = s.db.Model(&existing).Update("commission", 0).Error - } - continue - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - skin := model.Skin{ - MerchantID: merchant.ID, - Name: item.Name, - SKU: item.SKU, - Game: "和平精英", - Category: item.Category, - Price: 0, - CostPrice: 0, - Commission: 0, - Stock: -1, - Status: 1, - } - if err := s.db.Create(&skin).Error; err != nil { - return err - } - } - return nil -} - -type catalogItem struct { - Name string - SKU string - Category string -} - -// 和平精英商品目录(中文名保持原样,英文名为固定 sku) -var peaceEliteCatalog = []catalogItem{ - {Name: "套装-Alan Walker", SKU: "suit_alan_walker", Category: "套装"}, - {Name: "套装-暗影哥特", SKU: "suit_shadow_gothic", Category: "套装"}, - {Name: "黑色高级特训官上衣", SKU: "top_black_elite_trainer", Category: "上衣"}, - {Name: "M416-仓鼠灰灰", SKU: "m416_hamster_gray", Category: "枪械"}, - {Name: "萌熊伴侣背包", SKU: "bag_cute_bear", Category: "背包"}, - {Name: "套装-双彩绵绵", SKU: "suit_dual_fluffy", Category: "套装"}, - {Name: "套装-糯粉咩咩", SKU: "suit_pink_sheep", Category: "套装"}, - {Name: "套装-恋恋初桃", SKU: "suit_first_peach", Category: "套装"}, - {Name: "套装-浪漫天命", SKU: "suit_romantic_destiny", Category: "套装"}, - {Name: "西部牛仔大礼包", SKU: "pack_western_cowboy", Category: "礼包"}, - {Name: "烟雾弹-糯粉咩咩", SKU: "smoke_pink_sheep", Category: "投掷物"}, - {Name: "破片手榴弹-糯粉咩咩", SKU: "frag_pink_sheep", Category: "投掷物"}, - {Name: "套装-仓鼠灰灰", SKU: "suit_hamster_gray", Category: "套装"}, - {Name: "套装-萌熊伴侣", SKU: "suit_cute_bear", Category: "套装"}, - {Name: "糯粉咩咩背包", SKU: "bag_pink_sheep", Category: "背包"}, - {Name: "糯粉咩咩头盔", SKU: "helmet_pink_sheep", Category: "头盔"}, - {Name: "仓鼠灰灰背包", SKU: "bag_hamster_gray", Category: "背包"}, - {Name: "仓鼠灰灰头盔", SKU: "helmet_hamster_gray", Category: "头盔"}, - {Name: "套装-西部谜踪", SKU: "suit_western_mystery", Category: "套装"}, - {Name: "国宝胖达头盔", SKU: "helmet_panda_treasure", Category: "头盔"}, - {Name: "套装-胖达圆圆", SKU: "suit_panda_round", Category: "套装"}, - {Name: "套装-胖达团团", SKU: "suit_panda_tuan", Category: "套装"}, - {Name: "熔岩游骑兵礼包", SKU: "pack_lava_ranger", Category: "礼包"}, - {Name: "套装-狂沙舞者", SKU: "suit_sand_dancer", Category: "套装"}, - {Name: "星际漫游服装礼包", SKU: "pack_star_roam_outfit", Category: "礼包"}, - {Name: "星际漫游枪械礼包", SKU: "pack_star_roam_weapon", Category: "礼包"}, - {Name: "套装-绵云熊熊", SKU: "suit_cloud_bear", Category: "套装"}, -} diff --git a/backend/internal/service/tenant.go b/backend/internal/service/tenant.go index 66898dc..90021a6 100644 --- a/backend/internal/service/tenant.go +++ b/backend/internal/service/tenant.go @@ -118,3 +118,46 @@ func HasAnyScope(scopes string, wanted ...string) bool { } return false } + +func NormalizeMerchantFeatures(features string) string { + set := ParseScopes(features) + if len(set) == 0 { + set = ParseScopes(model.DefaultMerchantFeatures) + } + valid := map[string]struct{}{ + model.MerchantFeatureProducts: {}, + model.MerchantFeatureOrders: {}, + model.MerchantFeatureWallet: {}, + model.MerchantFeatureAPI: {}, + model.MerchantFeatureCallbacks: {}, + } + ordered := []string{ + model.MerchantFeatureProducts, + model.MerchantFeatureOrders, + model.MerchantFeatureWallet, + model.MerchantFeatureAPI, + model.MerchantFeatureCallbacks, + } + out := make([]string, 0, len(ordered)) + for _, feature := range ordered { + if _, ok := set[feature]; ok { + if _, allowed := valid[feature]; allowed { + out = append(out, feature) + } + } + } + if len(out) == 0 { + return model.DefaultMerchantFeatures + } + return strings.Join(out, ",") +} + +func MerchantHasFeature(features string, wanted ...string) bool { + set := ParseScopes(features) + for _, feature := range wanted { + if _, ok := set[feature]; ok { + return true + } + } + return false +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 5a03281..edf8081 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -58,14 +58,17 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { return list, total, err } -func (s *UserService) Create(username, password, nickname, role string, parentID *uint, merchantID uint) (*model.User, error) { +func (s *UserService) Create(username, password, nickname, role string, merchantID uint) (*model.User, error) { var count int64 s.db.Model(&model.User{}).Where("username = ?", username).Count(&count) if count > 0 { return nil, errors.New("用户名已存在") } if role == "" { - role = model.RoleDistributor + role = model.RoleMerchant + } + if role != model.RoleAdmin && role != model.RoleMerchant { + return nil, errors.New("无效的账号角色") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -77,8 +80,6 @@ func (s *UserService) Create(username, password, nickname, role string, parentID Nickname: nickname, Role: role, Status: 1, - InviteCode: generateInviteCode(), - ParentID: parentID, } if user.Nickname == "" { user.Nickname = username diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aee2232..74ed4eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,15 +6,10 @@ import MainLayout from './layouts/MainLayout' import Login from './pages/Login' import Register from './pages/Register' import Dashboard from './pages/Dashboard' -import Skins from './pages/Skins' -import Orders from './pages/Orders' -import Distributors from './pages/Distributors' -import ShipLogs from './pages/ShipLogs' import OpenApiDocs from './pages/OpenApiDocs' import MerchantCenter from './pages/MerchantCenter' import PlatformMerchants from './pages/PlatformMerchants' import type { ReactNode } from 'react' - function PrivateRoute({ children }: { children: ReactNode }) { const { token } = useAuth() if (!token) return @@ -41,17 +36,7 @@ function AppRoutes() { } > } /> - } /> - } /> } /> - - - - } - /> } /> - - - - } - /> r.data.data as DashboardStats), } -export const skinApi = { - list: (params?: Record) => - request.get('/skins', { params }).then((r) => r.data.data as PageResult), - get: (id: number) => - request.get(`/skins/${id}`).then((r) => r.data.data as Skin), - create: (data: Partial) => - request.post('/skins', data).then((r) => r.data.data as Skin), - update: (id: number, data: Partial) => - request.put(`/skins/${id}`, data).then((r) => r.data.data), - remove: (id: number) => - request.delete(`/skins/${id}`).then((r) => r.data.data), -} - -export const orderApi = { - list: (params?: Record) => - request.get('/orders', { params }).then((r) => r.data.data as PageResult), - create: (data: { - skin_id: number - buyer_name?: string - remark?: string - status?: string - distributor_id?: number - }) => request.post('/orders', data).then((r) => r.data.data as Order), - updateStatus: (id: number, status: string) => - request.patch(`/orders/${id}/status`, { status }).then((r) => r.data.data), -} - export const userApi = { list: (params?: Record) => request.get('/users', { params }).then((r) => r.data.data as PageResult), @@ -75,11 +45,6 @@ export const userApi = { request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data), } -export const shipLogApi = { - list: (params?: Record) => - request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult), -} - export const merchantApi = { current: () => request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }), @@ -132,8 +97,17 @@ export const platformApi = { name: string contact_name?: string contact_info?: string - owner_user_id: number + owner_user_id?: number + owner_username?: string + owner_password?: string + owner_nickname?: string + features?: string + fee_type?: 'rate' | 'fixed' + fee_rate_bp?: number + fee_fixed_amount?: number }) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant), + updateMerchant: (id: number, data: Partial) => + request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data), addMember: ( merchantId: number, data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }, diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 3051d2d..478ca15 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -11,14 +11,10 @@ import { } from 'antd' import { DashboardOutlined, - SkinOutlined, - ShoppingOutlined, - TeamOutlined, UserOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - SendOutlined, ApiOutlined, ShopOutlined, } from '@ant-design/icons' @@ -39,15 +35,11 @@ export default function MainLayout() { const menuItems: MenuProps['items'] = useMemo(() => { const items: MenuProps['items'] = [ { key: '/', icon: , label: '数据概览' }, - { key: '/skins', icon: , label: '皮肤商品' }, - { key: '/orders', icon: , label: '订单管理' }, { key: '/merchant-center', icon: , label: '商户中心' }, ] if (isAdmin) { items.push( - { key: '/distributors', icon: , label: '分销商' }, { key: '/platform-merchants', icon: , label: '商户管理' }, - { key: '/ship-logs', icon: , label: '发货记录' }, { key: '/open-api', icon: , label: '开放接口' }, ) } @@ -81,7 +73,7 @@ export default function MainLayout() { letterSpacing: 1, }} > - {collapsed ? '皮肤' : '皮肤分销系统'} + {collapsed ? '供货' : '皮肤供货平台'} } /> {user?.nickname || user?.username} - {isAdmin ? '(管理员)' : '(分销商)'} + {isAdmin ? '(平台管理员)' : '(商户账号)'} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c29ddbb..12b0839 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react' import { Card, Col, Row, Statistic, Typography, Spin, message } from 'antd' import { - SkinOutlined, - TeamOutlined, + ShopOutlined, ShoppingOutlined, DollarOutlined, PercentageOutlined, @@ -39,12 +38,12 @@ export default function Dashboard() { - } /> + } /> - } /> + } /> @@ -66,8 +65,8 @@ export default function Dashboard() { } suffix="元" diff --git a/frontend/src/pages/Distributors.tsx b/frontend/src/pages/Distributors.tsx deleted file mode 100644 index d85b1db..0000000 --- a/frontend/src/pages/Distributors.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { - Button, - Form, - Input, - Modal, - Space, - Switch, - Table, - Tag, - Typography, - message, -} from 'antd' -import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' -import dayjs from 'dayjs' -import { userApi } from '../api' -import type { User } from '../types' - -export default function Distributors() { - const [list, setList] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [size, setSize] = useState(10) - const [keyword, setKeyword] = useState('') - const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) - const [form] = Form.useForm() - - const load = useCallback(async () => { - setLoading(true) - try { - const data = await userApi.list({ - page, - size, - keyword, - role: 'distributor', - }) - setList(data.list || []) - setTotal(data.total) - } catch (e) { - message.error(e instanceof Error ? e.message : '加载失败') - } finally { - setLoading(false) - } - }, [page, size, keyword]) - - useEffect(() => { - load() - }, [load]) - - const onSubmit = async () => { - const values = await form.validateFields() - try { - await userApi.create({ ...values, role: 'distributor' }) - message.success('创建成功') - setOpen(false) - form.resetFields() - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') - } - } - - const toggleStatus = async (record: User, checked: boolean) => { - try { - await userApi.updateStatus(record.id, checked ? 1 : 0) - message.success('状态已更新') - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '更新失败') - } - } - - const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '用户名', dataIndex: 'username' }, - { title: '昵称', dataIndex: 'nickname' }, - { - title: '邀请码', - dataIndex: 'invite_code', - render: (v: string) => {v}, - }, - { - title: '状态', - dataIndex: 'status', - width: 120, - render: (v: number, record) => ( - toggleStatus(record, checked)} - /> - ), - }, - { - title: '注册时间', - dataIndex: 'created_at', - width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - ] - - return ( -
- - - 分销商管理 - - - { - setPage(1) - setKeyword(v) - }} - style={{ width: 220 }} - /> - - - - - - { - setPage(p) - setSize(s) - }, - }} - /> - - setOpen(false)} destroyOnClose> -
- - - - - - - - - - -
- - ) -} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 2f82c61..aca3966 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -36,7 +36,7 @@ export default function Login() {
- 游戏皮肤分销系统 + 游戏皮肤供货平台 登录后台管理
@@ -55,7 +55,7 @@ export default function Login() {
- 还没有账号? 注册分销商 + 还没有账号? 注册商户账号
diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index a9a1e67..a14460a 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Descriptions, @@ -103,11 +103,14 @@ export default function MerchantCenter() { const canManage = merchantRole === 'owner' || merchantRole === 'operator' const canFinance = merchantRole === 'owner' || merchantRole === 'finance' + const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features]) + const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant]) const loadCurrent = useCallback(async () => { const data = await merchantApi.current() setMerchant(data.merchant) setMerchantRole(data.role) + return data }, []) const loadProducts = useCallback(async (page = products.page, size = products.size) => { @@ -120,37 +123,86 @@ export default function MerchantCenter() { setOrders(data) }, [orders.page, orders.size]) - const loadWallet = useCallback(async (page = ledger.page, size = ledger.size) => { - const [walletData, ledgerData] = await Promise.all([ - merchantApi.wallet(), - merchantApi.ledger({ page, size }), - ]) + const loadWallet = useCallback(async ( + page = ledger.page, + size = ledger.size, + role = merchantRole, + ) => { + const walletData = await merchantApi.wallet() setWallet(walletData) - setLedger(ledgerData) - }, [ledger.page, ledger.size]) + if (role === 'owner' || role === 'finance') { + const ledgerData = await merchantApi.ledger({ page, size }) + setLedger(ledgerData) + } else { + setLedger({ list: [], total: 0, page, size }) + } + }, [ledger.page, ledger.size, merchantRole]) - const loadIntegrations = useCallback(async () => { - const [clientData, callbackData, memberData] = await Promise.all([ - merchantApi.apiClients(), - merchantApi.callbacks(), - merchantApi.members(), - ]) + const loadAPIClients = useCallback(async (role = merchantRole) => { + if (role !== 'owner' && role !== 'operator') { + setApiClients([]) + return + } + const clientData = await merchantApi.apiClients() setApiClients(clientData || []) + }, [merchantRole]) + + const loadCallbacks = useCallback(async (role = merchantRole) => { + if (role !== 'owner' && role !== 'operator') { + setCallbacks([]) + return + } + const callbackData = await merchantApi.callbacks() setCallbacks(callbackData || []) + }, [merchantRole]) + + const loadMembers = useCallback(async (role = merchantRole) => { + if (role !== 'owner' && role !== 'operator') { + setMembers([]) + return + } + const memberData = await merchantApi.members() setMembers(memberData || []) - }, []) + }, [merchantRole]) + + const loadActiveTab = useCallback(async (role = merchantRole, tab = activeTab) => { + switch (tab) { + case 'products': + await loadProducts() + return + case 'orders': + await loadOrders() + return + case 'wallet': + await loadWallet(undefined, undefined, role) + return + case 'api': + await loadAPIClients(role) + return + case 'callbacks': + await loadCallbacks(role) + return + case 'members': + await loadMembers(role) + return + } + }, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantRole]) const loadAll = useCallback(async () => { setLoading(true) try { - await loadCurrent() - await Promise.all([loadProducts(), loadOrders(), loadWallet(), loadIntegrations()]) + const current = await loadCurrent() + const nextTab = resolveEnabledTab(activeTab, current.merchant.features) + if (nextTab !== activeTab) { + setActiveTab(nextTab) + } + await loadActiveTab(current.role, nextTab) } catch (e) { message.error(e instanceof Error ? e.message : '加载失败') } finally { setLoading(false) } - }, [loadCurrent, loadProducts, loadOrders, loadWallet, loadIntegrations]) + }, [activeTab, loadActiveTab, loadCurrent]) useEffect(() => { loadAll() @@ -232,7 +284,7 @@ export default function MerchantCenter() { setApiCredential(credential) setApiClientOpen(false) message.success('API 客户端已创建') - loadIntegrations() + loadAPIClients() } catch (e) { message.error(e instanceof Error ? e.message : '创建失败') } @@ -249,7 +301,7 @@ export default function MerchantCenter() { setCallbackCredential(credential) setCallbackOpen(false) message.success('回调已创建') - loadIntegrations() + loadCallbacks() } catch (e) { message.error(e instanceof Error ? e.message : '创建失败') } @@ -265,7 +317,7 @@ export default function MerchantCenter() { }) setMemberOpen(false) message.success('成员已添加') - loadIntegrations() + loadMembers() } catch (e) { message.error(e instanceof Error ? e.message : '添加失败') } @@ -275,7 +327,7 @@ export default function MerchantCenter() { try { await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active') message.success('状态已更新') - loadIntegrations() + loadAPIClients() } catch (e) { message.error(e instanceof Error ? e.message : '更新失败') } @@ -285,7 +337,7 @@ export default function MerchantCenter() { try { await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active') message.success('状态已更新') - loadIntegrations() + loadCallbacks() } catch (e) { message.error(e instanceof Error ? e.message : '更新失败') } @@ -314,7 +366,9 @@ export default function MerchantCenter() { { title: '商户单号', dataIndex: 'client_order_no', width: 160, ellipsis: true }, { title: 'SKU', dataIndex: 'product_sku', width: 150, render: (v) => {v} }, { title: '商品', dataIndex: 'product_name', ellipsis: true }, - { title: '金额', dataIndex: 'amount', width: 100, render: money }, + { title: '基础金额', dataIndex: 'base_amount', width: 100, render: money }, + { title: '手续费', dataIndex: 'service_fee_amount', width: 100, render: money }, + { title: '扣款合计', dataIndex: 'amount', width: 100, render: money }, { title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag }, { title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag }, { title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' }, @@ -399,6 +453,7 @@ export default function MerchantCenter() { { key: 'products', label: '商品', + disabled: !hasFeature('products'), children: ( @@ -419,6 +474,7 @@ export default function MerchantCenter() { { key: 'orders', label: '履约订单', + disabled: !hasFeature('orders'), children: (
@@ -455,20 +512,25 @@ export default function MerchantCenter() { )} -
+ {canFinance ? ( +
+ ) : ( + 当前角色可查看余额,钱包流水仅财务或负责人可见。 + )} ), }, { key: 'api', label: 'API 客户端', + disabled: !hasFeature('api'), children: ( @@ -486,6 +548,7 @@ export default function MerchantCenter() { { key: 'callbacks', label: '回调', + disabled: !hasFeature('callbacks'), children: ( @@ -679,6 +742,31 @@ function centsToYuan(value?: number | null) { return Number(((value || 0) / 100).toFixed(2)) } +function featuresToList(features?: string) { + const list = (features || '') + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean) + return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks'] +} + +function resolveEnabledTab(current: string, features?: string) { + const enabled = new Set(featuresToList(features)) + const tabFeatures: Record = { + products: 'products', + orders: 'orders', + wallet: 'wallet', + api: 'api', + callbacks: 'callbacks', + members: null, + } + const feature = tabFeatures[current] + if (feature === null || enabled.has(feature)) { + return current + } + return ['products', 'orders', 'wallet', 'api', 'callbacks'].find((key) => enabled.has(key)) || 'members' +} + function money(value?: number | null) { return `¥${centsToYuan(value).toFixed(2)}` } diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx deleted file mode 100644 index 502e478..0000000 --- a/frontend/src/pages/Orders.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { - Button, - Form, - Input, - Modal, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd' -import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' -import dayjs from 'dayjs' -import { orderApi, skinApi } from '../api' -import { useAuth } from '../store/auth' -import type { Order, Skin } from '../types' - -const statusMap: Record = { - pending: { color: 'orange', text: '待支付' }, - paid: { color: 'blue', text: '已支付' }, - delivering: { color: 'cyan', text: '发货中' }, - delivered: { color: 'green', text: '已交付' }, - ship_failed: { color: 'red', text: '发货失败' }, - cancelled: { color: 'default', text: '已取消' }, -} - -export default function Orders() { - const { isAdmin } = useAuth() - const [list, setList] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [size, setSize] = useState(10) - const [status, setStatus] = useState() - const [loading, setLoading] = useState(false) - const [createOpen, setCreateOpen] = useState(false) - const [skins, setSkins] = useState([]) - const [creating, setCreating] = useState(false) - const [createdOrder, setCreatedOrder] = useState(null) - const [form] = Form.useForm() - - const load = useCallback(async () => { - setLoading(true) - try { - const data = await orderApi.list({ page, size, status }) - setList(data.list || []) - setTotal(data.total) - } catch (e) { - message.error(e instanceof Error ? e.message : '加载失败') - } finally { - setLoading(false) - } - }, [page, size, status]) - - useEffect(() => { - load() - }, [load]) - - const openCreate = async () => { - form.resetFields() - form.setFieldsValue({ - buyer_name: '测试买家', - status: 'paid', - remark: '联调测试订单', - }) - setCreatedOrder(null) - setCreateOpen(true) - try { - const data = await skinApi.list({ page: 1, size: 100, status: 1 }) - setSkins(data.list || []) - } catch (e) { - message.error(e instanceof Error ? e.message : '加载商品失败') - } - } - - const onCreate = async () => { - const values = await form.validateFields() - setCreating(true) - try { - const order = await orderApi.create({ - skin_id: values.skin_id, - buyer_name: values.buyer_name, - remark: values.remark, - // 管理员可指定任意初始状态,方便造异常单 - status: isAdmin ? values.status : undefined, - }) - setCreatedOrder(order) - message.success(`测试订单已创建:${order.order_no}`) - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') - } finally { - setCreating(false) - } - } - - const copyText = async (text: string, tip = '已复制') => { - try { - await navigator.clipboard.writeText(text) - message.success(tip) - } catch { - message.error('复制失败,请手动选择') - } - } - - const changeStatus = async (id: number, next: string) => { - try { - await orderApi.updateStatus(id, next) - message.success('状态已更新') - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '更新失败') - } - } - - const columns: ColumnsType = [ - { - title: '店铺订单号', - dataIndex: 'order_no', - width: 210, - ellipsis: true, - render: (v: string) => ( - - - {v} - - - ), - }, - { - title: '皮肤', - dataIndex: ['skin', 'name'], - width: 140, - ellipsis: true, - render: (_, r) => r.skin?.name || `#${r.skin_id}`, - }, - { - title: 'SKU', - width: 150, - ellipsis: true, - render: (_, r) => - r.skin?.sku ? ( - - {r.skin.sku} - - ) : ( - '-' - ), - }, - { - title: '分销商', - dataIndex: ['distributor', 'nickname'], - width: 100, - ellipsis: true, - render: (_, r) => r.distributor?.nickname || r.distributor?.username || `#${r.distributor_id}`, - }, - { title: '买家', dataIndex: 'buyer_name', width: 90, ellipsis: true }, - { - title: '金额', - dataIndex: 'amount', - width: 90, - render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`, - }, - { - title: '状态', - dataIndex: 'status', - width: 100, - render: (v: string) => { - const s = statusMap[v] || { color: 'default', text: v } - return {s.text} - }, - }, - { - title: '上游单号', - dataIndex: 'provider_order_no', - width: 120, - ellipsis: true, - render: (v?: string) => v || '-', - }, - { - title: '时间', - dataIndex: 'created_at', - width: 160, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - ] - - if (isAdmin) { - columns.push({ - title: '操作', - key: 'action', - width: 200, - fixed: 'right', - render: (_, record) => ( - - {record.status === 'pending' && ( - <> - - - - )} - {(record.status === 'paid' || - record.status === 'ship_failed' || - record.status === 'delivering') && ( - - )} - - ), - }) - } - - return ( -
- -
- - 订单管理 - - - 店铺订单号 order_no 给源头查询发货;已支付订单 can_ship=true - -
- -
`共 ${t} 条`, - onChange: (p, s) => { - setPage(p) - setSize(s) - }, - }} - /> - - setCreateOpen(false)} - footer={ - createdOrder - ? [ - , - , - ] - : [ - , - , - ] - } - destroyOnClose - width={560} - > - {createdOrder ? ( -
- - 订单已创建。把下面的 店铺订单号{' '} - 发给源头,或用开放接口查询: - - -
-
店铺订单号 order_no
- - - {createdOrder.order_no} - - - -
-
-
商品:{createdOrder.skin?.name || `#${createdOrder.skin_id}`}
-
- SKU: - - {createdOrder.skin?.sku || '-'} - -
-
- 状态: - - {statusMap[createdOrder.status]?.text || createdOrder.status} - - {(createdOrder.status === 'paid' || createdOrder.status === 'ship_failed') && ( - (可发货 can_ship=true) - )} - {createdOrder.status !== 'paid' && createdOrder.status !== 'ship_failed' && ( - (不可发货 can_ship=false) - )} -
-
买家:{createdOrder.buyer_name}
-
- - 源头查询:GET /api/open/v1/orders/{createdOrder.order_no} -
- 需带 X-Api-Key / X-Timestamp / X-Nonce / X-Sign -
-
-
- ) : ( -
- - - - - - - {isAdmin && ( - - @@ -174,15 +255,92 @@ export default function PlatformMerchants() { - - - - - - + + + + + + + + + + + + + + + + + + + + prev.fee_type !== cur.fee_type}> + {({ getFieldValue }) => + getFieldValue('fee_type') === 'fixed' ? ( + + + + ) : ( + + + + ) + } + + +
+ + setSettingsOpen(false)} destroyOnClose width={680}> +
+ + + + + + + + + + + + + + + prev.fee_type !== cur.fee_type}> + {({ getFieldValue }) => + getFieldValue('fee_type') === 'fixed' ? ( + + + + ) : ( + + + + ) + } +
@@ -208,3 +366,26 @@ export default function PlatformMerchants() { ) } + +function featuresToList(features?: string) { + return (features || '') + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean) +} + +function featureListToText(features?: string[]) { + return (features || []).join(',') +} + +function featureText(feature: string) { + return featureOptions.find((item) => item.value === feature)?.label || feature +} + +function yuanToCents(value?: number | null) { + return Math.round(Number(value || 0) * 100) +} + +function centsToYuan(value?: number | null) { + return Number(((value || 0) / 100).toFixed(2)) +} diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 923d3b5..518693a 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -39,9 +39,9 @@ export default function Register() {
- 注册分销商 + 注册商户账号 - 创建分销账号 + 创建商户员工账号
diff --git a/frontend/src/pages/ShipLogs.tsx b/frontend/src/pages/ShipLogs.tsx deleted file mode 100644 index f48511a..0000000 --- a/frontend/src/pages/ShipLogs.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { - Button, - Input, - Modal, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd' -import { ReloadOutlined } from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' -import dayjs from 'dayjs' -import { shipLogApi } from '../api' -import type { ShipLog } from '../types' - -const shipStatusMap: Record = { - success: { color: 'green', text: '成功' }, - failed: { color: 'red', text: '失败' }, - processing: { color: 'cyan', text: '发货中' }, -} - -const orderStatusMap: Record = { - pending: { color: 'orange', text: '待支付' }, - paid: { color: 'blue', text: '已支付' }, - delivering: { color: 'cyan', text: '发货中' }, - delivered: { color: 'green', text: '已交付' }, - ship_failed: { color: 'red', text: '发货失败' }, - cancelled: { color: 'default', text: '已取消' }, -} - -export default function ShipLogs() { - const [list, setList] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [size, setSize] = useState(10) - const [orderNo, setOrderNo] = useState('') - const [shipStatus, setShipStatus] = useState() - const [loading, setLoading] = useState(false) - const [detail, setDetail] = useState(null) - - const load = useCallback(async () => { - setLoading(true) - try { - const data = await shipLogApi.list({ - page, - size, - order_no: orderNo || undefined, - ship_status: shipStatus, - }) - setList(data.list || []) - setTotal(data.total) - } catch (e) { - message.error(e instanceof Error ? e.message : '加载失败') - } finally { - setLoading(false) - } - }, [page, size, orderNo, shipStatus]) - - useEffect(() => { - load() - }, [load]) - - const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '店铺订单号', dataIndex: 'order_no', width: 200, ellipsis: true }, - { - title: '推送状态', - dataIndex: 'ship_status', - width: 100, - render: (v: string) => { - const s = shipStatusMap[v] || { color: 'default', text: v } - return {s.text} - }, - }, - { - title: '处理后订单状态', - dataIndex: 'result_status', - width: 130, - render: (v: string) => { - if (!v) return '-' - const s = orderStatusMap[v] || { color: 'default', text: v } - return {s.text} - }, - }, - { - title: '上游单号', - dataIndex: 'provider_order_no', - width: 160, - ellipsis: true, - render: (v: string) => v || '-', - }, - { - title: '说明', - dataIndex: 'message', - ellipsis: true, - render: (v: string) => v || '-', - }, - { - title: '失败原因', - dataIndex: 'fail_reason', - width: 140, - ellipsis: true, - render: (v: string) => v || '-', - }, - { - title: '时间', - dataIndex: 'created_at', - width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { - title: '操作', - key: 'action', - width: 90, - render: (_, record) => ( - - ), - }, - ] - - return ( -
- - - 发货推送记录 - - - { - setPage(1) - setOrderNo(v) - }} - style={{ width: 220 }} - /> -
`共 ${t} 条`, - onChange: (p, s) => { - setPage(p) - setSize(s) - }, - }} - /> - - setDetail(null)} - footer={null} - width={640} - > - {detail && ( - -
- 店铺订单号: - {detail.order_no} -
-
- 上游单号: - {detail.provider_order_no || '-'} -
-
- 推送状态 / 结果状态: - {detail.ship_status} → {detail.result_status || '-'} -
-
- 说明: - {detail.message || '-'} -
-
- 失败原因: - {detail.fail_reason || '-'} -
-
- 原始请求: -
-                {formatPayload(detail.payload)}
-              
-
-
- )} -
- - ) -} - -function formatPayload(raw: string) { - if (!raw) return '-' - try { - return JSON.stringify(JSON.parse(raw), null, 2) - } catch { - return raw - } -} diff --git a/frontend/src/pages/Skins.tsx b/frontend/src/pages/Skins.tsx deleted file mode 100644 index 965df25..0000000 --- a/frontend/src/pages/Skins.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { - Button, - Form, - Input, - InputNumber, - Modal, - Popconfirm, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd' -import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' -import { skinApi, orderApi } from '../api' -import { useAuth } from '../store/auth' -import type { Skin } from '../types' - -export default function Skins() { - const { isAdmin } = useAuth() - const [list, setList] = useState([]) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [size, setSize] = useState(10) - const [keyword, setKeyword] = useState('') - const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) - const [editing, setEditing] = useState(null) - const [form] = Form.useForm() - - const load = useCallback(async () => { - setLoading(true) - try { - const data = await skinApi.list({ page, size, keyword }) - setList(data.list || []) - setTotal(data.total) - } catch (e) { - message.error(e instanceof Error ? e.message : '加载失败') - } finally { - setLoading(false) - } - }, [page, size, keyword]) - - useEffect(() => { - load() - }, [load]) - - const openCreate = () => { - setEditing(null) - form.resetFields() - form.setFieldsValue({ - game: '和平精英', - stock: -1, - commission: 0, - status: 1, - }) - setOpen(true) - } - - const openEdit = (record: Skin) => { - setEditing(record) - form.setFieldsValue(record) - setOpen(true) - } - - const onSubmit = async () => { - const values = await form.validateFields() - try { - if (editing) { - await skinApi.update(editing.id, values) - message.success('更新成功') - } else { - await skinApi.create(values) - message.success('创建成功') - } - setOpen(false) - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '操作失败') - } - } - - const onDelete = async (id: number) => { - try { - await skinApi.remove(id) - message.success('已删除') - load() - } catch (e) { - message.error(e instanceof Error ? e.message : '删除失败') - } - } - - const onOrder = async (skin: Skin) => { - try { - await orderApi.create({ skin_id: skin.id, buyer_name: '演示买家' }) - message.success('下单成功') - } catch (e) { - message.error(e instanceof Error ? e.message : '下单失败') - } - } - - const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '中文名', dataIndex: 'name', width: 180, ellipsis: true }, - { - title: '英文名', - dataIndex: 'sku', - width: 220, - ellipsis: true, - render: (v: string) => {v}, - }, - { title: '游戏', dataIndex: 'game', width: 100 }, - { - title: '售价', - dataIndex: 'price', - width: 100, - render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`, - }, - { - title: '佣金比例', - dataIndex: 'commission', - width: 100, - render: (v: number) => `${((v ?? 0) * 100).toFixed(0)}%`, - }, - { - title: '库存', - dataIndex: 'stock', - width: 80, - render: (v: number) => (v < 0 ? '无限' : v), - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (v: number) => - v === 1 ? 上架 : 下架, - }, - { - title: '操作', - key: 'action', - width: isAdmin ? 140 : 80, - render: (_, record) => ( - - {isAdmin ? ( - <> - - onDelete(record.id)}> - - - - ) : ( - - )} - - ), - }, - ] - - return ( -
- - - 皮肤商品 - - - { - setPage(1) - setKeyword(v) - }} - style={{ width: 240 }} - /> - - {isAdmin && ( - - )} - - - -
{ - setPage(p) - setSize(s) - }, - }} - /> - - setOpen(false)} - destroyOnClose - width={560} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - -