feat: 新增退款管理模块 — 完整的退款审批流程、佣金回扣和资产重置
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s

新增退款申请全生命周期管理:创建/列表/详情/审批通过/拒绝/退回/重新提交
审批通过后异步执行佣金全额回扣(扣减各代理佣金钱包)和资产重置(套餐失效+停机+世代重置)
新增 tb_refund_request 表(迁移 000093)、RefundRequest Model、8 个 DTO
新增 Store/Service/Handler/路由注册,仅平台用户可访问
This commit is contained in:
2026-03-28 17:57:52 +08:00
parent 5e64c64ce3
commit 02b10f87cf
18 changed files with 1345 additions and 26 deletions

View File

@@ -95,5 +95,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
Refund: admin.NewRefundHandler(svc.Refund),
}
}

View File

@@ -36,6 +36,7 @@ import (
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
@@ -91,6 +92,8 @@ type services struct {
StopResumeService *iotCardSvc.StopResumeService
WechatConfig *wechatConfigSvc.Service
AgentRecharge *agentRechargeSvc.Service
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -105,6 +108,18 @@ func initServices(s *stores, deps *Dependencies) *services {
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger)
packageActivation := packageSvc.NewActivationService(
deps.DB,
deps.Redis,
s.PackageUsage,
s.Package,
s.PackageUsageDailyRecord,
deps.Logger,
)
stopResumeService := iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger)
device := deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient)
return &services{
Account: account,
AccountAudit: accountAudit,
@@ -155,7 +170,7 @@ func initServices(s *stores, deps *Dependencies) *services {
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
Device: deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
@@ -180,7 +195,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient),
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeSvc.New(
deps.DB,
@@ -194,5 +209,21 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
),
PackageActivation: packageActivation,
Refund: refundSvc.New(
deps.DB,
s.RefundRequest,
s.Order,
s.CommissionRecord,
s.AgentWallet,
s.AgentWalletTransaction,
stopResumeService,
device,
packageActivation,
s.IotCard,
s.Device,
s.AssetWallet,
deps.Logger,
),
}
}

View File

@@ -59,6 +59,8 @@ type stores struct {
AssetRecharge *postgres.AssetRechargeStore
// 微信参数配置
WechatConfig *postgres.WechatConfigStore
// 退款系统
RefundRequest *postgres.RefundStore
}
func initStores(deps *Dependencies) *stores {
@@ -116,5 +118,6 @@ func initStores(deps *Dependencies) *stores {
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
}
}

View File

@@ -60,6 +60,7 @@ type Handlers struct {
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
}
// Middlewares 封装所有中间件

View File

