Files
junhong_cmp_fiber/internal/service/refund/service.go
huang 8d2e8faa84
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m43s
修复退款bug
2026-04-23 11:30:46 +08:00

684 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
OrderNo: order.OrderNo,
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: "tb_refund_request.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, constants.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.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.PackageUsageID); err != nil {
fields := []zap.Field{
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("order_id", order.ID),
zap.Error(err),
}
if refund.PackageUsageID != nil {
fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID))
}
logger.Error("资产重置:退款套餐精准失效失败", fields...)
}
}
// 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,
OrderNo: r.OrderNo,
PackageUsageID: r.PackageUsageID,
ShopID: r.ShopID,
ShopName: r.ShopName,
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundReason: r.RefundReason,
Status: r.Status,
StatusName: constants.GetRefundStatusName(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
}