重构: 手续费改为百分比/固定二选一,清理旧 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
@@ -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