feat: 新增退款管理模块 — 完整的退款审批流程、佣金回扣和资产重置
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s
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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ type Handlers struct {
|
||||
AssetWallet *admin.AssetWalletHandler
|
||||
WechatConfig *admin.WechatConfigHandler
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
}
|
||||
|
||||
// Middlewares 封装所有中间件
|
||||
|
||||
@@ -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:"设备ID(IMEI/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:"设备ID(IMEI/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)"`
|
||||
|
||||
150
internal/handler/admin/refund.go
Normal file
150
internal/handler/admin/refund.go
Normal 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)
|
||||
}
|
||||
84
internal/model/dto/refund_dto.go
Normal file
84
internal/model/dto/refund_dto.go
Normal 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
53
internal/model/refund.go
Normal 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 // 已退回
|
||||
)
|
||||
@@ -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
81
internal/routes/refund.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
670
internal/service/refund/service.go
Normal file
670
internal/service/refund/service.go
Normal 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
|
||||
}
|
||||
119
internal/store/postgres/refund_store.go
Normal file
119
internal/store/postgres/refund_store.go
Normal 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
|
||||
}
|
||||
1
migrations/000093_create_refund_request.down.sql
Normal file
1
migrations/000093_create_refund_request.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tb_refund_request;
|
||||
59
migrations/000093_create_refund_request.up.sql
Normal file
59
migrations/000093_create_refund_request.up.sql
Normal 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 '资产是否已重置(套餐停用+停机+世代重置)';
|
||||
@@ -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 生成函数 ==========
|
||||
|
||||
@@ -58,5 +58,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
AssetWallet: admin.NewAssetWalletHandler(nil),
|
||||
WechatConfig: admin.NewWechatConfigHandler(nil),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(nil),
|
||||
Refund: admin.NewRefundHandler(nil),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user