重构: 手续费改为百分比/固定二选一,清理旧 Skin/Order/ShipLog 演示链路

手续费:
- 新增 fee_type 字段(rate/fixed),百分比与固定金额二选一,不再叠加
- calculateServiceFee 按 fee_type 分支计算
- 商户创建/更新校验 fee_type,订单落库快照 fee_type
- 前端表单改为下拉选择手续费类型,动态显示对应输入框
- 测试拆为 TestFeeRate + TestFeeFixed

清理旧链路:
- 删除旧 Skin/Order/ShipLog 模型及 OrderService/SkinService
- Dashboard 迁移到 FulfillmentService
- 上游 /api/open/v1 改为基于 FulfillmentOrder 实现,接口契约不变
- 推送留痕改用 AuditLog,不再建 ShipLog 表
- 删除前端 Skins/Orders/ShipLogs/Distributors 页面及路由、菜单、API、类型
- 新增迁移 003: 添加 fee_type 列并 DROP 旧表
This commit is contained in:
yml2213
2026-07-30 14:12:27 +08:00
parent 83429456a6
commit bafe41a3ea
36 changed files with 1247 additions and 2446 deletions
+3 -9
View File
@@ -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)")
@@ -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;
@@ -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;
+59 -11
View File
@@ -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 {
+6 -6
View File
@@ -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,
+3
View File
@@ -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,
+7 -115
View File
@@ -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)
}
-128
View File
@@ -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)
}
+1 -2
View File
@@ -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
+45
View File
@@ -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()
}
}
+31
View File
@@ -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_bpfee_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"`
+3 -89
View File
@@ -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"`
}
+27 -40
View File
@@ -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)
}
}
+2 -5
View File
@@ -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,
+1 -11
View File
@@ -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))
}
+406 -3
View File
@@ -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
}
}
@@ -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)
+3 -50
View File
@@ -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 {
+154 -16
View File
@@ -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{})
-480
View File
@@ -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)
}
-179
View File
@@ -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: "套装"},
}
+43
View File
@@ -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
}
+5 -4
View File
@@ -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
-23
View File
@@ -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 <Navigate to="/login" replace />
@@ -41,17 +36,7 @@ function AppRoutes() {
}
>
<Route index element={<Dashboard />} />
<Route path="skins" element={<Skins />} />
<Route path="orders" element={<Orders />} />
<Route path="merchant-center" element={<MerchantCenter />} />
<Route
path="distributors"
element={
<AdminRoute>
<Distributors />
</AdminRoute>
}
/>
<Route
path="platform-merchants"
element={
@@ -60,14 +45,6 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="ship-logs"
element={
<AdminRoute>
<ShipLogs />
</AdminRoute>
}
/>
<Route
path="open-api"
element={
+10 -36
View File
@@ -10,10 +10,7 @@ import type {
Merchant,
MerchantMember,
MerchantProduct,
Order,
PageResult,
ShipLog,
Skin,
User,
WalletAccount,
WalletLedgerEntry,
@@ -39,33 +36,6 @@ export const dashboardApi = {
request.get('/dashboard').then((r) => r.data.data as DashboardStats),
}
export const skinApi = {
list: (params?: Record<string, unknown>) =>
request.get('/skins', { params }).then((r) => r.data.data as PageResult<Skin>),
get: (id: number) =>
request.get(`/skins/${id}`).then((r) => r.data.data as Skin),
create: (data: Partial<Skin>) =>
request.post('/skins', data).then((r) => r.data.data as Skin),
update: (id: number, data: Partial<Skin>) =>
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<string, unknown>) =>
request.get('/orders', { params }).then((r) => r.data.data as PageResult<Order>),
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<string, unknown>) =>
request.get('/users', { params }).then((r) => r.data.data as PageResult<User>),
@@ -75,11 +45,6 @@ export const userApi = {
request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data),
}
export const shipLogApi = {
list: (params?: Record<string, unknown>) =>
request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>),
}
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<Merchant>) =>
request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data),
addMember: (
merchantId: number,
data: { user_id: number; role: MerchantMember['role']; is_default?: boolean },
+2 -10
View File
@@ -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: <DashboardOutlined />, label: '数据概览' },
{ key: '/skins', icon: <SkinOutlined />, label: '皮肤商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
]
if (isAdmin) {
items.push(
{ key: '/distributors', icon: <TeamOutlined />, label: '分销商' },
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
)
}
@@ -81,7 +73,7 @@ export default function MainLayout() {
letterSpacing: 1,
}}
>
{collapsed ? '皮肤' : '皮肤分销系统'}
{collapsed ? '供货' : '皮肤供货平台'}
</div>
<Menu
theme="dark"
@@ -112,7 +104,7 @@ export default function MainLayout() {
<Avatar size="small" icon={<UserOutlined />} />
<Typography.Text>
{user?.nickname || user?.username}
{isAdmin ? '(管理员)' : '分销商)'}
{isAdmin ? '平台管理员)' : '(商户账号'}
</Typography.Text>
</Space>
</Dropdown>
+5 -6
View File
@@ -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() {
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic title="皮肤商品" value={stats?.skin_count ?? 0} prefix={<SkinOutlined />} />
<Statistic title="商户商品" value={stats?.product_count ?? 0} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic title="分销商" value={stats?.distributor_count ?? 0} prefix={<TeamOutlined />} />
<Statistic title="平台商户" value={stats?.merchant_count ?? 0} prefix={<ShopOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
@@ -66,8 +65,8 @@ export default function Dashboard() {
<Col xs={24} sm={12} lg={8}>
<Card>
<Statistic
title="累计佣金"
value={stats?.total_commission ?? 0}
title="平台手续费"
value={stats?.total_fees ?? 0}
precision={2}
prefix={<PercentageOutlined />}
suffix="元"
-169
View File
@@ -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<User[]>([])
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<User> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{
title: '邀请码',
dataIndex: 'invite_code',
render: (v: string) => <Tag color="blue">{v}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (v: number, record) => (
<Switch
checkedChildren="启用"
unCheckedChildren="禁用"
checked={v === 1}
onChange={(checked) => toggleStatus(record, checked)}
/>
),
},
{
title: '注册时间',
dataIndex: 'created_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="搜索用户名/昵称"
allowClear
onSearch={(v) => {
setPage(1)
setKeyword(v)
}}
style={{ width: 220 }}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields()
setOpen(true)
}}
>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal title="新增分销商" open={open} onOk={onSubmit} onCancel={() => setOpen(false)} destroyOnClose>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
<Input />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6 }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+2 -2
View File
@@ -36,7 +36,7 @@ export default function Login() {
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
@@ -55,7 +55,7 @@ export default function Login() {
</Form>
<div style={{ textAlign: 'center' }}>
<Typography.Text type="secondary">
<Link to="/register"></Link>
<Link to="/register"></Link>
</Typography.Text>
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0, textAlign: 'center' }}>
+120 -32
View File
@@ -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) => <Typography.Text code>{v}</Typography.Text> },
{ 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: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -419,6 +474,7 @@ export default function MerchantCenter() {
{
key: 'orders',
label: '履约订单',
disabled: !hasFeature('orders'),
children: (
<Table
rowKey="id"
@@ -434,6 +490,7 @@ export default function MerchantCenter() {
{
key: 'wallet',
label: '钱包',
disabled: !hasFeature('wallet'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -455,20 +512,25 @@ export default function MerchantCenter() {
</Button>
)}
</Space>
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
pagination={pageConfig(ledger, loadWallet)}
/>
{canFinance ? (
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
pagination={pageConfig(ledger, loadWallet)}
/>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
),
},
{
key: 'api',
label: 'API 客户端',
disabled: !hasFeature('api'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -486,6 +548,7 @@ export default function MerchantCenter() {
{
key: 'callbacks',
label: '回调',
disabled: !hasFeature('callbacks'),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
@@ -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<string, string | null> = {
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)}`
}
-419
View File
@@ -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<string, { color: string; text: string }> = {
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<Order[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [status, setStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [skins, setSkins] = useState<Skin[]>([])
const [creating, setCreating] = useState(false)
const [createdOrder, setCreatedOrder] = useState<Order | null>(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<Order> = [
{
title: '店铺订单号',
dataIndex: 'order_no',
width: 210,
ellipsis: true,
render: (v: string) => (
<Space size={4}>
<Typography.Text copyable={{ text: v }} style={{ maxWidth: 160 }} ellipsis>
{v}
</Typography.Text>
</Space>
),
},
{
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 ? (
<Typography.Text code copyable={{ text: r.skin.sku }}>
{r.skin.sku}
</Typography.Text>
) : (
'-'
),
},
{
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 <Tag color={s.color}>{s.text}</Tag>
},
},
{
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) => (
<Space size={0}>
{record.status === 'pending' && (
<>
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'paid')}>
</Button>
<Button type="link" size="small" danger onClick={() => changeStatus(record.id, 'cancelled')}>
</Button>
</>
)}
{(record.status === 'paid' ||
record.status === 'ship_failed' ||
record.status === 'delivering') && (
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
</Button>
)}
</Space>
),
})
}
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
order_no can_ship=true
</Typography.Text>
</div>
<Space>
<Select
allowClear
placeholder="订单状态"
style={{ width: 140 }}
value={status}
onChange={(v) => {
setPage(1)
setStatus(v)
}}
options={[
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'delivering', label: '发货中' },
{ value: 'delivered', label: '已交付' },
{ value: 'ship_failed', label: '发货失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="创建测试订单(联调用)"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={
createdOrder
? [
<Button key="close" onClick={() => setCreateOpen(false)}>
</Button>,
<Button
key="again"
type="primary"
onClick={() => {
setCreatedOrder(null)
form.setFieldsValue({ status: 'paid' })
}}
>
</Button>,
]
: [
<Button key="cancel" onClick={() => setCreateOpen(false)}>
</Button>,
<Button key="ok" type="primary" loading={creating} onClick={onCreate}>
</Button>,
]
}
destroyOnClose
width={560}
>
{createdOrder ? (
<div>
<Typography.Paragraph>
<Typography.Text strong></Typography.Text>{' '}
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div
style={{
padding: 16,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
}}
>
<div style={{ marginBottom: 8, color: '#666' }}> order_no</div>
<Space>
<Typography.Title level={4} style={{ margin: 0 }} copyable>
{createdOrder.order_no}
</Typography.Title>
<Button
icon={<CopyOutlined />}
onClick={() => copyText(createdOrder.order_no, '订单号已复制')}
>
</Button>
</Space>
</div>
<div>
<div>{createdOrder.skin?.name || `#${createdOrder.skin_id}`}</div>
<div>
SKU
<Typography.Text code copyable>
{createdOrder.skin?.sku || '-'}
</Typography.Text>
</div>
<div>
<Tag color={statusMap[createdOrder.status]?.color}>
{statusMap[createdOrder.status]?.text || createdOrder.status}
</Tag>
{(createdOrder.status === 'paid' || createdOrder.status === 'ship_failed') && (
<Typography.Text type="success"> can_ship=true</Typography.Text>
)}
{createdOrder.status !== 'paid' && createdOrder.status !== 'ship_failed' && (
<Typography.Text type="secondary"> can_ship=false</Typography.Text>
)}
</div>
<div>{createdOrder.buyer_name}</div>
</div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
GET /api/open/v1/orders/{createdOrder.order_no}
<br />
X-Api-Key / X-Timestamp / X-Nonce / X-Sign
</Typography.Paragraph>
</Space>
</div>
) : (
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="skin_id"
label="商品皮肤"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择要发货的皮肤"
options={skins.map((s) => ({
value: s.id,
label: `${s.name}${s.sku}`,
}))}
/>
</Form.Item>
<Form.Item name="buyer_name" label="买家名称">
<Input placeholder="测试买家" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="联调说明" />
</Form.Item>
{isAdmin && (
<Form.Item
name="status"
label="初始状态(联调造单)"
rules={[{ required: true, message: '请选择状态' }]}
extra="源头 can_ship=true 的只有:已支付、发货失败"
>
<Select
options={[
{ value: 'pending', label: '待支付 pending — can_ship=false' },
{ value: 'paid', label: '已支付 paid — can_ship=true(正常可发)' },
{ value: 'delivering', label: '发货中 delivering — can_ship=false' },
{ value: 'delivered', label: '已交付 delivered — can_ship=false' },
{ value: 'ship_failed', label: '发货失败 ship_failed — can_ship=true(可重试)' },
{ value: 'cancelled', label: '已取消 cancelled — can_ship=false' },
]}
/>
</Form.Item>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
O20260720
</Typography.Paragraph>
</Form>
)}
</Modal>
</div>
)
}
+189 -8
View File
@@ -26,14 +26,24 @@ const memberRoleOptions = [
{ value: 'viewer', label: '只读' },
]
const featureOptions = [
{ value: 'products', label: '商品' },
{ value: 'orders', label: '订单' },
{ value: 'wallet', label: '钱包' },
{ value: 'api', label: 'API' },
{ value: 'callbacks', label: '回调' },
]
export default function PlatformMerchants() {
const navigate = useNavigate()
const [data, setData] = useState<PageResult<Merchant>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [memberOpen, setMemberOpen] = useState(false)
const [selectedMerchant, setSelectedMerchant] = useState<Merchant | null>(null)
const [createForm] = Form.useForm()
const [settingsForm] = Form.useForm()
const [memberForm] = Form.useForm()
const load = useCallback(async (page = data.page, size = data.size) => {
@@ -55,7 +65,13 @@ export default function PlatformMerchants() {
const submitCreate = async () => {
const values = await createForm.validateFields()
try {
await platformApi.createMerchant(values)
await platformApi.createMerchant({
...values,
features: featureListToText(values.features),
fee_type: values.fee_type,
fee_rate_bp: Number(values.fee_rate_bp || 0),
fee_fixed_amount: yuanToCents(values.fee_fixed_yuan),
})
message.success('商户已创建')
setCreateOpen(false)
createForm.resetFields()
@@ -65,6 +81,28 @@ export default function PlatformMerchants() {
}
}
const submitSettings = async () => {
if (!selectedMerchant) return
const values = await settingsForm.validateFields()
try {
await platformApi.updateMerchant(selectedMerchant.id, {
name: values.name,
status: values.status,
contact_name: values.contact_name,
contact_info: values.contact_info,
features: featureListToText(values.features),
fee_type: values.fee_type,
fee_rate_bp: Number(values.fee_rate_bp || 0),
fee_fixed_amount: yuanToCents(values.fee_fixed_yuan),
})
message.success('商户设置已更新')
setSettingsOpen(false)
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const submitMember = async () => {
if (!selectedMerchant) return
const values = await memberForm.validateFields()
@@ -87,6 +125,27 @@ export default function PlatformMerchants() {
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' },
{ title: '联系方式', dataIndex: 'contact_info', width: 160, render: (v) => v || '-' },
{
title: '手续费',
key: 'fee',
width: 160,
render: (_, r) =>
r.fee_type === 'fixed'
? `固定 ¥${centsToYuan(r.fee_fixed_amount).toFixed(2)}/单`
: `${(r.fee_rate_bp / 100).toFixed(2)}%`,
},
{
title: '功能',
dataIndex: 'features',
width: 220,
render: (v) => (
<Space size={4} wrap>
{featuresToList(v).map((feature) => (
<Tag key={feature}>{featureText(feature)}</Tag>
))}
</Space>
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
@@ -106,6 +165,22 @@ export default function PlatformMerchants() {
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setSelectedMerchant(record)
settingsForm.setFieldsValue({
...record,
features: featuresToList(record.features),
fee_type: record.fee_type || 'rate',
fee_fixed_yuan: centsToYuan(record.fee_fixed_amount),
})
setSettingsOpen(true)
}}
>
</Button>
<Button
type="link"
size="small"
@@ -142,6 +217,12 @@ export default function PlatformMerchants() {
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields()
createForm.setFieldsValue({
features: featureOptions.map((item) => item.value),
fee_type: 'rate',
fee_rate_bp: 0,
fee_fixed_yuan: 0,
})
setCreateOpen(true)
}}
>
@@ -166,7 +247,7 @@ export default function PlatformMerchants() {
}}
/>
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose>
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose width={680}>
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="code" label="商户编码" rules={[{ required: true }]}>
<Input placeholder="lower-case-code" />
@@ -174,15 +255,92 @@ export default function PlatformMerchants() {
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="owner_user_id" label="负责人用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="contact_name" label="联系人">
<Input />
</Form.Item>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="owner_username" label="负责人用户名" rules={[{ required: true }]} style={{ width: 300 }}>
<Input />
</Form.Item>
<Form.Item name="owner_password" label="负责人密码" rules={[{ required: true, min: 6 }]} style={{ width: 300 }}>
<Input.Password />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="owner_nickname" label="负责人昵称" style={{ width: 300 }}>
<Input />
</Form.Item>
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="contact_info" label="联系方式">
<Input />
</Form.Item>
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
<Select mode="multiple" options={featureOptions} />
</Form.Item>
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
{ value: 'fixed', label: '按固定金额(每单固定手续费)' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
{({ getFieldValue }) =>
getFieldValue('fee_type') === 'fixed' ? (
<Form.Item name="fee_fixed_yuan" label="每单固定手续费(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item name="fee_rate_bp" label="手续费比例 BP1BP=0.01%,如 250=2.5%" rules={[{ required: true }]}>
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
</Form>
</Modal>
<Modal title={selectedMerchant ? `商户设置:${selectedMerchant.name}` : '商户设置'} open={settingsOpen} onOk={submitSettings} onCancel={() => setSettingsOpen(false)} destroyOnClose width={680}>
<Form form={settingsForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="status" label="状态" style={{ width: 300 }}>
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
<Form.Item name="contact_name" label="联系人" style={{ width: 300 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="contact_info" label="联系方式">
<Input />
</Form.Item>
<Form.Item name="features" label="开通功能" rules={[{ required: true }]}>
<Select mode="multiple" options={featureOptions} />
</Form.Item>
<Form.Item name="fee_type" label="手续费类型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'rate', label: '按百分比(每单按订单金额比例收取)' },
{ value: 'fixed', label: '按固定金额(每单固定手续费)' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.fee_type !== cur.fee_type}>
{({ getFieldValue }) =>
getFieldValue('fee_type') === 'fixed' ? (
<Form.Item name="fee_fixed_yuan" label="每单固定手续费(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item name="fee_rate_bp" label="手续费比例 BP1BP=0.01%,如 250=2.5%" rules={[{ required: true }]}>
<InputNumber min={0} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
</Form>
</Modal>
@@ -208,3 +366,26 @@ export default function PlatformMerchants() {
</div>
)
}
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))
}
+2 -2
View File
@@ -39,9 +39,9 @@ export default function Register() {
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="username" rules={[{ required: true, min: 3, message: '用户名至少3位' }]}>
-245
View File
@@ -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<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
failed: { color: 'red', text: '失败' },
processing: { color: 'cyan', text: '发货中' },
}
const orderStatusMap: Record<string, { color: string; text: string }> = {
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<ShipLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [orderNo, setOrderNo] = useState('')
const [shipStatus, setShipStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<ShipLog | null>(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<ShipLog> = [
{ 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 <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '处理后订单状态',
dataIndex: 'result_status',
width: 130,
render: (v: string) => {
if (!v) return '-'
const s = orderStatusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
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) => (
<Button type="link" size="small" onClick={() => setDetail(record)}>
</Button>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="店铺订单号"
allowClear
onSearch={(v) => {
setPage(1)
setOrderNo(v)
}}
style={{ width: 220 }}
/>
<Select
allowClear
placeholder="推送状态"
style={{ width: 140 }}
value={shipStatus}
onChange={(v) => {
setPage(1)
setShipStatus(v)
}}
options={[
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'processing', label: '发货中' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
</Space>
</Space>
<Typography.Paragraph type="secondary" style={{ marginTop: -4 }}>
便
</Typography.Paragraph>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="推送详情"
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={640}
>
{detail && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text copyable>{detail.order_no}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.provider_order_no || '-'}
</div>
<div>
<Typography.Text type="secondary"> / </Typography.Text>
{detail.ship_status} {detail.result_status || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.message || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.fail_reason || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 280,
overflow: 'auto',
fontSize: 12,
}}
>
{formatPayload(detail.payload)}
</pre>
</div>
</Space>
)}
</Modal>
</div>
)
}
function formatPayload(raw: string) {
if (!raw) return '-'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
-293
View File
@@ -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<Skin[]>([])
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<Skin | null>(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<Skin> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '中文名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: '英文名',
dataIndex: 'sku',
width: 220,
ellipsis: true,
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{ 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 ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
key: 'action',
width: isAdmin ? 140 : 80,
render: (_, record) => (
<Space>
{isAdmin ? (
<>
<Button type="link" size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => onDelete(record.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</>
) : (
<Button
type="link"
size="small"
disabled={record.status !== 1}
onClick={() => onOrder(record)}
>
</Button>
)}
</Space>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="搜索中文名 / 英文名"
allowClear
onSearch={(v) => {
setPage(1)
setKeyword(v)
}}
style={{ width: 240 }}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
{isAdmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
)}
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title={editing ? '编辑皮肤' : '新增皮肤'}
open={open}
onOk={onSubmit}
onCancel={() => setOpen(false)}
destroyOnClose
width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="中文名" rules={[{ required: true }]}>
<Input placeholder="如:套装-糯粉咩咩" />
</Form.Item>
<Form.Item
name="sku"
label="英文名 (sku)"
rules={[{ required: true, message: '请填写英文固定标识' }]}
>
<Input placeholder="如:suit_pink_sheep" disabled={!!editing} />
</Form.Item>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="game" label="游戏" style={{ width: 240 }}>
<Select
options={[
{ value: '和平精英', label: '和平精英' },
{ value: '王者荣耀', label: '王者荣耀' },
{ value: '英雄联盟', label: '英雄联盟' },
]}
/>
</Form.Item>
<Form.Item name="category" label="品类" style={{ width: 240 }}>
<Select
allowClear
options={[
{ value: '套装', label: '套装' },
{ value: '上衣', label: '上衣' },
{ value: '背包', label: '背包' },
{ value: '头盔', label: '头盔' },
{ value: '枪械', label: '枪械' },
{ value: '投掷物', label: '投掷物' },
{ value: '礼包', label: '礼包' },
]}
/>
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="price" label="售价" rules={[{ required: true }]} style={{ width: 160 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cost_price" label="成本价" style={{ width: 160 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="commission" label="佣金比例" style={{ width: 160 }}>
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space style={{ width: '100%' }} size="middle">
<Form.Item name="stock" label="库存(-1无限)" style={{ width: 240 }}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" style={{ width: 240 }}>
<Select
options={[
{ value: 1, label: '上架' },
{ value: 0, label: '下架' },
]}
/>
</Form.Item>
</Space>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+13 -53
View File
@@ -2,10 +2,8 @@ export interface User {
id: number
username: string
nickname: string
role: 'admin' | 'distributor'
role: 'admin' | 'merchant'
status: number
invite_code: string
parent_id?: number | null
created_at: string
}
@@ -16,6 +14,10 @@ export interface Merchant {
status: 'active' | 'disabled'
contact_name?: string
contact_info?: string
features: string
fee_type: 'rate' | 'fixed'
fee_rate_bp: number
fee_fixed_amount: number
created_at: string
}
@@ -86,6 +88,11 @@ export interface FulfillmentOrder {
product_sku: string
product_name: string
quantity: number
base_amount: number
fee_type: 'rate' | 'fixed'
fee_rate_bp: number
fee_fixed_amount: number
service_fee_amount: number
amount: number
currency: string
payment_status: string
@@ -131,59 +138,12 @@ export interface CallbackCredential {
secret: string
}
export interface Skin {
id: number
name: string
sku: string
game: string
category: string
cover_url: string
price: number
cost_price: number
commission: number
stock: number
status: number
description: string
created_at: string
}
export interface Order {
id: number
order_no: string
skin_id: number
skin?: Skin
distributor_id: number
distributor?: User
buyer_name: string
amount: number
commission_amt: number
status: string
remark: string
provider_order_no?: string
shipped_at?: string | null
ship_fail_reason?: string
created_at: string
}
export interface ShipLog {
id: number
order_no: string
order_id: number
ship_status: string
provider_order_no: string
fail_reason: string
payload: string
result_status: string
message: string
created_at: string
}
export interface DashboardStats {
skin_count: number
distributor_count: number
product_count: number
merchant_count: number
order_count: number
total_sales: number
total_commission: number
total_fees: number
pending_order_count: number
}