diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 7fea7db..460221c 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -139,8 +139,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { }(), WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), - Refund: admin.NewRefundHandler(svc.Refund), - ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), + Refund: admin.NewRefundHandler(svc.Refund), + OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate), + ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword), AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate), } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 6027bd5..0c00075 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -46,6 +46,7 @@ import ( agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge" operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password" + orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate" 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" @@ -111,6 +112,7 @@ type services struct { OperationPassword *operationPasswordSvc.Service AgentOpenAPI *agentOpenAPISvc.Service CustomerBinding *customerBindingSvc.Service + OrderPackageInvalidate *orderPackageInvalidateSvc.Service } func initServices(s *stores, deps *Dependencies) *services { @@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services { s.AssetWallet, deps.Logger, ), - CustomerBinding: customerBinding, + CustomerBinding: customerBinding, + OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index e7eaaab..a6f3e7e 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -66,6 +66,8 @@ type stores struct { WechatConfig *postgres.WechatConfigStore // 退款系统 RefundRequest *postgres.RefundStore + // 订单套餐失效任务 + OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore // 资产标识符注册表 @@ -131,8 +133,9 @@ func initStores(deps *Dependencies) *stores { RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis), Payment: postgres.NewPaymentStore(deps.DB, deps.Redis), WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), - RefundRequest: postgres.NewRefundStore(deps.DB), - CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), - AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), + RefundRequest: postgres.NewRefundStore(deps.DB), + CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), + OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 75d870e..06cad8d 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -63,6 +63,7 @@ type Handlers struct { WechatConfig *admin.WechatConfigHandler AgentRecharge *admin.AgentRechargeHandler Refund *admin.RefundHandler + OrderPackageInvalidate *admin.OrderPackageInvalidateHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler AgentOpenAPI *openapiHandler.Handler diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index b9aea91..73d64b3 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -33,8 +33,9 @@ type workerStores struct { AgentWalletTransaction *postgres.AgentWalletTransactionStore AssetWallet *postgres.AssetWalletStore AssetIdentifier *postgres.AssetIdentifierStore - PersonalCustomer *postgres.PersonalCustomerStore - PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + PersonalCustomer *postgres.PersonalCustomerStore + PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { @@ -66,8 +67,9 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), - PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), - PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), + PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), + PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), + OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), } return &queue.WorkerStores{ @@ -98,7 +100,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AgentWalletTransaction: stores.AgentWalletTransaction, AssetWallet: stores.AssetWallet, AssetIdentifier: stores.AssetIdentifier, - PersonalCustomer: stores.PersonalCustomer, - PersonalCustomerPhone: stores.PersonalCustomerPhone, + PersonalCustomer: stores.PersonalCustomer, + PersonalCustomerPhone: stores.PersonalCustomerPhone, + OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, } } diff --git a/internal/handler/admin/order_package_invalidate.go b/internal/handler/admin/order_package_invalidate.go new file mode 100644 index 0000000..e1ce445 --- /dev/null +++ b/internal/handler/admin/order_package_invalidate.go @@ -0,0 +1,92 @@ +package admin + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + invalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate" + "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/response" +) + +// OrderPackageInvalidateHandler 订单套餐批量失效任务 Handler +type OrderPackageInvalidateHandler struct { + service *invalidateSvc.Service +} + +// NewOrderPackageInvalidateHandler 创建 Handler 实例 +func NewOrderPackageInvalidateHandler(service *invalidateSvc.Service) *OrderPackageInvalidateHandler { + return &OrderPackageInvalidateHandler{service: service} +} + +// Create 创建批量失效订单套餐任务 +// POST /api/admin/order-package-invalidate-tasks +func (h *OrderPackageInvalidateHandler) Create(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可创建套餐失效任务") + } + + var req dto.CreateOrderPackageInvalidateTaskRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + if req.FileKey == "" { + 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/order-package-invalidate-tasks +func (h *OrderPackageInvalidateHandler) List(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务") + } + + var req dto.ListOrderPackageInvalidateTaskRequest + 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.List, result.Total, result.Page, result.PageSize) +} + +// GetByID 查询批量失效任务详情 +// GET /api/admin/order-package-invalidate-tasks/:id +func (h *OrderPackageInvalidateHandler) GetByID(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务详情") + } + + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 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) +} diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index 82e9f74..3479e00 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -82,7 +82,8 @@ type AgentRechargeRecord struct { PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` - PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:支付凭证对象存储Key(线下支付时必填,微信支付时为空)" json:"payment_voucher_key,omitempty"` + PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"` + Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` diff --git a/internal/model/device_import_task.go b/internal/model/device_import_task.go index 01b7132..0774ee5 100644 --- a/internal/model/device_import_task.go +++ b/internal/model/device_import_task.go @@ -29,6 +29,7 @@ type DeviceImportTask struct { StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"` RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"` } // TableName 指定表名 diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index 21fd635..ca57ac2 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -5,7 +5,8 @@ type CreateAgentRechargeRequest struct { ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"` Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"` - PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" description:"支付凭证对象存储Key(payment_method=offline 时必填,微信支付时忽略)"` + PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"` + Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` } // AgentOfflinePayRequest 代理线下充值确认请求 @@ -31,7 +32,8 @@ type AgentRechargeResponse struct { PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"` PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"` PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"` - PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"支付凭证对象存储Key(线下支付时存在)"` + PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"` + Remark string `json:"remark,omitempty" description:"运营备注"` Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"` StatusName string `json:"status_name" description:"状态名称(中文)"` PaidAt *string `json:"paid_at" description:"支付时间"` diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go index 37e2e8a..a3f4849 100644 --- a/internal/model/dto/device_import_dto.go +++ b/internal/model/dto/device_import_dto.go @@ -39,6 +39,7 @@ type DeviceImportTaskResponse struct { StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"` CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"` ErrorMessage string `json:"error_message,omitempty" description:"错误信息"` + CreatorName string `json:"creator_name" description:"操作人姓名"` CreatedAt time.Time `json:"created_at" description:"创建时间"` } diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index d26d9d0..a9d878d 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -126,6 +126,7 @@ type ImportTaskResponse struct { StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"` CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"` ErrorMessage string `json:"error_message,omitempty" description:"错误信息"` + CreatorName string `json:"creator_name" description:"操作人姓名"` CreatedAt time.Time `json:"created_at" description:"创建时间"` } diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index a3c0315..6744eff 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct { Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"` PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` - PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"线下支付凭证对象存储file_key(payment_method=offline时必填,通过/storage/upload-url上传图片后获得)"` + PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` } type OrderListRequest struct { @@ -63,7 +63,7 @@ type OrderResponse struct { PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"` PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"` PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` - PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"线下支付凭证对象存储file_key(仅线下支付订单有值)"` + PaymentVoucherKey []string `json:"payment_voucher_key" description:"线下支付凭证对象存储file_key列表(线下支付订单有值,最多5个)"` IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"` CommissionStatus int `json:"commission_status" description:"佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理)"` CommissionStatusName string `json:"commission_status_name" description:"佣金流程状态名称(中文)"` diff --git a/internal/model/dto/order_package_invalidate_dto.go b/internal/model/dto/order_package_invalidate_dto.go new file mode 100644 index 0000000..4e249d3 --- /dev/null +++ b/internal/model/dto/order_package_invalidate_dto.go @@ -0,0 +1,56 @@ +package dto + +// CreateOrderPackageInvalidateTaskRequest 创建订单套餐失效任务请求 +type CreateOrderPackageInvalidateTaskRequest struct { + FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"CSV文件对象存储Key(通过/storage/upload-url上传后获得;CSV单列 order_no)"` + VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(可选,最多5个)"` + Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` +} + +// OrderPackageInvalidateTaskResponse 任务详情响应 +type OrderPackageInvalidateTaskResponse struct { + ID uint `json:"id" description:"任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + TotalCount int `json:"total_count" description:"总行数"` + SuccessCount int `json:"success_count" description:"成功数"` + FailCount int `json:"fail_count" description:"失败数"` + FileName string `json:"file_name" description:"原始文件名"` + VoucherKeys []string `json:"voucher_keys" description:"支付凭证Key列表"` + Remark string `json:"remark,omitempty" description:"运营备注"` + CreatorName string `json:"creator_name" description:"操作人姓名"` + ErrorMessage string `json:"error_message,omitempty" description:"任务级错误信息"` + StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"` + CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +// OrderPackageInvalidateTaskDetailResponse 任务详情(含失败明细) +type OrderPackageInvalidateTaskDetailResponse struct { + OrderPackageInvalidateTaskResponse + FailedItems []InvalidateFailedItem `json:"failed_items" description:"失败记录列表"` +} + +// InvalidateFailedItem 失效失败记录 +type InvalidateFailedItem struct { + Line int `json:"line" description:"CSV 行号"` + OrderNo string `json:"order_no" description:"订单号"` + Reason string `json:"reason" description:"失败原因"` +} + +// ListOrderPackageInvalidateTaskRequest 任务列表查询请求 +type ListOrderPackageInvalidateTaskRequest 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:失败)"` +} + +// OrderPackageInvalidateTaskListResponse 任务列表响应 +type OrderPackageInvalidateTaskListResponse struct { + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页条数"` + List []*OrderPackageInvalidateTaskResponse `json:"items" description:"任务列表"` +} diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index ef0b48c..5d13754 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -5,7 +5,7 @@ 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:"申请退款金额(分)"` - RefundVoucherKey string `json:"refund_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"退款凭证对象存储file_key(通过/storage/upload-url上传图片后获得)"` + RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"` } @@ -27,7 +27,7 @@ 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:"申请退款金额(分)"` - RefundVoucherKey *string `json:"refund_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"退款凭证对象存储file_key(重新提交时可替换;历史记录缺失时必填)"` + RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"` RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` } @@ -70,7 +70,7 @@ type RefundResponse struct { ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"` ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` - RefundVoucherKey string `json:"refund_voucher_key,omitempty" description:"退款凭证对象存储file_key"` + RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"` RefundReason string `json:"refund_reason" description:"退款原因"` Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"` StatusName string `json:"status_name" description:"状态名称(中文)"` diff --git a/internal/model/iot_card_import_task.go b/internal/model/iot_card_import_task.go index c2e42f8..4d2eae0 100644 --- a/internal/model/iot_card_import_task.go +++ b/internal/model/iot_card_import_task.go @@ -33,6 +33,7 @@ type IotCardImportTask struct { StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"` CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"` RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"` } // CardItem 卡信息(ICCID + MSISDN + VirtualNo) diff --git a/internal/model/order.go b/internal/model/order.go index f296b59..d50fe39 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -70,8 +70,8 @@ type Order struct { // 支付配置 PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` - // 线下支付凭证对象存储 file_key(仅线下支付订单必填) - PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:线下支付凭证对象存储file_key" json:"payment_voucher_key,omitempty"` + // 线下支付凭证对象存储 file_key 列表(最多5个,仅线下支付订单必填) + PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表(jsonb存储[]string)" json:"payment_voucher_key"` } // TableName 指定表名 diff --git a/internal/model/order_package_invalidate_task.go b/internal/model/order_package_invalidate_task.go new file mode 100644 index 0000000..2594c7a --- /dev/null +++ b/internal/model/order_package_invalidate_task.go @@ -0,0 +1,36 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// OrderPackageInvalidateTask 订单套餐批量失效任务模型 +type OrderPackageInvalidateTask struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"` + TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"` + FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"` + FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;not null;default:'[]';comment:失败记录详情(jsonb)" json:"failed_items"` + FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"` + VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表(最多5个)" json:"voucher_keys"` + Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"` + ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"` + StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定表名 +func (OrderPackageInvalidateTask) TableName() string { + return "tb_order_package_invalidate_task" +} diff --git a/internal/model/refund.go b/internal/model/refund.go index 599093c..f8cb2cb 100644 --- a/internal/model/refund.go +++ b/internal/model/refund.go @@ -31,7 +31,7 @@ type RefundRequest struct { ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount;type:bigint;comment:审批实际退款金额(分)" json:"approved_refund_amount,omitempty"` // 退款凭证、原因和状态 - RefundVoucherKey string `gorm:"column:refund_voucher_key;type:varchar(500);not null;default:'';comment:退款凭证对象存储file_key" json:"refund_voucher_key,omitempty"` + RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表(jsonb存储[]string)" json:"refund_voucher_key"` 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"` diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..ac19de8 --- /dev/null +++ b/internal/model/types.go @@ -0,0 +1,32 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" +) + +// StringJSONBArray 用于将 []string 与 PostgreSQL jsonb 列互转 +// 读写时通过 Scan/Value 完成序列化,空切片序列化为 [] +type StringJSONBArray []string + +// Value 写入数据库时序列化为 JSON +func (a StringJSONBArray) Value() (driver.Value, error) { + if a == nil { + return "[]", nil + } + return json.Marshal(a) +} + +// Scan 从数据库读取时反序列化 +func (a *StringJSONBArray) Scan(value any) error { + if value == nil { + *a = StringJSONBArray{} + return nil + } + b, ok := value.([]byte) + if !ok { + *a = StringJSONBArray{} + return nil + } + return json.Unmarshal(b, a) +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 5d7de6d..ac26298 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -125,6 +125,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.Refund != nil { registerRefundRoutes(authGroup, handlers.Refund, doc, basePath) } + if handlers.OrderPackageInvalidate != nil { + registerOrderPackageInvalidateRoutes(authGroup, handlers.OrderPackageInvalidate, doc, basePath) + } if handlers.SuperAdmin != nil { registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath) } diff --git a/internal/routes/order_package_invalidate.go b/internal/routes/order_package_invalidate.go new file mode 100644 index 0000000..b9f0b86 --- /dev/null +++ b/internal/routes/order_package_invalidate.go @@ -0,0 +1,40 @@ +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/openapi" +) + +// registerOrderPackageInvalidateRoutes 注册订单套餐批量失效任务路由 +func registerOrderPackageInvalidateRoutes(router fiber.Router, handler *admin.OrderPackageInvalidateHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/order-package-invalidate-tasks") + groupPath := basePath + "/order-package-invalidate-tasks" + + Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{ + Summary: "创建订单套餐批量失效任务", + Description: "上传单列 CSV 文件(列名 order_no),批量将订单下的非终态套餐标记为已失效(status=4)。", + Tags: []string{"订单套餐失效"}, + Input: new(dto.CreateOrderPackageInvalidateTaskRequest), + Output: new(dto.OrderPackageInvalidateTaskResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "查询订单套餐失效任务列表", + Tags: []string{"订单套餐失效"}, + Input: new(dto.ListOrderPackageInvalidateTaskRequest), + Output: new(dto.OrderPackageInvalidateTaskListResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{ + Summary: "查询订单套餐失效任务详情", + Tags: []string{"订单套餐失效"}, + Input: new(dto.IDReq), + Output: new(dto.OrderPackageInvalidateTaskDetailResponse), + Auth: true, + }) +} diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index d204893..688a8bf 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "math/rand" - "strings" "time" "github.com/redis/go-redis/v9" @@ -96,7 +95,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques } // 线下充值必须上传支付凭证 - if req.PaymentMethod == "offline" && strings.TrimSpace(req.PaymentVoucherKey) == "" { + if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 { return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证") } @@ -147,7 +146,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques PaymentMethod: req.PaymentMethod, PaymentChannel: &paymentChannel, PaymentConfigID: paymentConfigID, - PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey), + PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey), + Remark: req.Remark, Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, @@ -486,7 +486,8 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe AgentWalletID: record.AgentWalletID, Amount: record.Amount, PaymentMethod: record.PaymentMethod, - PaymentVoucherKey: record.PaymentVoucherKey, + PaymentVoucherKey: []string(record.PaymentVoucherKey), + Remark: record.Remark, Status: record.Status, StatusName: constants.GetRechargeStatusName(record.Status), CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"), diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index cae9e0c..1c362a8 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -60,6 +60,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq FileName: fileName, StorageKey: req.FileKey, RealnamePolicy: req.RealnamePolicy, + CreatorName: middleware.GetUsernameFromContext(ctx), } task.Creator = userID task.Updater = userID @@ -190,21 +191,23 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport } return &dto.DeviceImportTaskResponse{ - ID: task.ID, - TaskNo: task.TaskNo, - Status: task.Status, - StatusText: getStatusText(task.Status), - BatchNo: task.BatchNo, - FileName: task.FileName, - TotalCount: task.TotalCount, - SuccessCount: task.SuccessCount, - SkipCount: task.SkipCount, - FailCount: task.FailCount, - WarningCount: task.WarningCount, - StartedAt: startedAt, - CompletedAt: completedAt, - ErrorMessage: task.ErrorMessage, - CreatedAt: task.CreatedAt, + ID: task.ID, + TaskNo: task.TaskNo, + Status: task.Status, + StatusText: getStatusText(task.Status), + BatchNo: task.BatchNo, + RealnamePolicy: task.RealnamePolicy, + FileName: task.FileName, + TotalCount: task.TotalCount, + SuccessCount: task.SuccessCount, + SkipCount: task.SkipCount, + FailCount: task.FailCount, + WarningCount: task.WarningCount, + StartedAt: startedAt, + CompletedAt: completedAt, + ErrorMessage: task.ErrorMessage, + CreatorName: task.CreatorName, + CreatedAt: task.CreatedAt, } } diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index a0b9f3d..8bf083f 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -132,6 +132,14 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC } } +// isRiskGatewayExtend 判断网关扩展状态是否为风险状态(风险停机或已销户) +// 独立卡命中此状态时禁止发起复机 +func isRiskGatewayExtend(extend string) bool { + trimmed := strings.TrimSpace(extend) + return trimmed == constants.GatewayCardExtendRiskStop || + trimmed == constants.GatewayCardExtendCancelled +} + // isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机) func isPollingStopReason(reason string) bool { switch reason { @@ -692,6 +700,30 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card } gatewayExtend := strings.TrimSpace(card.GatewayExtend) + + // 独立卡处于风险停机或已销户状态时,优先返回明确的拒绝原因 + if card.IsStandalone && isRiskGatewayExtend(gatewayExtend) { + var denyMsg string + if gatewayExtend == constants.GatewayCardExtendRiskStop { + denyMsg = "该卡已被运营商风险停机,不允许复机" + } else { + denyMsg = "该卡已被运营商销户,不允许复机" + } + denyErr := errors.New(errors.CodeForbidden, denyMsg) + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "机卡分离复机被拒绝(风险状态)", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return denyErr + } + if gatewayExtend != constants.GatewayCardExtendMachineSeparated { denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) @@ -880,6 +912,29 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e return denyErr } + // 独立卡处于风险停机或已销户状态时,拒绝复机 + if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) { + var denyMsg string + if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop { + denyMsg = "该卡已被运营商风险停机,不允许复机" + } else { + denyMsg = "该卡已被运营商销户,不允许复机" + } + denyErr := errors.New(errors.CodeForbidden, denyMsg) + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardManualStart, + OperationDesc: "手动复机被拒绝", + ResultStatus: constants.AssetAuditResultDenied, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + BeforeData: cardSnapshot(card), + }) + return denyErr + } + if card.RealNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) diff --git a/internal/service/iot_card/stop_resume_service_test.go b/internal/service/iot_card/stop_resume_service_test.go new file mode 100644 index 0000000..692b6de --- /dev/null +++ b/internal/service/iot_card/stop_resume_service_test.go @@ -0,0 +1,26 @@ +package iot_card + +import "testing" + +// TestIsRiskGatewayExtend 验证网关扩展状态的风险判断逻辑 +func TestIsRiskGatewayExtend(t *testing.T) { + cases := []struct { + extend string + want bool + }{ + {"风险停机", true}, + {"已销户", true}, + {"机卡分离停机", false}, + {"待激活", false}, + {"", false}, + {" 风险停机 ", true}, // 含空白字符 + {"已注销", false}, // 非目标状态 + } + + for _, tc := range cases { + got := isRiskGatewayExtend(tc.extend) + if got != tc.want { + t.Errorf("isRiskGatewayExtend(%q) = %v, 期望 %v", tc.extend, got, tc.want) + } + } +} diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 2445331..a0c7e8a 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -98,6 +98,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe StorageKey: req.FileKey, CardCategory: cardCategory, RealnamePolicy: req.RealnamePolicy, + CreatorName: middleware.GetUsernameFromContext(ctx), } task.Creator = userID task.Updater = userID @@ -224,24 +225,26 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskR } return &dto.ImportTaskResponse{ - ID: task.ID, - TaskNo: task.TaskNo, - Status: task.Status, - StatusText: getStatusText(task.Status), - CarrierID: task.CarrierID, - CarrierType: task.CarrierType, - CarrierName: task.CarrierName, - BatchNo: task.BatchNo, - CardCategory: task.CardCategory, - FileName: task.FileName, - TotalCount: task.TotalCount, - SuccessCount: task.SuccessCount, - SkipCount: task.SkipCount, - FailCount: task.FailCount, - StartedAt: startedAt, - CompletedAt: completedAt, - ErrorMessage: task.ErrorMessage, - CreatedAt: task.CreatedAt, + ID: task.ID, + TaskNo: task.TaskNo, + Status: task.Status, + StatusText: getStatusText(task.Status), + CarrierID: task.CarrierID, + CarrierType: task.CarrierType, + CarrierName: task.CarrierName, + BatchNo: task.BatchNo, + CardCategory: task.CardCategory, + RealnamePolicy: task.RealnamePolicy, + FileName: task.FileName, + TotalCount: task.TotalCount, + SuccessCount: task.SuccessCount, + SkipCount: task.SkipCount, + FailCount: task.FailCount, + StartedAt: startedAt, + CompletedAt: completedAt, + ErrorMessage: task.ErrorMessage, + CreatorName: task.CreatorName, + CreatedAt: task.CreatedAt, } } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index f443a69..619ad7f 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -182,7 +182,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde return nil, err } } - if req.PaymentMethod == model.PaymentMethodOffline && strings.TrimSpace(req.PaymentVoucherKey) == "" { + if req.PaymentMethod == model.PaymentMethodOffline && len(req.PaymentVoucherKey) == 0 { return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证") } @@ -453,9 +453,9 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde PaymentConfigID: paymentConfigID, } - // 线下支付订单写入支付凭证 file_key + // 线下支付订单写入支付凭证 file_key 列表 if req.PaymentMethod == model.PaymentMethodOffline { - order.PaymentVoucherKey = strings.TrimSpace(req.PaymentVoucherKey) + order.PaymentVoucherKey = model.StringJSONBArray(req.PaymentVoucherKey) } if orderBuyerType == model.BuyerTypePersonal { @@ -2533,7 +2533,7 @@ func (s *Service) buildOrderResponse(ctx context.Context, order *model.Order, it PaymentStatus: order.PaymentStatus, PaymentStatusText: statusText, PaidAt: order.PaidAt, - PaymentVoucherKey: order.PaymentVoucherKey, + PaymentVoucherKey: []string(order.PaymentVoucherKey), IsPurchaseOnBehalf: order.IsPurchaseOnBehalf, CommissionStatus: order.CommissionStatus, CommissionStatusName: constants.GetOrderCommissionStatusName(order.CommissionStatus), diff --git a/internal/service/order_package_invalidate/service.go b/internal/service/order_package_invalidate/service.go new file mode 100644 index 0000000..262b3aa --- /dev/null +++ b/internal/service/order_package_invalidate/service.go @@ -0,0 +1,185 @@ +package order_package_invalidate + +import ( + "context" + "path/filepath" + "time" + + "github.com/hibiken/asynq" + + "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" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +// Service 订单套餐失效任务业务逻辑层 +type Service struct { + taskStore *postgres.OrderPackageInvalidateTaskStore + queueClient *queue.Client +} + +// New 创建 Service 实例 +func New( + taskStore *postgres.OrderPackageInvalidateTaskStore, + queueClient *queue.Client, +) *Service { + return &Service{ + taskStore: taskStore, + queueClient: queueClient, + } +} + +// InvalidateTaskPayload Worker 任务载荷 +type InvalidateTaskPayload struct { + TaskID uint `json:"task_id"` +} + +// Create 创建订单套餐失效任务 +func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskResponse, error) { + userID := middleware.GetUserIDFromContext(ctx) + if userID == 0 { + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") + } + + task := &model.OrderPackageInvalidateTask{ + TaskNo: s.taskStore.GenerateTaskNo(), + Status: model.ImportTaskStatusPending, + FileName: filepath.Base(req.FileKey), + StorageKey: req.FileKey, + VoucherKeys: model.StringJSONBArray(req.VoucherKeys), + Remark: req.Remark, + CreatorName: middleware.GetUsernameFromContext(ctx), + FailedItems: model.ImportResultItems{}, + Creator: userID, + Updater: userID, + } + + if err := s.taskStore.Create(ctx, task); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建任务失败") + } + + payload := InvalidateTaskPayload{TaskID: task.ID} + err := s.queueClient.EnqueueTask( + ctx, + constants.TaskTypeOrderPackageInvalidate, + payload, + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderPackageInvalidate)), + ) + if err != nil { + s.taskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) + } + + return s.toResponse(task), nil +} + +// List 分页查询任务列表 +func (s *Service) List(ctx context.Context, req *dto.ListOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskListResponse, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = constants.DefaultPageSize + } + + opts := &store.QueryOptions{ + Page: req.Page, + PageSize: req.PageSize, + } + + filters := map[string]interface{}{} + if req.Status != nil { + filters["status"] = *req.Status + } + + tasks, total, err := s.taskStore.List(ctx, opts, filters) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询任务列表失败") + } + + list := make([]*dto.OrderPackageInvalidateTaskResponse, 0, len(tasks)) + for _, t := range tasks { + list = append(list, s.toResponse(t)) + } + + return &dto.OrderPackageInvalidateTaskListResponse{ + Total: total, + Page: req.Page, + PageSize: req.PageSize, + List: list, + }, nil +} + +// GetByID 查询任务详情(含失败明细) +func (s *Service) GetByID(ctx context.Context, id uint) (*dto.OrderPackageInvalidateTaskDetailResponse, error) { + task, err := s.taskStore.GetByID(ctx, id) + if err != nil { + return nil, errors.New(errors.CodeNotFound, "任务不存在") + } + + failedItems := make([]dto.InvalidateFailedItem, 0, len(task.FailedItems)) + for _, item := range task.FailedItems { + failedItems = append(failedItems, dto.InvalidateFailedItem{ + Line: item.Line, + OrderNo: item.ICCID, // 复用 ImportResultItem.ICCID 存储 order_no + Reason: item.Reason, + }) + } + + return &dto.OrderPackageInvalidateTaskDetailResponse{ + OrderPackageInvalidateTaskResponse: *s.toResponse(task), + FailedItems: failedItems, + }, nil +} + +// toResponse 转换为响应 DTO +func (s *Service) toResponse(task *model.OrderPackageInvalidateTask) *dto.OrderPackageInvalidateTaskResponse { + resp := &dto.OrderPackageInvalidateTaskResponse{ + ID: task.ID, + TaskNo: task.TaskNo, + Status: task.Status, + StatusName: invalidateTaskStatusName(task.Status), + TotalCount: task.TotalCount, + SuccessCount: task.SuccessCount, + FailCount: task.FailCount, + FileName: task.FileName, + VoucherKeys: []string(task.VoucherKeys), + Remark: task.Remark, + CreatorName: task.CreatorName, + ErrorMessage: task.ErrorMessage, + CreatedAt: task.CreatedAt.Format(time.RFC3339), + UpdatedAt: task.UpdatedAt.Format(time.RFC3339), + } + if task.StartedAt != nil { + t := task.StartedAt.Format(time.RFC3339) + resp.StartedAt = &t + } + if task.CompletedAt != nil { + t := task.CompletedAt.Format(time.RFC3339) + resp.CompletedAt = &t + } + if resp.VoucherKeys == nil { + resp.VoucherKeys = []string{} + } + return resp +} + +// invalidateTaskStatusName 状态码转中文 +func invalidateTaskStatusName(status int) string { + switch status { + case model.ImportTaskStatusPending: + return "待处理" + case model.ImportTaskStatusProcessing: + return "处理中" + case model.ImportTaskStatusCompleted: + return "已完成" + case model.ImportTaskStatusFailed: + return "失败" + default: + return "未知" + } +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 829372a..11a10a6 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "math/rand" - "strings" "time" "go.uber.org/zap" @@ -619,11 +618,11 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund } refundVoucherKey := refund.RefundVoucherKey if req.RefundVoucherKey != nil { - refundVoucherKey = *req.RefundVoucherKey - } - refundVoucherKey, err = normalizeRefundVoucherKey(refundVoucherKey) - if err != nil { - return err + normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey) + if normErr != nil { + return normErr + } + refundVoucherKey = normalized } order, err := s.orderStore.GetByID(ctx, refund.OrderID) @@ -914,15 +913,14 @@ func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount in return validateRequestedRefundAmountByOrder(approvedAmount, order) } -func normalizeRefundVoucherKey(voucherKey string) (string, error) { - normalized := strings.TrimSpace(voucherKey) - if normalized == "" { - return "", errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证") +func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) { + if len(keys) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证") } - if len(normalized) > 500 { - return "", errors.New(errors.CodeInvalidParam, "退款凭证长度不能超过500") + if len(keys) > 5 { + return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个") } - return normalized, nil + return model.StringJSONBArray(keys), nil } // buildRefundResponse 将退款 Model 转换为 DTO 响应 @@ -949,7 +947,7 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { ActualReceivedAmount: r.ActualReceivedAmount, RequestedRefundAmount: r.RequestedRefundAmount, ApprovedRefundAmount: r.ApprovedRefundAmount, - RefundVoucherKey: r.RefundVoucherKey, + RefundVoucherKey: []string(r.RefundVoucherKey), RefundReason: r.RefundReason, Status: r.Status, StatusName: constants.GetRefundStatusName(r.Status), diff --git a/internal/store/postgres/order_package_invalidate_task_store.go b/internal/store/postgres/order_package_invalidate_task_store.go new file mode 100644 index 0000000..9cd4ac7 --- /dev/null +++ b/internal/store/postgres/order_package_invalidate_task_store.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// OrderPackageInvalidateTaskStore 订单套餐失效任务数据访问层 +type OrderPackageInvalidateTaskStore struct { + db *gorm.DB +} + +// NewOrderPackageInvalidateTaskStore 创建 OrderPackageInvalidateTaskStore 实例 +func NewOrderPackageInvalidateTaskStore(db *gorm.DB) *OrderPackageInvalidateTaskStore { + return &OrderPackageInvalidateTaskStore{db: db} +} + +// Create 创建任务 +func (s *OrderPackageInvalidateTaskStore) Create(ctx context.Context, task *model.OrderPackageInvalidateTask) error { + return s.db.WithContext(ctx).Create(task).Error +} + +// GetByID 按主键查询任务(Worker 直接查询,不过滤租户) +func (s *OrderPackageInvalidateTaskStore) GetByID(ctx context.Context, id uint) (*model.OrderPackageInvalidateTask, error) { + var task model.OrderPackageInvalidateTask + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +// List 分页查询任务列表 +func (s *OrderPackageInvalidateTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.OrderPackageInvalidateTask, int64, error) { + var tasks []*model.OrderPackageInvalidateTask + var total int64 + + query := s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}) + + if status, ok := filters["status"].(int); ok && status > 0 { + query = query.Where("status = ?", status) + } + + 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).Order("created_at DESC") + + if err := query.Find(&tasks).Error; err != nil { + return nil, 0, err + } + + return tasks, total, nil +} + +// UpdateStatus 更新任务状态 +func (s *OrderPackageInvalidateTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error { + updates := map[string]interface{}{ + "status": status, + "updated_at": time.Now(), + } + if status == model.ImportTaskStatusProcessing { + updates["started_at"] = time.Now() + } + if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed { + updates["completed_at"] = time.Now() + } + if errorMessage != "" { + updates["error_message"] = errorMessage + } + return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error +} + +// UpdateResult 更新任务计数和失败明细 +func (s *OrderPackageInvalidateTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, failCount int, failedItems model.ImportResultItems) error { + updates := map[string]interface{}{ + "total_count": totalCount, + "success_count": successCount, + "fail_count": failCount, + "failed_items": failedItems, + "updated_at": time.Now(), + } + return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error +} + +// GenerateTaskNo 生成任务编号(OPINV-YYYYMMDD-XXXXXX) +func (s *OrderPackageInvalidateTaskStore) GenerateTaskNo() string { + now := time.Now() + dateStr := now.Format("20060102") + seq := now.UnixNano() % 1000000 + return fmt.Sprintf("OPINV-%s-%06d", dateStr, seq) +} diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index d7cc09e..28d233c 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -131,6 +131,15 @@ func (s *PackageUsageStore) GetAddonsByMasterID(ctx context.Context, masterUsage return usages, nil } +// ListActiveByOrderID 查询订单下所有非终态套餐记录(status NOT IN 已过期=3, 已失效=4) +func (s *PackageUsageStore) ListActiveByOrderID(ctx context.Context, orderID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + err := s.db.WithContext(ctx). + Where("order_id = ? AND status NOT IN ?", orderID, []int{3, 4}). + Find(&usages).Error + return usages, err +} + // BatchUpdateStatus 批量更新加油包状态 func (s *PackageUsageStore) BatchUpdateStatus(ctx context.Context, ids []uint, status int) error { if len(ids) == 0 { diff --git a/internal/task/order_package_invalidate.go b/internal/task/order_package_invalidate.go new file mode 100644 index 0000000..59a353e --- /dev/null +++ b/internal/task/order_package_invalidate.go @@ -0,0 +1,257 @@ +package task + +import ( + "context" + "encoding/csv" + "io" + "os" + "strings" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/storage" +) + +// OrderPackageInvalidatePayload 批量失效订单套餐任务载荷 +type OrderPackageInvalidatePayload struct { + TaskID uint `json:"task_id"` +} + +// OrderPackageInvalidateHandler 批量失效订单套餐任务处理器 +type OrderPackageInvalidateHandler struct { + taskStore *postgres.OrderPackageInvalidateTaskStore + orderStore *postgres.OrderStore + packageUsageStore *postgres.PackageUsageStore + storageService *storage.Service + logger *zap.Logger +} + +// NewOrderPackageInvalidateHandler 创建处理器实例 +func NewOrderPackageInvalidateHandler( + taskStore *postgres.OrderPackageInvalidateTaskStore, + orderStore *postgres.OrderStore, + packageUsageStore *postgres.PackageUsageStore, + storageSvc *storage.Service, + logger *zap.Logger, +) *OrderPackageInvalidateHandler { + return &OrderPackageInvalidateHandler{ + taskStore: taskStore, + orderStore: orderStore, + packageUsageStore: packageUsageStore, + storageService: storageSvc, + logger: logger, + } +} + +// Handle 处理批量失效订单套餐任务 +// POST /api/admin/order-package-invalidate-tasks +func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Task) error { + var payload OrderPackageInvalidatePayload + if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { + h.logger.Error("解析批量失效任务载荷失败", + zap.Error(err), + zap.String("task_id", t.ResultWriter().TaskID()), + ) + return asynq.SkipRetry + } + + importTask, err := h.taskStore.GetByID(ctx, payload.TaskID) + if err != nil { + h.logger.Error("获取批量失效任务失败", + zap.Uint("task_id", payload.TaskID), + zap.Error(err), + ) + return asynq.SkipRetry + } + + if importTask.Status != model.ImportTaskStatusPending { + h.logger.Info("批量失效任务已处理,跳过", + zap.Uint("task_id", payload.TaskID), + zap.Int("status", importTask.Status), + ) + return nil + } + + h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "") + + h.logger.Info("开始处理批量失效订单套餐任务", + zap.Uint("task_id", importTask.ID), + zap.String("task_no", importTask.TaskNo), + ) + + orderNos, err := h.downloadAndParseCSV(ctx, importTask) + if err != nil { + h.logger.Error("下载或解析 CSV 失败", + zap.Uint("task_id", importTask.ID), + zap.Error(err), + ) + h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error()) + return asynq.SkipRetry + } + + successCount, failedItems := h.processRows(ctx, orderNos) + failCount := len(failedItems) + totalCount := len(orderNos) + + h.taskStore.UpdateResult(ctx, importTask.ID, totalCount, successCount, failCount, + model.ImportResultItems(toImportResultItems(failedItems))) + + if failCount > 0 && successCount == 0 { + h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有行均处理失败") + } else { + h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "") + } + + h.logger.Info("批量失效订单套餐任务完成", + zap.Uint("task_id", importTask.ID), + zap.Int("total", totalCount), + zap.Int("success", successCount), + zap.Int("fail", failCount), + ) + + return nil +} + +// invalidateRow 单行处理结果 +type invalidateRow struct { + line int + orderNo string + reason string +} + +// processRows 逐行处理订单号,返回成功数和失败列表 +func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, rows []string) (int, []invalidateRow) { + successCount := 0 + var failed []invalidateRow + + for i, orderNo := range rows { + line := i + 2 // 第1行为表头,数据从第2行开始 + if err := h.processOneOrder(ctx, orderNo); err != nil { + failed = append(failed, invalidateRow{line: line, orderNo: orderNo, reason: err.Error()}) + } else { + successCount++ + } + } + + return successCount, failed +} + +// processOneOrder 处理单个订单号:查订单 → 查套餐 → 批量更新状态=4 +func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, orderNo string) error { + order, err := h.orderStore.GetByOrderNo(ctx, orderNo) + if err != nil { + return errOrderNotFound(orderNo) + } + + usages, err := h.packageUsageStore.ListActiveByOrderID(ctx, order.ID) + if err != nil { + return errQueryFailed(orderNo) + } + + if len(usages) == 0 { + // 套餐全部已是终态,视为成功 + return nil + } + + ids := make([]uint, 0, len(usages)) + for _, u := range usages { + ids = append(ids, u.ID) + } + + if err := h.packageUsageStore.BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil { + return errUpdateFailed(orderNo) + } + + return nil +} + +// downloadAndParseCSV 从对象存储下载 CSV 并解析 order_no 列 +func (h *OrderPackageInvalidateHandler) downloadAndParseCSV(ctx context.Context, task *model.OrderPackageInvalidateTask) ([]string, error) { + if h.storageService == nil { + return nil, ErrStorageNotConfigured + } + if task.StorageKey == "" { + return nil, ErrStorageKeyEmpty + } + + localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey) + if err != nil { + return nil, err + } + defer cleanup() + + return parseOrderNoCSV(localPath) +} + +// parseOrderNoCSV 解析单列 CSV(第一列为 order_no,首行为表头) +func parseOrderNoCSV(filePath string) ([]string, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + reader := csv.NewReader(f) + reader.TrimLeadingSpace = true + + var orderNos []string + firstRow := true + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if firstRow { + firstRow = false + continue // 跳过表头 + } + if len(record) == 0 { + continue + } + orderNo := strings.TrimSpace(record[0]) + if orderNo == "" { + continue + } + orderNos = append(orderNos, orderNo) + } + + return orderNos, nil +} + +// toImportResultItems 将内部失败记录转换为通用失败明细格式 +// 复用 ImportResultItem,ICCID 字段存储 order_no +func toImportResultItems(rows []invalidateRow) []model.ImportResultItem { + items := make([]model.ImportResultItem, 0, len(rows)) + for _, r := range rows { + items = append(items, model.ImportResultItem{ + Line: r.line, + ICCID: r.orderNo, + Reason: r.reason, + }) + } + return items +} + +func errOrderNotFound(orderNo string) error { + return orderInvalidateError("订单不存在: " + orderNo) +} + +func errQueryFailed(orderNo string) error { + return orderInvalidateError("查询套餐失败: " + orderNo) +} + +func errUpdateFailed(orderNo string) error { + return orderInvalidateError("更新套餐状态失败: " + orderNo) +} + +type orderInvalidateError string + +func (e orderInvalidateError) Error() string { return string(e) } diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 936c77f..41f05ce 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -9,11 +9,23 @@ import ( "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/model" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" ) +// shouldStopPollingForRisk 判断轮询到风险 gateway_extend 时是否应停止该卡的轮询 +// 仅独立卡(is_standalone=true)命中风险状态时才停止;绑定设备的卡不受此逻辑约束 +func shouldStopPollingForRisk(card *model.IotCard, newGatewayExtend string) bool { + if !card.IsStandalone { + return false + } + extend := strings.TrimSpace(newGatewayExtend) + return extend == constants.GatewayCardExtendRiskStop || + extend == constants.GatewayCardExtendCancelled +} + // PollingCardStatusHandler 卡开停机状态轮询任务处理器 // 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估 // 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct @@ -141,6 +153,19 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er h.base.updateCardCache(ctx, cardID, cacheUpdates) } + // 独立卡写入风险 gateway_extend 后:关闭轮询,终止循环 + if statusQueried && shouldStopPollingForRisk(card, gatewayExtend) { + if updateErr := h.iotCardStore.UpdatePollingStatus(ctx, cardID, false); updateErr != nil { + h.base.logger.Warn("关闭风险卡轮询状态失败", + zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend), zap.Error(updateErr)) + } else { + h.base.logger.Info("独立卡命中风险状态,已关闭轮询", + zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend)) + } + h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime)) + return nil // 不再入队,终止轮询循环 + } + if statusChanged { h.base.logger.Info("卡状态已变化", zap.Uint("card_id", cardID), diff --git a/internal/task/polling_cardstatus_handler_test.go b/internal/task/polling_cardstatus_handler_test.go new file mode 100644 index 0000000..aa43eb5 --- /dev/null +++ b/internal/task/polling_cardstatus_handler_test.go @@ -0,0 +1,63 @@ +package task + +import ( + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// TestShouldStopPolling 验证风险状态独立卡的轮询停止判断逻辑 +func TestShouldStopPolling(t *testing.T) { + cases := []struct { + name string + isStandalone bool + gatewayExtend string + want bool + }{ + { + name: "独立卡+风险停机 -> 停止轮询", + isStandalone: true, + gatewayExtend: constants.GatewayCardExtendRiskStop, + want: true, + }, + { + name: "独立卡+已销户 -> 停止轮询", + isStandalone: true, + gatewayExtend: constants.GatewayCardExtendCancelled, + want: true, + }, + { + name: "非独立卡+风险停机 -> 不停止轮询", + isStandalone: false, + gatewayExtend: constants.GatewayCardExtendRiskStop, + want: false, + }, + { + name: "独立卡+机卡分离停机 -> 不停止轮询", + isStandalone: true, + gatewayExtend: constants.GatewayCardExtendMachineSeparated, + want: false, + }, + { + name: "独立卡+空扩展 -> 不停止轮询", + isStandalone: true, + gatewayExtend: "", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + card := &model.IotCard{ + IsStandalone: tc.isStandalone, + GatewayExtend: tc.gatewayExtend, + } + got := shouldStopPollingForRisk(card, tc.gatewayExtend) + if got != tc.want { + t.Errorf("shouldStopPollingForRisk(standalone=%v, extend=%q) = %v, 期望 %v", + tc.isStandalone, tc.gatewayExtend, got, tc.want) + } + }) + } +} diff --git a/migrations/000155_add_creator_name_to_import_tasks.down.sql b/migrations/000155_add_creator_name_to_import_tasks.down.sql new file mode 100644 index 0000000..7fe87ef --- /dev/null +++ b/migrations/000155_add_creator_name_to_import_tasks.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tb_iot_card_import_task DROP COLUMN IF EXISTS creator_name; +ALTER TABLE tb_device_import_task DROP COLUMN IF EXISTS creator_name; diff --git a/migrations/000155_add_creator_name_to_import_tasks.up.sql b/migrations/000155_add_creator_name_to_import_tasks.up.sql new file mode 100644 index 0000000..4b4135e --- /dev/null +++ b/migrations/000155_add_creator_name_to_import_tasks.up.sql @@ -0,0 +1,7 @@ +-- 为 IoT 卡导入任务表和设备导入任务表添加操作人快照字段 +-- 旧记录 creator_name 留空字符串,新任务创建时从登录用户快照姓名 +ALTER TABLE tb_iot_card_import_task + ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT ''; + +ALTER TABLE tb_device_import_task + ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT ''; diff --git a/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.down.sql b/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.down.sql new file mode 100644 index 0000000..1b3a4b2 --- /dev/null +++ b/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.down.sql @@ -0,0 +1,14 @@ +-- 回滚:jsonb 数组 → varchar(500),取第一个元素;空数组 → '' +ALTER TABLE tb_order + ALTER COLUMN payment_voucher_key TYPE varchar(500) + USING COALESCE(payment_voucher_key->>0, ''); + +ALTER TABLE tb_refund_request + ALTER COLUMN refund_voucher_key TYPE varchar(500) + USING COALESCE(refund_voucher_key->>0, ''); + +ALTER TABLE tb_agent_recharge_record + ALTER COLUMN payment_voucher_key TYPE varchar(500) + USING COALESCE(payment_voucher_key->>0, ''); + +ALTER TABLE tb_agent_recharge_record DROP COLUMN IF EXISTS remark; diff --git a/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.up.sql b/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.up.sql new file mode 100644 index 0000000..9dec64e --- /dev/null +++ b/migrations/000156_voucher_keys_to_jsonb_and_recharge_remark.up.sql @@ -0,0 +1,38 @@ +-- 将三个表的凭证字段从 varchar(500) 改为 jsonb 数组存储(最多5个元素) +-- 同时为代理预充值记录新增 remark 备注字段 +-- 注意:旧数据可能为逗号分隔多路径,用 string_to_array 拆分为数组元素 +-- USING 子句中必须显式 ::text 转换,避免 PostgreSQL 隐式将空字符串转 jsonb 报错 + +-- tb_order.payment_voucher_key: 空/NULL → [],非空 → 按逗号拆分为数组 +ALTER TABLE tb_order + ALTER COLUMN payment_voucher_key TYPE jsonb + USING CASE + WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb + ELSE to_jsonb(string_to_array(payment_voucher_key::text, ',')) + END; + +-- tb_refund_request.refund_voucher_key: 先删除旧 varchar DEFAULT 再转类型 +ALTER TABLE tb_refund_request + ALTER COLUMN refund_voucher_key DROP DEFAULT; + +ALTER TABLE tb_refund_request + ALTER COLUMN refund_voucher_key TYPE jsonb + USING CASE + WHEN refund_voucher_key IS NULL OR refund_voucher_key::text = '' THEN '[]'::jsonb + ELSE to_jsonb(string_to_array(refund_voucher_key::text, ',')) + END; + +ALTER TABLE tb_refund_request + ALTER COLUMN refund_voucher_key SET DEFAULT '[]'::jsonb; + +-- tb_agent_recharge_record.payment_voucher_key: 同 tb_order +ALTER TABLE tb_agent_recharge_record + ALTER COLUMN payment_voucher_key TYPE jsonb + USING CASE + WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb + ELSE to_jsonb(string_to_array(payment_voucher_key::text, ',')) + END; + +-- tb_agent_recharge_record 新增 remark 备注字段(创建后只读,NULL 表示未填写) +ALTER TABLE tb_agent_recharge_record + ADD COLUMN IF NOT EXISTS remark text; diff --git a/migrations/000157_create_order_package_invalidate_task.down.sql b/migrations/000157_create_order_package_invalidate_task.down.sql new file mode 100644 index 0000000..774a1fa --- /dev/null +++ b/migrations/000157_create_order_package_invalidate_task.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_order_package_invalidate_task; diff --git a/migrations/000157_create_order_package_invalidate_task.up.sql b/migrations/000157_create_order_package_invalidate_task.up.sql new file mode 100644 index 0000000..376f698 --- /dev/null +++ b/migrations/000157_create_order_package_invalidate_task.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS tb_order_package_invalidate_task ( + id bigserial PRIMARY KEY, + task_no varchar(50) NOT NULL UNIQUE, + status int NOT NULL DEFAULT 1, + total_count int NOT NULL DEFAULT 0, + success_count int NOT NULL DEFAULT 0, + fail_count int NOT NULL DEFAULT 0, + failed_items jsonb NOT NULL DEFAULT '[]', + file_name varchar(255) NOT NULL DEFAULT '', + storage_key varchar(500) NOT NULL DEFAULT '', + voucher_keys jsonb NOT NULL DEFAULT '[]', + remark text NOT NULL DEFAULT '', + creator_name varchar(100) NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + started_at timestamptz, + completed_at timestamptz, + creator bigint NOT NULL DEFAULT 0, + updater bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_status ON tb_order_package_invalidate_task (status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_created_at ON tb_order_package_invalidate_task (created_at DESC) WHERE deleted_at IS NULL; diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 0263bcb..024e496 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -67,6 +67,9 @@ const ( TaskTypePackageQueueActivation = "package:queue:activation" // 主套餐排队激活 TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置 + // 订单套餐失效任务类型 + TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐 + // 订单超时任务类型 TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包 @@ -194,6 +197,7 @@ const ( QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列 QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列 QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列 + QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列 QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列 QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列 QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列 @@ -244,6 +248,8 @@ func QueueForTaskType(taskType string) string { return QueuePackageQueueActivation case TaskTypePackageDataReset: return QueuePackageDataReset + case TaskTypeOrderPackageInvalidate: + return QueueOrderPackageInvalidate case TaskTypeOrderExpire: return QueueOrderExpire case TaskTypeAutoPurchaseAfterRecharge: @@ -268,6 +274,7 @@ func DefaultTaskQueueWeights() map[string]int { QueueAutoPurchase: 5, QueuePackageFirstActivation: 5, QueuePackageQueueActivation: 5, + QueueOrderPackageInvalidate: 5, QueueOrderExpire: 4, QueueEmailSend: 4, QueueExportDispatch: 4, diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 0671fab..69e7f69 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -60,9 +60,11 @@ const ( GatewayCardStatusReady = "准备" // 网关卡状态:准备 GatewayCardStatusNormal = "正常" // 网关卡状态:正常 GatewayCardStatusStopped = "停机" // 网关卡状态:停机 - GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活 - GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机 + GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活 + GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机 GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值 + GatewayCardExtendRiskStop = "风险停机" // 网关扩展状态:运营商风险停机(独立卡不允许复机) + GatewayCardExtendCancelled = "已销户" // 网关扩展状态:已销户(独立卡不允许复机) ) // IoT 卡停机原因 diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 7ccdb42..23ee028 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -64,6 +64,7 @@ func BuildDocHandlers() *bootstrap.Handlers { WechatConfig: admin.NewWechatConfigHandler(nil), AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), Refund: admin.NewRefundHandler(nil), + OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), AgentOpenAPI: openapiHandler.NewHandler(nil, nil), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 121f911..2844571 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -65,6 +65,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerIotCardImportHandler() h.registerDeviceImportHandler() + h.registerOrderPackageInvalidateHandler() h.registerExportHandlers() h.registerCommissionStatsHandlers() h.registerCommissionCalculationHandler() @@ -97,6 +98,18 @@ func (h *Handler) registerIotCardImportHandler() { h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport)) } +func (h *Handler) registerOrderPackageInvalidateHandler() { + orderPkgHandler := task.NewOrderPackageInvalidateHandler( + h.workerResult.Stores.OrderPackageInvalidateTask, + h.workerResult.Stores.Order, + h.workerResult.Stores.PackageUsage, + h.storage, + h.logger, + ) + h.mux.HandleFunc(constants.TaskTypeOrderPackageInvalidate, orderPkgHandler.Handle) + h.logger.Info("注册订单套餐批量失效任务处理器", zap.String("task_type", constants.TaskTypeOrderPackageInvalidate)) +} + func (h *Handler) registerDeviceImportHandler() { deviceImportHandler := task.NewDeviceImportHandler( h.db, diff --git a/pkg/queue/types.go b/pkg/queue/types.go index c4758ba..d34c0d2 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -46,8 +46,9 @@ type WorkerStores struct { AgentWalletTransaction *postgres.AgentWalletTransactionStore AssetWallet *postgres.AssetWalletStore AssetIdentifier *postgres.AssetIdentifierStore - PersonalCustomer *postgres.PersonalCustomerStore - PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + PersonalCustomer *postgres.PersonalCustomerStore + PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore } // WorkerServices Worker 侧所有 Service 的集合