@@ -1,7 +1,28 @@
// Package gateway 定义 Gateway API 的请求和响应数据传输对象DTO
package gateway
import "encoding/json"
import (
"encoding/json"
"strings"
)
// FlexBool 灵活布尔类型
// Gateway 部分设备返回 bool 字段时使用字符串(如 "1"/"0")而非标准 JSON bool
// 导致 sonic 严格类型检查失败。此类型兼容 bool、string、number 等多种 JSON 值。
type FlexBool bool
// UnmarshalJSON 实现 json.Unmarshaler 接口
// 支持true/false, "1"/"0", "true"/"false", 1/0, null
func (b *FlexBool) UnmarshalJSON(data []byte) error {
raw := strings.Trim(string(data), "\"")
switch raw {
case "true", "1":
*b = true
default:
*b = false
}
return nil
}
// GatewayResponse 是 Gateway API 的通用响应结构
type GatewayResponse struct {
@@ -141,23 +162,23 @@ type SyncDeviceInfoReq struct {
// SyncDeviceInfoResp sync-info 同步查询设备信息响应(对应 Gateway data 字段)
// 注意:所有字段均可能为 null/零值,表示暂无数据
type SyncDeviceInfoResp struct {
DeviceID string `json:"device_id" description:"设备IDIMEI/SN"`
DeviceName string `json:"device_name" description:"设备名称"`
IMEI string `json:"imei" description:"IMEI号"`
CurrentIccid string `json:"current_iccid" description:"当前使用的ICCID"`
DeviceType string `json:"device_type" description:"设备类型1=诺行2=玺龙3=迎势达"`
SoftwareVersion string `json:"software_version" description:"软件版本号"`
MacAddress string `json:"mac_address" description:"MAC地址"`
SSID string `json:"ssid" description:"WiFi热点名称"`
WifiEnabled bool `json:"wifi_enabled" description:"WiFi开关状态"`
SwitchMode string `json:"switch_mode" description:"切卡模式0=自动1=手动"`
RSSI string `json:"rssi" description:"接收信号强度"`
BatteryLevel *int `json:"battery_level" description:"电池电量百分比无电池时为null"`
OnlineStatus int `json:"online_status" description:"在线状态1=在线2=离线"`
LastUpdateTime string `json:"last_update_time" description:"设备信息最后更新时间ISO 8601"`
LastOnlineTime string `json:"last_online_time" description:"设备最后在线时间ISO 8601"`
RunTime string `json:"run_time" description:"本次开机运行时间(秒)"`
DailyUsage string `json:"daily_usage" description:"日使用流量(字节)"`
DeviceID string `json:"device_id" description:"设备IDIMEI/SN"`
DeviceName string `json:"device_name" description:"设备名称"`
IMEI string `json:"imei" description:"IMEI号"`
CurrentIccid string `json:"current_iccid" description:"当前使用的ICCID"`
DeviceType string `json:"device_type" description:"设备类型1=诺行2=玺龙3=迎势达"`
SoftwareVersion string `json:"software_version" description:"软件版本号"`
MacAddress string `json:"mac_address" description:"MAC地址"`
SSID string `json:"ssid" description:"WiFi热点名称"`
WifiEnabled FlexBool `json:"wifi_enabled" description:"WiFi开关状态"`
SwitchMode string `json:"switch_mode" description:"切卡模式0=自动1=手动"`
RSSI string `json:"rssi" description:"接收信号强度"`
BatteryLevel *int `json:"battery_level" description:"电池电量百分比无电池时为null"`
OnlineStatus int `json:"online_status" description:"在线状态1=在线2=离线"`
LastUpdateTime string `json:"last_update_time" description:"设备信息最后更新时间ISO 8601"`
LastOnlineTime string `json:"last_online_time" description:"设备最后在线时间ISO 8601"`
RunTime string `json:"run_time" description:"本次开机运行时间(秒)"`
DailyUsage string `json:"daily_usage" description:"日使用流量(字节)"`
// 信号相关D-10 补全)
Rsrp int `json:"rsrp" description:"参考信号接收功率(dBm)"`

View File

@@ -0,0 +1,150 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
refundService "github.com/break/junhong_cmp_fiber/internal/service/refund"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// RefundHandler 退款管理处理器
type RefundHandler struct {
service *refundService.Service
}
// NewRefundHandler 创建退款管理处理器
func NewRefundHandler(service *refundService.Service) *RefundHandler {
return &RefundHandler{service: service}
}
// Create 创建退款申请
// POST /api/admin/refunds
func (h *RefundHandler) Create(c *fiber.Ctx) error {
var req dto.CreateRefundRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 退款申请列表
// GET /api/admin/refunds
func (h *RefundHandler) List(c *fiber.Ctx) error {
var req dto.RefundListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetByID 退款申请详情
// GET /api/admin/refunds/:id
func (h *RefundHandler) GetByID(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
result, err := h.service.GetByID(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Approve 审批通过退款申请
// POST /api/admin/refunds/:id/approve
func (h *RefundHandler) Approve(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
var req dto.ApproveRefundRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.Approve(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// Reject 审批拒绝退款申请
// POST /api/admin/refunds/:id/reject
func (h *RefundHandler) Reject(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
var req dto.RejectRefundRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.Reject(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// Return 退回退款申请
// POST /api/admin/refunds/:id/return
func (h *RefundHandler) Return(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
var req dto.ReturnRefundRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.Return(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// Resubmit 重新提交退款申请
// POST /api/admin/refunds/:id/resubmit
func (h *RefundHandler) Resubmit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
var req dto.ResubmitRefundRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.Resubmit(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,84 @@
package dto
// CreateRefundRequest 创建退款申请请求
type CreateRefundRequest struct {
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID可选"`
}
// RefundIDRequest 退款申请ID路径参数
type RefundIDRequest struct {
ID uint `path:"id" description:"退款申请ID" required:"true"`
}
// RejectRefundRequest 拒绝退款申请请求
type RejectRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
RejectReason string `json:"reject_reason" validate:"required,max=500" required:"true" maxLength:"500" description:"拒绝原因(必填)"`
}
// ResubmitRefundRequest 重新提交退款申请请求
// 退款单被退回后,可修改部分字段后重新提交
type ResubmitRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
}
// ApproveRefundRequest 审批通过退款申请请求
type ApproveRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
ApprovedRefundAmount *int64 `json:"approved_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"审批实际退款金额(分),不填则使用申请金额"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"审批备注"`
}
// ReturnRefundRequest 退回退款申请请求
type ReturnRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"退回备注"`
}
// RefundListRequest 退款申请列表查询请求
type RefundListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"`
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"`
}
// RefundResponse 退款申请详情响应
type RefundResponse struct {
ID uint `json:"id" description:"退款申请ID"`
RefundNo string `json:"refund_no" description:"退款单号"`
OrderID uint `json:"order_id" description:"关联订单ID"`
PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"`
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
RefundReason string `json:"refund_reason" description:"退款原因"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"`
ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"`
RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"`
Remark string `json:"remark,omitempty" description:"审批备注"`
CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"`
AssetReset bool `json:"asset_reset" description:"资产是否已重置"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// RefundListResponse 退款申请列表分页响应
type RefundListResponse struct {
Items []RefundResponse `json:"items" description:"退款申请列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}

53
internal/model/refund.go Normal file
View File

@@ -0,0 +1,53 @@
package model
import (
"time"
"gorm.io/gorm"
)
// RefundRequest 退款申请模型
// 记录退款申请的完整生命周期,包含退款单号、关联订单、金额、状态流转、审批信息等
// 状态流转1(待审批) → 2(已通过)/3(已拒绝)/4(已退回)4(已退回) → 1(待审批)
type RefundRequest struct {
gorm.Model
BaseModel `gorm:"embedded"`
// 退款单基础信息
RefundNo string `gorm:"column:refund_no;type:varchar(50);uniqueIndex:idx_refund_request_refund_no,where:deleted_at IS NULL;not null;comment:退款单号RF+日期时间+6位随机数" json:"refund_no"`
OrderID uint `gorm:"column:order_id;index;not null;comment:关联订单ID" json:"order_id"`
PackageUsageID *uint `gorm:"column:package_usage_id;comment:关联套餐使用记录ID可选" json:"package_usage_id,omitempty"`
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID从订单获取用于数据权限过滤" json:"shop_id,omitempty"`
// 金额信息
ActualReceivedAmount int64 `gorm:"column:actual_received_amount;type:bigint;not null;comment:实收金额(分)" json:"actual_received_amount"`
RequestedRefundAmount int64 `gorm:"column:requested_refund_amount;type:bigint;not null;comment:申请退款金额(分)" json:"requested_refund_amount"`
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount;type:bigint;comment:审批实际退款金额(分)" json:"approved_refund_amount,omitempty"`
// 退款原因和状态
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`
// 审批信息
ProcessorID *uint `gorm:"column:processor_id;comment:审批人ID" json:"processor_id,omitempty"`
ProcessedAt *time.Time `gorm:"column:processed_at;comment:审批时间" json:"processed_at,omitempty"`
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"`
Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"`
// 后处理标记
CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"`
AssetReset bool `gorm:"column:asset_reset;not null;default:false;comment:资产是否已重置(套餐停用+停机+世代重置)" json:"asset_reset"`
}
// TableName 指定表名
func (RefundRequest) TableName() string {
return "tb_refund_request"
}
// 退款状态常量
const (
RefundStatusPending = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回
)

View File

@@ -122,4 +122,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.AgentRecharge != nil {
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
}
if handlers.Refund != nil {
registerRefundRoutes(authGroup, handlers.Refund, doc, basePath)
}
}

81
internal/routes/refund.go Normal file
View File

@@ -0,0 +1,81 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerRefundRoutes 注册退款管理路由
// 仅平台用户(超级管理员和平台用户)可访问
func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc *openapi.Generator, basePath string) {
refund := router.Group("/refunds", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问退款管理功能")
}
return c.Next()
})
groupPath := basePath + "/refunds"
Register(refund, doc, groupPath, "POST", "/", handler.Create, RouteSpec{
Summary: "创建退款申请",
Tags: []string{"退款管理"},
Input: new(dto.CreateRefundRequest),
Output: new(dto.RefundResponse),
Auth: true,
})
Register(refund, doc, groupPath, "GET", "/", handler.List, RouteSpec{
Summary: "退款申请列表",
Tags: []string{"退款管理"},
Input: new(dto.RefundListRequest),
Output: new(dto.RefundListResponse),
Auth: true,
})
Register(refund, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
Summary: "退款申请详情",
Tags: []string{"退款管理"},
Input: new(dto.RefundIDRequest),
Output: new(dto.RefundResponse),
Auth: true,
})
Register(refund, doc, groupPath, "POST", "/:id/approve", handler.Approve, RouteSpec{
Summary: "审批通过退款申请",
Tags: []string{"退款管理"},
Input: new(dto.ApproveRefundRequest),
Output: nil,
Auth: true,
})
Register(refund, doc, groupPath, "POST", "/:id/reject", handler.Reject, RouteSpec{
Summary: "审批拒绝退款申请",
Tags: []string{"退款管理"},
Input: new(dto.RejectRefundRequest),
Output: nil,
Auth: true,
})
Register(refund, doc, groupPath, "POST", "/:id/return", handler.Return, RouteSpec{
Summary: "退回退款申请",
Tags: []string{"退款管理"},
Input: new(dto.ReturnRefundRequest),
Output: nil,
Auth: true,
})
Register(refund, doc, groupPath, "POST", "/:id/resubmit", handler.Resubmit, RouteSpec{
Summary: "重新提交退款申请",
Tags: []string{"退款管理"},
Input: new(dto.ResubmitRefundRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -373,7 +373,7 @@ func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGa
Rssi: strPtr(r.RSSI),
Sinr: &r.Sinr,
SSID: strPtr(r.SSID),
WifiEnabled: &r.WifiEnabled,
WifiEnabled: flexBoolPtr(r.WifiEnabled),
WifiPassword: strPtr(r.WifiPassword),
IPAddress: strPtr(r.IPAddress),
WANIP: strPtr(r.WANIP),
@@ -410,6 +410,11 @@ func strPtr(s string) *string {
return &s
}
func flexBoolPtr(fb gateway.FlexBool) *bool {
b := bool(fb)
return &b
}
// Refresh 刷新资产数据(调网关同步)
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
switch assetType {

View File

@@ -369,3 +369,37 @@ func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *
zap.Error(err))
}
}
// InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐
// 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4)
func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error {
query := s.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
})
switch assetType {
case "iot_card":
query = query.Where("iot_card_id = ?", assetID)
case "device":
query = query.Where("device_id = ?", assetID)
default:
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
result := query.Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
}
s.logger.Info("批量失效套餐完成",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Int64("affected", result.RowsAffected),
)
return nil
}

View File

@@ -0,0 +1,670 @@
// Package refund 提供退款申请的业务逻辑服务
// 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理
// 审批通过后异步执行佣金回扣和资产世代重置
package refund
import (
"context"
"fmt"
"math/rand"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
)
// Service 退款业务服务
// 负责退款申请的 CRUD、审批流程、佣金回扣和资产世代重置
type Service struct {
db *gorm.DB
refundStore *postgres.RefundStore
orderStore *postgres.OrderStore
commissionRecordStore *postgres.CommissionRecordStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
stopResumeService *iotCardSvc.StopResumeService
deviceService *deviceSvc.Service
packageActivationService *packageSvc.ActivationService
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
logger *zap.Logger
}
// New 创建退款业务服务实例
func New(
db *gorm.DB,
refundStore *postgres.RefundStore,
orderStore *postgres.OrderStore,
commissionRecordStore *postgres.CommissionRecordStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
stopResumeService *iotCardSvc.StopResumeService,
deviceService *deviceSvc.Service,
packageActivationService *packageSvc.ActivationService,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
assetWalletStore *postgres.AssetWalletStore,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
refundStore: refundStore,
orderStore: orderStore,
commissionRecordStore: commissionRecordStore,
agentWalletStore: agentWalletStore,
agentWalletTransactionStore: agentWalletTransactionStore,
stopResumeService: stopResumeService,
deviceService: deviceService,
packageActivationService: packageActivationService,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
logger: logger,
}
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 校验订单存在且已支付
order, err := s.orderStore.GetByID(ctx, req.OrderID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
if order.PaymentStatus != model.PaymentStatusPaid {
return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款")
}
// 检查是否存在活跃退款申请(待审批、已通过、已退回状态)
existing, err := s.refundStore.FindActiveByOrderID(ctx, req.OrderID)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeConflict, "该订单已存在退款申请")
}
// 从订单获取 shop_id优先使用 SellerShopID代理商买家使用 BuyerID
var shopID *uint
if order.SellerShopID != nil {
shopID = order.SellerShopID
} else if order.BuyerType == model.BuyerTypeAgent {
shopID = &order.BuyerID
}
refund := &model.RefundRequest{
RefundNo: generateRefundNo(),
OrderID: req.OrderID,
PackageUsageID: req.PackageUsageID,
ShopID: shopID,
ActualReceivedAmount: req.ActualReceivedAmount,
RequestedRefundAmount: req.RequestedRefundAmount,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
}
refund.Creator = userID
refund.Updater = userID
if err := s.refundStore.Create(ctx, refund); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建退款申请失败")
}
return buildRefundResponse(refund), nil
}
// List 分页查询退款申请列表
func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.RefundListResponse, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := &postgres.RefundListFilters{
Status: req.Status,
OrderID: req.OrderID,
ShopID: req.ShopID,
}
requests, total, err := s.refundStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败")
}
items := make([]dto.RefundResponse, 0, len(requests))
for _, r := range requests {
items = append(items, *buildRefundResponse(r))
}
return &dto.RefundListResponse{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// GetByID 根据 ID 查询退款申请详情
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) {
refund, err := s.refundStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
return buildRefundResponse(refund), nil
}
// Approve 审批通过退款申请
// 条件更新 WHERE status=1设置审批信息
// 事务提交成功后异步执行佣金回扣和资产重置
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
refund, err := s.refundStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
if refund.Status != model.RefundStatusPending {
return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
}
now := time.Now()
approvedAmount := refund.RequestedRefundAmount
if req.ApprovedRefundAmount != nil {
approvedAmount = *req.ApprovedRefundAmount
}
// 条件更新确保幂等性
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusApproved,
"processor_id": userID,
"processed_at": now,
"approved_refund_amount": approvedAmount,
"remark": req.Remark,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
// 事务提交成功后,异步执行佣金回扣和资产重置(失败不影响审批结果)
go func() {
asyncCtx := context.Background()
s.deductAllCommission(asyncCtx, id)
}()
go func() {
asyncCtx := context.Background()
s.handleAssetReset(asyncCtx, id)
}()
return nil
}
// Reject 审批拒绝退款申请
// 条件更新 WHERE status=1
func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
now := time.Now()
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusRejected,
"processor_id": userID,
"processed_at": now,
"reject_reason": req.RejectReason,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
return nil
}
// Return 退回退款申请
// 条件更新 WHERE status=1退回后可重新提交
func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
now := time.Now()
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusReturned,
"processor_id": userID,
"processed_at": now,
"remark": req.Remark,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
return nil
}
// Resubmit 重新提交退款申请
// 条件更新 WHERE status=4已退回修改部分字段后重新进入待审批状态
func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
now := time.Now()
updates := map[string]any{
"status": model.RefundStatusPending,
"updater": userID,
"updated_at": now,
}
if req.ActualReceivedAmount != nil {
updates["actual_received_amount"] = *req.ActualReceivedAmount
}
if req.RequestedRefundAmount != nil {
updates["requested_refund_amount"] = *req.RequestedRefundAmount
}
if req.RefundReason != nil {
updates["refund_reason"] = *req.RefundReason
}
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
}
return nil
}
// deductAllCommission 异步回扣该订单所有已入账佣金
// 查询退款单关联订单的所有已入账佣金记录,逐条从代理佣金钱包扣减
// 失败仅记录日志,不影响审批结果
func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
logger := s.logger
// 查询退款单
var refund model.RefundRequest
if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil {
logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
return
}
// 查询该订单所有已入账佣金记录
var commissions []model.CommissionRecord
if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, model.CommissionStatusReleased).Find(&commissions).Error; err != nil {
logger.Error("佣金回扣:查询佣金记录失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
return
}
if len(commissions) == 0 {
logger.Info("佣金回扣:无已入账佣金记录", zap.Uint("refund_id", refundID))
// 无佣金需要回扣,直接标记完成
s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true)
return
}
// 对每条佣金记录执行扣减
for _, commission := range commissions {
if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil {
logger.Error("佣金回扣:单条佣金扣减失败",
zap.Uint("refund_id", refundID),
zap.Uint("commission_id", commission.ID),
zap.Uint("shop_id", commission.ShopID),
zap.Int64("amount", commission.Amount),
zap.Error(err),
)
// 继续处理下一条,不中断
}
}
// 全部完成后标记退款单佣金已回扣
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil {
logger.Error("佣金回扣:更新回扣标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
}
}
// deductSingleCommission 扣减单条佣金记录对应的代理钱包余额
// 使用乐观锁扣减(允许余额为负数),并创建交易流水
func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error {
// 获取对应代理的佣金钱包
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, commission.ShopID)
if err != nil {
return fmt.Errorf("获取佣金钱包失败: shop_id=%d, %w", commission.ShopID, err)
}
// 乐观锁扣减余额(允许负数)
result := s.db.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", commission.Amount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return fmt.Errorf("扣减钱包余额失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("乐观锁冲突: wallet_id=%d, version=%d", wallet.ID, wallet.Version)
}
// 创建交易流水
refType := constants.ReferenceTypeRefund
refID := refund.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: commission.ShopID,
UserID: refund.Creator,
TransactionType: constants.AgentTransactionTypeCommissionDeduct,
Amount: -commission.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - commission.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &refID,
Creator: refund.Creator,
ShopIDTag: commission.ShopID,
}
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, s.db, transaction); err != nil {
return fmt.Errorf("创建交易流水失败: %w", err)
}
return nil
}
// handleAssetReset 异步处理资产重置
// 包括套餐失效、停机、世代重置generation+1、钱包重建、订单状态更新
// 失败仅记录日志,不影响审批结果
func (s *Service) handleAssetReset(ctx context.Context, refundID uint) {
logger := s.logger
// 查询退款单
var refund model.RefundRequest
if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil {
logger.Error("资产重置:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
return
}
// 查询关联订单
var order model.Order
if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
logger.Error("资产重置:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
return
}
// 确定资产类型和 ID
var assetType string
var assetID uint
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil {
logger.Error("资产重置:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID))
return
}
assetType = "iot_card"
assetID = *order.IotCardID
case model.OrderTypeDevice:
if order.DeviceID == nil {
logger.Error("资产重置:设备订单缺少 device_id", zap.Uint("order_id", order.ID))
return
}
assetType = "device"
assetID = *order.DeviceID
default:
logger.Error("资产重置:未知订单类型", zap.String("order_type", order.OrderType))
return
}
// 1. 失效所有套餐
if s.packageActivationService != nil {
if err := s.packageActivationService.InvalidateAllPackagesByAsset(ctx, assetType, assetID); err != nil {
logger.Error("资产重置:失效套餐失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err))
}
}
// 2. 停机
s.stopAsset(ctx, assetType, assetID)
// 3. 世代重置(参照 exchange/service.go
if err := s.resetAssetGeneration(ctx, assetType, assetID); err != nil {
logger.Error("资产重置:世代重置失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err))
return
}
// 4. 更新订单支付状态为已退款
if err := s.db.Model(&model.Order{}).Where("id = ?", order.ID).Update("payment_status", model.PaymentStatusRefunded).Error; err != nil {
logger.Error("资产重置:更新订单状态失败", zap.Uint("order_id", order.ID), zap.Error(err))
}
// 5. 标记退款单资产已重置
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil {
logger.Error("资产重置:更新重置标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
}
}
// stopAsset 根据资产类型执行停机操作
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) {
logger := s.logger
switch assetType {
case "iot_card":
// 单卡停机:需要先查卡获取 iccid
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
logger.Error("资产重置:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err))
return
}
if s.stopResumeService != nil {
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("资产重置:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err))
}
}
case "device":
// 设备停机
if s.deviceService != nil {
if _, err := s.deviceService.StopDevice(ctx, assetID); err != nil {
logger.Error("资产重置:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err))
}
}
}
}
// resetAssetGeneration 执行资产世代重置
// 参照 exchange/service.go 实现:
// 1. generation+1, asset_status=1, 清零累计充值和佣金标记
// 2. 清理个人客户绑定
// 3. 删除旧钱包、创建新空钱包
func (s *Service) resetAssetGeneration(ctx context.Context, assetType string, assetID uint) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
if assetType == "iot_card" {
var card model.IotCard
if err := tx.Where("id = ?", assetID).First(&card).Error; err != nil {
return fmt.Errorf("查询卡信息失败: %w", err)
}
// 世代+1, 重置状态和累计数据
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": 1,
"accumulated_recharge": 0,
"first_commission_paid": false,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("重置卡世代失败: %w", err)
}
// 清理个人客户绑定(按 virtual_no
if card.VirtualNo != "" {
if err := tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return fmt.Errorf("清理个人客户绑定失败: %w", err)
}
}
// 删除旧钱包、创建新空钱包
if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return fmt.Errorf("清理旧钱包失败: %w", err)
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if err := tx.Create(&model.AssetWallet{
ResourceType: constants.ExchangeAssetTypeIotCard,
ResourceID: card.ID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
Status: 1,
Version: 0,
ShopIDTag: shopTag,
}).Error; err != nil {
return fmt.Errorf("创建新钱包失败: %w", err)
}
return nil
}
// 设备类型
var device model.Device
if err := tx.Where("id = ?", assetID).First(&device).Error; err != nil {
return fmt.Errorf("查询设备失败: %w", err)
}
// 世代+1, 重置状态和累计数据
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
"asset_status": 1,
"accumulated_recharge": 0,
"first_commission_paid": false,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("重置设备世代失败: %w", err)
}
// 清理个人客户绑定(按 virtual_no
if device.VirtualNo != "" {
if err := tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return fmt.Errorf("清理个人客户绑定失败: %w", err)
}
}
// 删除旧钱包、创建新空钱包
if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return fmt.Errorf("清理旧钱包失败: %w", err)
}
shopTag := uint(0)
if device.ShopID != nil {
shopTag = *device.ShopID
}
if err := tx.Create(&model.AssetWallet{
ResourceType: constants.ExchangeAssetTypeDevice,
ResourceID: device.ID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
Status: 1,
Version: 0,
ShopIDTag: shopTag,
}).Error; err != nil {
return fmt.Errorf("创建新钱包失败: %w", err)
}
return nil
})
}
// generateRefundNo 生成退款单号
// 格式RF + 日期时间 + 6位随机数
func generateRefundNo() string {
now := time.Now()
randomNum := rand.Intn(1000000)
return fmt.Sprintf("RF%s%06d", now.Format("20060102150405"), randomNum)
}
// buildRefundResponse 将退款 Model 转换为 DTO 响应
func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
resp := &dto.RefundResponse{
ID: r.ID,
RefundNo: r.RefundNo,
OrderID: r.OrderID,
PackageUsageID: r.PackageUsageID,
ShopID: r.ShopID,
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundReason: r.RefundReason,
Status: r.Status,
ProcessorID: r.ProcessorID,
RejectReason: r.RejectReason,
Remark: r.Remark,
CommissionDeducted: r.CommissionDeducted,
AssetReset: r.AssetReset,
Creator: r.Creator,
Updater: r.Updater,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if r.ProcessedAt != nil {
resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
}
return resp
}

