diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index b53f74d..cb88817 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index efff415..ccf0d31 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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, + ), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 7076d8d..405f57c 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -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), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 3f07091..7479d5b 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -60,6 +60,7 @@ type Handlers struct { AssetWallet *admin.AssetWalletHandler WechatConfig *admin.WechatConfigHandler AgentRecharge *admin.AgentRechargeHandler + Refund *admin.RefundHandler } // Middlewares 封装所有中间件 diff --git a/internal/gateway/models.go b/internal/gateway/models.go index c8f53ef..1fd859b 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -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)"` diff --git a/internal/handler/admin/refund.go b/internal/handler/admin/refund.go new file mode 100644 index 0000000..52bca00 --- /dev/null +++ b/internal/handler/admin/refund.go @@ -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) +} diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go new file mode 100644 index 0000000..1e6666c --- /dev/null +++ b/internal/model/dto/refund_dto.go @@ -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:"每页数量"` +} diff --git a/internal/model/refund.go b/internal/model/refund.go new file mode 100644 index 0000000..e2ed2bd --- /dev/null +++ b/internal/model/refund.go @@ -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 // 已退回 +) diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 6126830..2076c57 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -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) + } } diff --git a/internal/routes/refund.go b/internal/routes/refund.go new file mode 100644 index 0000000..6ff792c --- /dev/null +++ b/internal/routes/refund.go @@ -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, + }) +} diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 697786e..21a3653 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -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 { diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 7bd4def..926d2c0 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -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 +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go new file mode 100644 index 0000000..3079973 --- /dev/null +++ b/internal/service/refund/service.go @@ -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 +} diff --git a/internal/store/postgres/refund_store.go b/internal/store/postgres/refund_store.go new file mode 100644 index 0000000..5b61bea --- /dev/null +++ b/internal/store/postgres/refund_store.go @@ -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 +} diff --git a/migrations/000093_create_refund_request.down.sql b/migrations/000093_create_refund_request.down.sql new file mode 100644 index 0000000..895c43c --- /dev/null +++ b/migrations/000093_create_refund_request.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_refund_request; diff --git a/migrations/000093_create_refund_request.up.sql b/migrations/000093_create_refund_request.up.sql new file mode 100644 index 0000000..badaab4 --- /dev/null +++ b/migrations/000093_create_refund_request.up.sql @@ -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 '资产是否已重置(套餐停用+停机+世代重置)'; diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index b6f931e..74d137c 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -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 生成函数 ========== diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 6206ad9..2184075 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -58,5 +58,6 @@ func BuildDocHandlers() *bootstrap.Handlers { AssetWallet: admin.NewAssetWalletHandler(nil), WechatConfig: admin.NewWechatConfigHandler(nil), AgentRecharge: admin.NewAgentRechargeHandler(nil), + Refund: admin.NewRefundHandler(nil), } }