相关问题优化以及新功能开发
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s

迁移 155- 157
This commit is contained in:
2026-06-22 17:21:13 +09:00
parent 2885b503b3
commit 5c2ef97c24
45 changed files with 1201 additions and 82 deletions

View File

@@ -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"),

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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,
}
}

View File

@@ -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),

View File

@@ -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 "未知"
}
}

View File

@@ -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),