View File

@@ -0,0 +1,119 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
// RefundListFilters 退款列表筛选条件
type RefundListFilters struct {
Status *int `json:"status"`
OrderID *uint `json:"order_id"`
ShopID *uint `json:"shop_id"`
}
// RefundStore 退款申请数据访问层
type RefundStore struct {
db *gorm.DB
}
// NewRefundStore 创建退款申请 Store 实例
func NewRefundStore(db *gorm.DB) *RefundStore {
return &RefundStore{db: db}
}
// Create 创建退款申请记录
func (s *RefundStore) Create(ctx context.Context, req *model.RefundRequest) error {
return s.db.WithContext(ctx).Create(req).Error
}
// GetByID 根据 ID 查询退款申请(含数据权限过滤)
func (s *RefundStore) GetByID(ctx context.Context, id uint) (*model.RefundRequest, error) {
var req model.RefundRequest
query := s.db.WithContext(ctx).Where("id = ?", id)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&req).Error; err != nil {
return nil, err
}
return &req, nil
}
// Update 更新退款申请记录(全字段保存)
func (s *RefundStore) Update(ctx context.Context, req *model.RefundRequest) error {
return s.db.WithContext(ctx).Save(req).Error
}
// UpdateFields 按字段更新退款申请(用于条件更新场景)
func (s *RefundStore) UpdateFields(ctx context.Context, id uint, updates map[string]any) error {
return s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ?", id).
Updates(updates).Error
}
// List 分页查询退款申请列表(含数据权限过滤和筛选条件)
func (s *RefundStore) List(ctx context.Context, opts *store.QueryOptions, filters *RefundListFilters) ([]*model.RefundRequest, int64, error) {
var requests []*model.RefundRequest
var total int64
query := s.db.WithContext(ctx).Model(&model.RefundRequest{})
query = middleware.ApplyShopFilter(ctx, query)
if filters != nil {
if filters.Status != nil {
query = query.Where("status = ?", *filters.Status)
}
if filters.OrderID != nil {
query = query.Where("order_id = ?", *filters.OrderID)
}
if filters.ShopID != nil {
query = query.Where("shop_id = ?", *filters.ShopID)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&requests).Error; err != nil {
return nil, 0, err
}
return requests, total, nil
}
// FindActiveByOrderID 查找订单关联的活跃退款申请(状态为待审批、已通过、已退回)
func (s *RefundStore) FindActiveByOrderID(ctx context.Context, orderID uint) (*model.RefundRequest, error) {
var req model.RefundRequest
err := s.db.WithContext(ctx).
Where("order_id = ? AND status IN ?", orderID, []int{
model.RefundStatusPending,
model.RefundStatusApproved,
model.RefundStatusReturned,
}).
First(&req).Error
if err != nil {
return nil, err
}
return &req, nil
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS tb_refund_request;

View File

@@ -0,0 +1,59 @@
-- 创建退款申请表
CREATE TABLE IF NOT EXISTS tb_refund_request (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
-- 退款单基础信息
refund_no VARCHAR(50) NOT NULL,
order_id BIGINT NOT NULL,
package_usage_id BIGINT,
shop_id BIGINT,
-- 金额信息
actual_received_amount BIGINT NOT NULL,
requested_refund_amount BIGINT NOT NULL,
approved_refund_amount BIGINT,
-- 退款原因和状态
refund_reason TEXT,
status INT NOT NULL DEFAULT 1,
-- 审批信息
processor_id BIGINT,
processed_at TIMESTAMP WITH TIME ZONE,
reject_reason TEXT,
remark TEXT,
-- 后处理标记
commission_deducted BOOLEAN NOT NULL DEFAULT FALSE,
asset_reset BOOLEAN NOT NULL DEFAULT FALSE
);
-- 索引
CREATE UNIQUE INDEX idx_refund_request_refund_no ON tb_refund_request (refund_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_refund_request_order_id ON tb_refund_request (order_id);
CREATE INDEX idx_refund_request_shop_id ON tb_refund_request (shop_id);
CREATE INDEX idx_refund_request_status ON tb_refund_request (status);
CREATE INDEX idx_refund_request_deleted_at ON tb_refund_request (deleted_at);
-- 字段注释
COMMENT ON TABLE tb_refund_request IS '退款申请表';
COMMENT ON COLUMN tb_refund_request.refund_no IS '退款单号RF+日期时间+6位随机数';
COMMENT ON COLUMN tb_refund_request.order_id IS '关联订单ID';
COMMENT ON COLUMN tb_refund_request.package_usage_id IS '关联套餐使用记录ID可选';
COMMENT ON COLUMN tb_refund_request.shop_id IS '店铺ID从订单获取用于数据权限过滤';
COMMENT ON COLUMN tb_refund_request.actual_received_amount IS '实收金额(分)';
COMMENT ON COLUMN tb_refund_request.requested_refund_amount IS '申请退款金额(分)';
COMMENT ON COLUMN tb_refund_request.approved_refund_amount IS '审批实际退款金额(分)';
COMMENT ON COLUMN tb_refund_request.refund_reason IS '退款原因';
COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回';
COMMENT ON COLUMN tb_refund_request.processor_id IS '审批人ID';
COMMENT ON COLUMN tb_refund_request.processed_at IS '审批时间';
COMMENT ON COLUMN tb_refund_request.reject_reason IS '拒绝原因';
COMMENT ON COLUMN tb_refund_request.remark IS '审批备注';
COMMENT ON COLUMN tb_refund_request.commission_deducted IS '佣金是否已回扣';
COMMENT ON COLUMN tb_refund_request.asset_reset IS '资产是否已重置(套餐停用+停机+世代重置)';

View File

@@ -23,11 +23,12 @@ const (
// 代理钱包交易类型
const (
AgentTransactionTypeRecharge = "recharge" // 充值
AgentTransactionTypeDeduct = "deduct" // 扣款
AgentTransactionTypeRefund = "refund" // 退款
AgentTransactionTypeCommission = "commission" // 分佣
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
AgentTransactionTypeRecharge = "recharge" // 充值
AgentTransactionTypeDeduct = "deduct" // 扣款
AgentTransactionTypeRefund = "refund" // 退款
AgentTransactionTypeCommission = "commission" // 分佣
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
AgentTransactionTypeCommissionDeduct = "commission_deduct" // 退款佣金回扣
)
// 代理钱包交易子类型(当 transaction_type = "deduct" 用于订单支付时)
@@ -112,6 +113,7 @@ const (
ReferenceTypeCommission = "commission" // 分佣
ReferenceTypeWithdrawal = "withdrawal" // 提现
ReferenceTypeTopup = "topup" // 充值
ReferenceTypeRefund = "refund" // 退款
)
// ========== Redis Key 生成函数 ==========

View File

@@ -58,5 +58,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
AssetWallet: admin.NewAssetWalletHandler(nil),
WechatConfig: admin.NewWechatConfigHandler(nil),
AgentRecharge: admin.NewAgentRechargeHandler(nil),
Refund: admin.NewRefundHandler(nil),
}
}