七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s

This commit is contained in:
2026-07-25 17:06:58 +08:00
parent ad9f613dd6
commit 73f5125d3d
249 changed files with 17137 additions and 877 deletions

View File

@@ -4,7 +4,9 @@ package account
import (
"context"
stdErrors "errors"
"fmt"
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -13,6 +15,7 @@ import (
"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/jackc/pgx/v5/pgconn"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
@@ -31,12 +34,18 @@ type Service struct {
shopStore ShopStoreInterface
enterpriseStore middleware.EnterpriseStoreInterface
auditService AuditServiceInterface
wecomMembers WeComMemberFinder
}
type AuditServiceInterface interface {
LogOperation(ctx context.Context, log *model.AccountOperationLog)
}
// WeComMemberFinder 定义账号绑定时校验应用可见成员的边界。
type WeComMemberFinder interface {
GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error)
}
// New 创建账号服务
func New(
accountStore *postgres.AccountStore,
@@ -58,6 +67,11 @@ func New(
}
}
// SetWeComMemberFinder 注入企业微信应用可见成员查询边界。
func (s *Service) SetWeComMemberFinder(finder WeComMemberFinder) {
s.wecomMembers = finder
}
// Create 创建账号
func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*model.Account, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
@@ -180,7 +194,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*m
}
// Get 获取账号
func (s *Service) Get(ctx context.Context, id uint) (*model.Account, error) {
func (s *Service) Get(ctx context.Context, id uint) (*dto.AccountResponse, error) {
account, err := s.accountStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -188,7 +202,66 @@ func (s *Service) Get(ctx context.Context, id uint) (*model.Account, error) {
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
}
return account, nil
accounts := []*model.Account{account}
return s.toAccountResponse(account, s.loadShopNames(ctx, accounts), s.loadEnterpriseNames(ctx, accounts)), nil
}
// BindWeCom 将系统账号绑定到管理员明确选择的企微应用可见成员。
func (s *Service) BindWeCom(ctx context.Context, accountID uint, request dto.BindAccountWeComRequest) (*dto.AccountResponse, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
operatorType := middleware.GetUserTypeFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if operatorType != constants.UserTypeSuperAdmin && operatorType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden)
}
if s.wecomMembers == nil || request.ApplicationID == 0 || strings.TrimSpace(request.UserID) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
account, err := s.accountStore.GetByID(ctx, accountID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待绑定账号失败")
}
member, err := s.wecomMembers.GetVisible(ctx, request.ApplicationID, request.UserID)
if err != nil {
return nil, err
}
beforeData := model.JSONB{
"wecom_corp_id": account.WeComCorpID, "wecom_userid": account.WeComUserID,
"wecom_name": account.WeComName,
}
if err := s.accountStore.BindWeCom(ctx, accountID, member.CorpID, member.UserID, member.Name, operatorID); err != nil {
var pgErr *pgconn.PgError
if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, errors.New(errors.CodeConflict, "该企业微信成员已绑定其他系统账号")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "绑定企业微信成员失败")
}
account.WeComCorpID = member.CorpID
account.WeComUserID = member.UserID
account.WeComName = member.Name
account.Updater = operatorID
if s.auditService != nil {
operatorName := ""
if operator, getErr := s.accountStore.GetByID(ctx, operatorID); getErr == nil {
operatorName = operator.Username
}
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
OperatorID: operatorID, OperatorType: operatorType, OperatorName: operatorName,
TargetAccountID: &account.ID, TargetUsername: &account.Username, TargetUserType: &account.UserType,
OperationType: "bind_wecom", OperationDesc: fmt.Sprintf("绑定账号企业微信成员: %s", account.Username),
BeforeData: beforeData, AfterData: model.JSONB{
"wecom_corp_id": member.CorpID, "wecom_userid": member.UserID, "wecom_name": member.Name,
}, RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx),
UserAgent: middleware.GetUserAgentFromContext(ctx),
})
}
accounts := []*model.Account{account}
return s.toAccountResponse(account, s.loadShopNames(ctx, accounts), s.loadEnterpriseNames(ctx, accounts)), nil
}
// Update 更新账号
@@ -796,6 +869,10 @@ func (s *Service) toAccountResponse(acc *model.Account, shopMap map[uint]string,
UserType: acc.UserType,
ShopID: acc.ShopID,
EnterpriseID: acc.EnterpriseID,
WeComCorpID: acc.WeComCorpID,
WeComUserID: acc.WeComUserID,
WeComName: acc.WeComName,
WeComBound: acc.WeComCorpID != "" && acc.WeComUserID != "",
Status: acc.Status,
StatusName: constants.GetStatusName(acc.Status),
Creator: acc.Creator,

View File

@@ -13,10 +13,12 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -44,6 +46,7 @@ type Service struct {
agentRechargeStore *postgres.AgentRechargeStore
agentWalletStore *postgres.AgentWalletStore
agentWalletPosting *walletapp.PostingService
offlineCreation *agentrechargeapp.OfflineCreationService
shopStore *postgres.ShopStore
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
@@ -82,6 +85,11 @@ func (s *Service) SetAgentWalletPostingService(service *walletapp.PostingService
s.agentWalletPosting = service
}
// SetOfflineCreationService 注入员工线下代充值审批申请用例。
func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCreationService) {
s.offlineCreation = service
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
@@ -95,12 +103,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
}
// 线下充值仅平台可用
if req.PaymentMethod == "offline" && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
if req.PaymentMethod == constants.RechargeMethodOffline && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
// 线下充值必须上传支付凭证
if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 {
if req.PaymentMethod == constants.RechargeMethodOffline && len(req.PaymentVoucherKey) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
}
@@ -108,6 +116,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
return nil, errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
rechargeNo := s.generateRechargeNo()
if req.PaymentMethod == constants.RechargeMethodOffline {
return s.createOffline(ctx, req, userID, userType, rechargeNo)
}
// 查找目标店铺的主钱包
wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID)
if err != nil {
@@ -140,8 +153,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
paymentChannel = "offline"
}
rechargeNo := s.generateRechargeNo()
record := &model.AgentRechargeRecord{
UserID: userID,
AgentWalletID: wallet.ID,
@@ -170,12 +181,55 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
zap.Uint("user_id", userID),
)
return toResponse(record, shop.ShopName), nil
resp := toResponse(record, shop.ShopName)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID)
return resp, nil
}
func (s *Service) createOffline(
ctx context.Context,
req *dto.CreateAgentRechargeRequest,
userID uint,
userType int,
rechargeNo string,
) (*dto.AgentRechargeResponse, error) {
if s.offlineCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{
SubmitterAccountID: userID,
SubmitterUserType: userType,
ShopID: req.ShopID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentVoucherKeys: req.PaymentVoucherKey,
Remark: req.Remark,
})
if err != nil {
return nil, err
}
resp := toResponse(result.Record, result.ShopName)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
s.logger.Info("创建员工线下代充值审批申请成功",
zap.Uint("recharge_id", result.Record.ID),
zap.String("recharge_no", result.Record.RechargeNo),
zap.Uint("approval_instance_id", *result.Record.ApprovalInstanceID),
zap.Int64("amount", result.Record.Amount),
zap.Uint("shop_id", result.Record.ShopID),
zap.Uint("submitter_id", result.Record.UserID),
)
return resp, nil
}
// OfflinePay 线下充值确认
// POST /api/admin/agent-recharges/:id/offline-pay
func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOfflinePayRequest) (*dto.AgentRechargeResponse, error) {
if !legacyOfflineRechargePayEnabled() {
return nil, errors.New(errors.CodeInvalidStatus, "线下充值人工确认入口已停用,请查看企业微信审批状态")
}
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
@@ -197,9 +251,12 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
if record.PaymentMethod != "offline" {
if record.PaymentMethod != constants.RechargeMethodOffline {
return nil, errors.New(errors.CodeInvalidParam, "该订单非线下充值,不支持此操作")
}
if record.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工确认")
}
if record.Status != constants.RechargeStatusPending {
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
}
@@ -266,7 +323,19 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
record.PaidAt = &now
record.CompletedAt = &now
return toResponse(record, shopName), nil
resp := toResponse(record, shopName)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID)
approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.AgentRechargeRecord{record})
if err != nil {
return nil, err
}
applyApprovalSummary(resp, approvalSummaries, record.ApprovalInstanceID)
return resp, nil
}
func legacyOfflineRechargePayEnabled() bool {
cfg := config.Get()
return cfg == nil || cfg.Approval.LegacyOfflineRechargePayEnabled
}
// HandlePaymentCallback 处理支付回调
@@ -388,6 +457,9 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
if record.Status != constants.RechargeStatusPending {
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
}
if record.ApprovalInstanceID != nil {
return errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工驳回")
}
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
if err == gorm.ErrRecordNotFound {
@@ -420,7 +492,14 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeRespo
shopName = shop.ShopName
}
return toResponse(record, shopName), nil
resp := toResponse(record, shopName)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID)
approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.AgentRechargeRecord{record})
if err != nil {
return nil, err
}
applyApprovalSummary(resp, approvalSummaries, record.ApprovalInstanceID)
return resp, nil
}
// List 分页查询充值订单列表
@@ -475,10 +554,27 @@ func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) (
}
}
}
submitterIDs := make([]uint, 0, len(records))
for _, record := range records {
if record.UserID > 0 {
submitterIDs = append(submitterIDs, record.UserID)
}
}
submitterNames, err := s.loadSubmitterNames(ctx, submitterIDs)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值提交人失败")
}
approvalSummaries, err := s.loadApprovalSummaries(ctx, records)
if err != nil {
return nil, 0, err
}
list := make([]*dto.AgentRechargeResponse, 0, len(records))
for _, r := range records {
list = append(list, toResponse(r, shopMap[r.ShopID]))
item := toResponse(r, shopMap[r.ShopID])
item.SubmitterName = submitterNames[r.UserID]
applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID)
list = append(list, item)
}
return list, total, nil
@@ -495,20 +591,22 @@ func (s *Service) generateRechargeNo() string {
// toResponse 将模型转换为响应 DTO
func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse {
resp := &dto.AgentRechargeResponse{
ID: record.ID,
RechargeNo: record.RechargeNo,
ShopID: record.ShopID,
ShopName: shopName,
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
PaymentVoucherKey: []string(record.PaymentVoucherKey),
Remark: record.Remark,
Status: record.Status,
StatusName: constants.GetRechargeStatusName(record.Status),
RejectionReason: record.RejectionReason,
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
ID: record.ID,
RechargeNo: record.RechargeNo,
ShopID: record.ShopID,
ShopName: shopName,
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
PaymentVoucherKey: []string(record.PaymentVoucherKey),
Remark: record.Remark,
Status: record.Status,
StatusName: constants.GetRechargeStatusName(record.Status),
RejectionReason: record.RejectionReason,
SubmitterID: record.UserID,
ApprovalInstanceID: record.ApprovalInstanceID,
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if record.PaymentChannel != nil {
@@ -531,3 +629,74 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
return resp
}
type approvalSummary struct {
Provider string
Status int
}
func (s *Service) loadApprovalSummaries(
ctx context.Context,
records []*model.AgentRechargeRecord,
) (map[uint]approvalSummary, error) {
ids := make([]uint, 0, len(records))
seen := make(map[uint]struct{}, len(records))
for _, record := range records {
if record == nil || record.ApprovalInstanceID == nil || *record.ApprovalInstanceID == 0 {
continue
}
id := *record.ApprovalInstanceID
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
summaries := make(map[uint]approvalSummary, len(ids))
if len(ids) == 0 {
return summaries, nil
}
var instances []model.ApprovalInstance
if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询充值审批状态失败")
}
for _, instance := range instances {
summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status}
}
return summaries, nil
}
func applyApprovalSummary(response *dto.AgentRechargeResponse, summaries map[uint]approvalSummary, instanceID *uint) {
if response == nil || instanceID == nil {
return
}
summary, exists := summaries[*instanceID]
if !exists {
return
}
status := summary.Status
response.ApprovalProvider = summary.Provider
response.ApprovalStatus = &status
response.ApprovalStatusName = constants.GetApprovalStatusName(status)
}
func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) {
accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids)
if err != nil {
return nil, err
}
names := make(map[uint]string, len(accounts))
for _, account := range accounts {
names[account.ID] = account.Username
}
return names, nil
}
func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string {
names, err := s.loadSubmitterNames(ctx, []uint{id})
if err != nil {
s.logger.Warn("查询充值提交人失败", zap.Uint("submitter_id", id), zap.Error(err))
return ""
}
return names[id]
}

View File

@@ -300,9 +300,11 @@ func getOperationFieldDesc(operationType string) map[string]string {
"skip_count": "跳过数量",
"failed_items": "失败明细含ICCID与原因",
})
case "device_speed_limit":
case "card_speed_tier":
return map[string]string{
"speed_limit": "限速值KB/s",
"tier_code": "固定限速档位编码",
"tier_name": "固定限速档位",
"integration_id": "Gateway 外部交互记录ID",
}
case "device_set_wifi":
return map[string]string{

View File

@@ -0,0 +1,163 @@
// Package asset_package_batch_order 提供单列 CSV 资产套餐批量订购任务能力。
package asset_package_batch_order
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/hibiken/asynq"
"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/asynctask"
"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.AssetPackageBatchOrderTaskStore
packageStore *postgres.PackageStore
queueClient *queue.Client
}
// New 创建资产套餐批量订购任务服务。
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client) *Service {
return &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
}
// TaskPayload 批量订购 Worker 结构化载荷。
type TaskPayload struct {
TaskID uint `json:"task_id"`
}
// Create 创建批量订购任务。
func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOrderRequest) (*dto.AssetPackageBatchOrderTaskResponse, error) {
if !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") {
return nil, errors.New(errors.CodeInvalidParam, "批量订购文件必须为CSV格式")
}
if !strings.HasPrefix(req.FileKey, constants.AssetPackageBatchOrderStoragePrefix+"/") {
return nil, errors.New(errors.CodeInvalidParam, "批量订购文件Key不属于指定上传目录")
}
if err := validatePaymentParameters(req); err != nil {
return nil, err
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
pkg, err := s.packageStore.GetByID(ctx, req.PackageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐失败")
}
task := &model.AssetPackageBatchOrderTask{
TaskNo: s.taskStore.GenerateTaskNo(), PackageID: pkg.ID,
PackageCode: pkg.PackageCode, PackageName: pkg.PackageName,
PaymentMethod: req.PaymentMethod, FileName: filepath.Base(req.FileKey), StorageKey: req.FileKey,
VoucherKeys: model.StringJSONBArray(req.VoucherKeys), Status: asynctask.StatusPending,
ResultItems: model.AssetPackageBatchOrderResultItems{}, Creator: userID, Updater: userID,
CreatorUserType: middleware.GetUserTypeFromContext(ctx), CreatorShopID: middleware.GetShopIDFromContext(ctx),
CreatorName: middleware.GetUsernameFromContext(ctx),
}
if err := s.taskStore.Create(ctx, task); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建批量订购任务失败")
}
var enqueueErr error
if s.queueClient == nil {
enqueueErr = errors.New(errors.CodeTaskQueueError, "批量订购任务队列未配置")
} else {
enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypeAssetPackageBatchOrder, TaskPayload{TaskID: task.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetPackageBatchOrder)),
asynq.Timeout(constants.AssetPackageBatchOrderTaskTimeout))
}
if enqueueErr != nil {
message := "批量订购任务入队失败"
_ = s.taskStore.MarkFailed(ctx, task.ID, message)
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
now := time.Now()
task.CompletedAt = &now
}
return toResponse(task), nil
}
// List 分页查询批量订购任务。
func (s *Service) List(ctx context.Context, req *dto.ListAssetPackageBatchOrderRequest) (*dto.AssetPackageBatchOrderTaskListResponse, error) {
if req.Page <= 0 {
req.Page = constants.DefaultPage
}
if req.PageSize <= 0 {
req.PageSize = constants.DefaultPageSize
}
tasks, total, err := s.taskStore.List(ctx, &store.QueryOptions{Page: req.Page, PageSize: req.PageSize}, req.Status)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询批量订购任务失败")
}
items := make([]*dto.AssetPackageBatchOrderTaskResponse, 0, len(tasks))
for _, task := range tasks {
items = append(items, toResponse(task))
}
return &dto.AssetPackageBatchOrderTaskListResponse{Total: total, Page: req.Page, PageSize: req.PageSize, Items: items}, nil
}
// GetByID 查询批量订购任务及逐行结果。
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AssetPackageBatchOrderTaskDetailResponse, error) {
task, err := s.taskStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "批量订购任务不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询批量订购任务失败")
}
items := make([]dto.AssetPackageBatchOrderResultItemResponse, 0, len(task.ResultItems))
for _, item := range task.ResultItems {
items = append(items, dto.AssetPackageBatchOrderResultItemResponse{
Line: item.Line, AssetIdentifier: item.AssetIdentifier, Status: item.Status,
StatusName: constants.GetAssetPackageBatchOrderItemStatusName(item.Status),
OrderID: item.OrderID, OrderNo: item.OrderNo, Amount: item.Amount, Reason: item.Reason,
})
}
return &dto.AssetPackageBatchOrderTaskDetailResponse{AssetPackageBatchOrderTaskResponse: *toResponse(task), Items: items}, nil
}
func validatePaymentParameters(req *dto.CreateAssetPackageBatchOrderRequest) error {
if req.PaymentMethod == model.PaymentMethodWallet && len(req.VoucherKeys) > 0 {
return errors.New(errors.CodeInvalidParam, "钱包支付不能提交线下凭证")
}
if req.PaymentMethod == model.PaymentMethodOffline && len(req.VoucherKeys) == 0 {
return errors.New(errors.CodeInvalidParam, "线下支付必须提交至少一个凭证")
}
return nil
}
func toResponse(task *model.AssetPackageBatchOrderTask) *dto.AssetPackageBatchOrderTaskResponse {
response := &dto.AssetPackageBatchOrderTaskResponse{
ID: task.ID, TaskNo: task.TaskNo, PackageID: task.PackageID, PackageCode: task.PackageCode,
PackageName: task.PackageName, PaymentMethod: task.PaymentMethod, FileName: task.FileName,
VoucherKeys: []string(task.VoucherKeys), Status: task.Status, StatusName: asynctask.StatusName(task.Status),
TotalCount: task.TotalCount, SuccessCount: task.SuccessCount, FailCount: task.FailCount,
ErrorMessage: task.ErrorMessage, CreatorName: task.CreatorName,
CreatedAt: task.CreatedAt.Format(time.RFC3339), UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
}
if response.VoucherKeys == nil {
response.VoucherKeys = []string{}
}
if task.StartedAt != nil {
value := task.StartedAt.Format(time.RFC3339)
response.StartedAt = &value
}
if task.CompletedAt != nil {
value := task.CompletedAt.Format(time.RFC3339)
response.CompletedAt = &value
}
return response
}

View File

@@ -102,7 +102,7 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest,
return nil, err
}
assetType, assetID, err := s.resolveAsset(ctx, req.Identifier)
assetType, assetID, shopID, err := s.resolveAsset(ctx, req.Identifier)
if err != nil {
return nil, err
}
@@ -116,6 +116,9 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest,
return nil, errors.New(errors.CodeForbidden, "该卡绑定了设备,请使用设备的虚拟号/设备号/IMEI/SN登录")
}
}
if err := s.ensureShopClientLoginAllowed(ctx, shopID); err != nil {
return nil, err
}
assetToken, err := s.signAssetToken(assetType, assetID)
if err != nil {
@@ -407,26 +410,45 @@ func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string
return nil
}
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, error) {
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, *uint, error) {
var card model.IotCard
if err := s.db.WithContext(ctx).
Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier).
First(&card).Error; err == nil {
return assetTypeIotCard, card.ID, nil
return assetTypeIotCard, card.ID, card.ShopID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
var device model.Device
if err := s.db.WithContext(ctx).
Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
First(&device).Error; err == nil {
return assetTypeDevice, device.ID, nil
return assetTypeDevice, device.ID, device.ShopID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
return "", 0, errors.New(errors.CodeAssetNotFound)
return "", 0, nil, errors.New(errors.CodeAssetNotFound)
}
// ensureShopClientLoginAllowed 在签发资产令牌前检查店铺的新登录限制。
func (s *Service) ensureShopClientLoginAllowed(ctx context.Context, shopID *uint) error {
if shopID == nil {
return nil
}
var shop model.Shop
err := s.db.WithContext(ctx).Select("client_login_disabled").First(&shop, *shopID).Error
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "资产所属店铺不可用")
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺C端登录限制失败")
}
if shop.ClientLoginDisabled {
return errors.New(errors.CodeForbidden, "该店铺暂不允许C端登录")
}
return nil
}
func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
@@ -681,7 +703,7 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp
// 根据资产标识符查找或创建测试客户并直接签发 JWT无需微信 OAuth
// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
assetType, assetID, err := s.resolveAsset(ctx, identifier)
assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
if err != nil {
return "", 0, false, err
}

View File

@@ -42,6 +42,12 @@ type OrderWalletPayServiceInterface interface {
WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error
}
// PaymentMethodPolicy 提供按资产类型校验支付方式的能力。
type PaymentMethodPolicy interface {
AllowedMethods(ctx context.Context, assetType string) ([]string, error)
EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error
}
// ForceRechargeRequirement 强充要求。
type ForceRechargeRequirement struct {
NeedForceRecharge bool
@@ -69,6 +75,12 @@ type Service struct {
db *gorm.DB
redis *redis.Client
logger *zap.Logger
paymentMethodPolicy PaymentMethodPolicy
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (s *Service) SetPaymentMethodPolicy(policy PaymentMethodPolicy) {
s.paymentMethodPolicy = policy
}
// New 创建客户端订单服务。
@@ -132,12 +144,20 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
if err != nil {
return nil, err
}
if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
} else if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
if s.paymentMethodPolicy == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
if req.PaymentMethod == "" {
return nil, errors.New(errors.CodeInvalidParam, "请选择支付方式")
}
if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, assetInfo.AssetType, req.PaymentMethod); err != nil {
return nil, err
}
validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs)
if err != nil {
@@ -152,7 +172,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
// 先判断是否需要强充,避免普通下单时不必要地校验微信配置
forceRecharge := s.checkForceRechargeRequirement(skipCtx, validationResult)
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs)
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs, req.PaymentMethod)
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
@@ -229,6 +249,15 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
}()
if forceRecharge.NeedForceRecharge {
if s.paymentMethodPolicy == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, assetInfo.AssetType, req.PaymentMethod); err != nil {
return nil, err
}
if req.PaymentMethod == model.PaymentMethodWallet {
return nil, errors.New(errors.CodePaymentMethodUnavailable, "该套餐需要先充值,请选择当前资产允许的微信或支付宝方式")
}
// 强充第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)
// switch assetInfo.AssetType {
// case "card":
@@ -268,7 +297,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
}
return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &created)
return s.createPackageOrder(skipCtx, customerID, validationResult, req.PaymentMethod, redisKey, &created)
}
// getOrderAssetInfo 从订单中提取资产类型和 ID无需额外 DB 查询
@@ -295,9 +324,9 @@ func getOrderAssetInfo(order *model.Order) (string, uint, error) {
func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) {
switch assetInfo.AssetType {
case "card":
return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs)
return s.purchaseValidationService.ValidatePersonalCardPurchase(ctx, assetInfo.AssetID, packageIDs)
case constants.ResourceTypeDevice:
return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs)
return s.purchaseValidationService.ValidatePersonalDevicePurchase(ctx, assetInfo.AssetID, packageIDs)
default:
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -350,6 +379,7 @@ func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
validationResult *purchase_validation.PurchaseValidationResult,
paymentMethod string,
redisKey string,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
@@ -365,7 +395,7 @@ func (s *Service) createPackageOrder(
sellerCostPrice = costPrice
}
order, err := s.buildPendingOrder(ctx, customerID, validationResult, sellerCostPrice)
order, err := s.buildPendingOrder(ctx, customerID, validationResult, sellerCostPrice, paymentMethod)
if err != nil {
return nil, err
}
@@ -388,6 +418,7 @@ func (s *Service) createPackageOrder(
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentMethod: order.PaymentMethod,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
@@ -538,7 +569,7 @@ func (s *Service) buildPersonalCustomerOperatorSnapshot(ctx context.Context, cus
return &id, model.OperatorAccountTypePersonalCustomer, name
}
func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64) (*model.Order, error) {
func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64, paymentMethod string) (*model.Order, error) {
orderType := resolveOrderType(result)
if orderType == "" {
return nil, errors.New(errors.CodeInvalidParam)
@@ -556,10 +587,12 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result
BuyerType: model.BuyerTypePersonal,
BuyerID: customerID,
TotalAmount: result.TotalPrice,
PaymentMethod: paymentMethod,
PaymentStatus: model.PaymentStatusPending,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
PurchaseRole: model.PurchaseRoleSelfPurchase,
Generation: resolveGeneration(result),
ExpiresAt: &expiresAt,
SellerCostPrice: sellerCostPrice,
@@ -569,10 +602,12 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result
order.IotCardID = &result.Card.ID
order.SeriesID = result.Card.SeriesID
order.SellerShopID = result.Card.ShopID
order.AssetIdentifier = result.Card.ICCID
} else if result.Device != nil {
order.DeviceID = &result.Device.ID
order.SeriesID = result.Device.SeriesID
order.SellerShopID = result.Device.ShopID
order.AssetIdentifier = firstNonEmptyClientOrderIdentifier(result.Device.VirtualNo, result.Device.IMEI)
}
order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, customerID)
@@ -581,6 +616,16 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result
return order, nil
}
// firstNonEmptyClientOrderIdentifier 返回 C 端订单可用的第一个资产标识快照。
func firstNonEmptyClientOrderIdentifier(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) {
sellerShopID := resolveSellerShopID(result)
items := make([]*model.OrderItem, 0, len(result.Packages))
@@ -617,13 +662,20 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur
var seriesID *uint
var sellerShopID uint
var firstCommissionTriggered bool
if result.Card != nil {
seriesID = result.Card.SeriesID
if seriesID != nil {
firstCommissionTriggered = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Card.ShopID != nil {
sellerShopID = *result.Card.ShopID
}
} else if result.Device != nil {
seriesID = result.Device.SeriesID
if seriesID != nil {
firstCommissionTriggered = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Device.ShopID != nil {
sellerShopID = *result.Device.ShopID
}
@@ -643,6 +695,9 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur
if err != nil || config == nil || !config.Enable {
return defaultResult
}
if firstCommissionTriggered {
return defaultResult
}
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
return &ForceRechargeRequirement{
@@ -796,7 +851,7 @@ func extractPackageIDs(packages []*model.Package) []uint {
return ids
}
func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, packageIDs []uint) string {
func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, packageIDs []uint, paymentMethod string) string {
sorted := make([]uint, len(packageIDs))
copy(sorted, packageIDs)
slices.Sort(sorted)
@@ -806,7 +861,7 @@ func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolve
parts = append(parts, strconv.FormatUint(uint64(id), 10))
}
return fmt.Sprintf("%d:%s:%d:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ","))
return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ","), paymentMethod)
}
func rechargeStatusToClientStatus(status int) int {
@@ -1090,7 +1145,7 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie
query := s.db.WithContext(skipCtx).
Model(&model.Order{}).
Where("generation = ?", generation)
Where("generation = ? AND buyer_type = ? AND buyer_id = ?", generation, model.BuyerTypePersonal, customerID)
if assetInfo.AssetType == constants.ResourceTypeDevice {
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, assetInfo.AssetID)
@@ -1132,19 +1187,29 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie
}
packageNames := make([]string, 0)
packageIDs := make([]uint, 0)
for _, item := range itemMap[order.ID] {
if item != nil && item.PackageName != "" {
packageNames = append(packageNames, item.PackageName)
if item != nil {
packageIDs = append(packageIDs, item.PackageID)
if item.PackageName != "" {
packageNames = append(packageNames, item.PackageName)
}
}
}
assetType, assetID := clientOrderAssetReference(order)
list = append(list, dto.ClientOrderListItem{
OrderID: order.ID,
OrderNo: order.OrderNo,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: order.AssetIdentifier,
TotalAmount: order.TotalAmount,
PaymentMethod: order.PaymentMethod,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
PackageIDs: packageIDs,
PackageNames: packageNames,
})
}
@@ -1161,6 +1226,9 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
}
if order.BuyerType != model.BuyerTypePersonal || order.BuyerID != customerID {
return nil, errors.New(errors.CodeForbidden, "无权查看此订单")
}
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
orderAssetType, orderAssetID, err := getOrderAssetInfo(order)
@@ -1185,10 +1253,14 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u
Quantity: item.Quantity,
})
}
assetType, assetID := clientOrderAssetReference(order)
return &dto.ClientOrderDetailResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: order.AssetIdentifier,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
@@ -1200,6 +1272,19 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u
}, nil
}
func clientOrderAssetReference(order *model.Order) (string, uint) {
if order == nil {
return "", 0
}
if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
return constants.ResourceTypeDevice, *order.DeviceID
}
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
return "card", *order.IotCardID
}
return "", 0
}
// PayOrder D4 对待支付的 C 端订单发起支付。
// 支持 wallet钱包扣款和 wechat微信 JSAPI两种支付方式。
// 先校验订单归属和状态,再按 payment_method 路由到对应支付逻辑:
@@ -1228,6 +1313,19 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
paymentMethod := order.PaymentMethod
if paymentMethod == "" {
return nil, errors.New(errors.CodePaymentMethodUnavailable, "历史订单缺少支付方式,请重新下单")
}
if req.PaymentMethod != "" && req.PaymentMethod != paymentMethod {
return nil, errors.New(errors.CodeConflict, "支付方式与订单创建时选择不一致")
}
if s.paymentMethodPolicy == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, order.OrderType, paymentMethod); err != nil {
return nil, err
}
// before_order 策略:必须先实名才能支付订单
var assetRealnamePolicy string
@@ -1267,12 +1365,12 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
// }
// }
switch req.PaymentMethod {
switch paymentMethod {
case model.PaymentMethodWallet:
if err := s.orderPaymentService.WalletPay(skipCtx, orderID, model.BuyerTypePersonal, customerID); err != nil {
return nil, err
}
return &dto.ClientPayOrderResponse{PaymentMethod: model.PaymentMethodWallet}, nil
return &dto.ClientPayOrderResponse{PaymentMethod: paymentMethod}, nil
case model.PaymentMethodWechat:
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
@@ -1326,7 +1424,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
return nil, err
}
return &dto.ClientPayOrderResponse{
PaymentMethod: model.PaymentMethodWechat,
PaymentMethod: paymentMethod,
PayConfig: buildClientPayConfigFromResult(paymentResult),
}, nil
@@ -1356,7 +1454,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
)
}
return &dto.ClientPayOrderResponse{
PaymentMethod: model.PaymentMethodAlipay,
PaymentMethod: paymentMethod,
PaymentLink: paymentLink,
}, nil

View File

@@ -4,12 +4,14 @@ package customer_binding
import (
"context"
"sort"
"time"
"gorm.io/gorm"
"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/errors"
)
@@ -144,6 +146,72 @@ func (s *Service) OwnsAsset(ctx context.Context, customerID uint, assetType stri
return false, errors.New(errors.CodeInvalidParam)
}
// ActiveCustomerIDsByAsset 返回当前启用且关联指定资产的个人客户 ID结果去重并稳定排序。
func (s *Service) ActiveCustomerIDsByAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) ([]uint, error) {
if tx == nil {
tx = s.db
}
customerIDs := make(map[uint]struct{})
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, assetID)
if err != nil {
return nil, err
}
if card.VirtualNo != "" {
records, err := s.makePCD(tx).GetByDeviceNo(ctx, card.VirtualNo)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败")
}
collectActiveDeviceCustomerIDs(customerIDs, records)
} else {
records, err := s.makePCI(tx).GetByICCID(ctx, card.ICCID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败")
}
collectActiveCardCustomerIDs(customerIDs, records)
}
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
if err != nil {
return nil, err
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
records, err := s.makePCD(tx).GetByDeviceNo(ctx, identifier)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备关联客户失败")
}
collectActiveDeviceCustomerIDs(customerIDs, records)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
result := make([]uint, 0, len(customerIDs))
for customerID := range customerIDs {
result = append(result, customerID)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result, nil
}
func collectActiveDeviceCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerDevice) {
for _, record := range records {
if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 {
target[record.CustomerID] = struct{}{}
}
}
}
func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerICCID) {
for _, record := range records {
if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 {
target[record.CustomerID] = struct{}{}
}
}
}
// Bind 在事务中创建客户与资产的绑定关系
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccidIssue 02

View File

@@ -47,64 +47,6 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g
})
}
// GatewaySetSpeedLimit 通过标识符设置设备限速
func (s *Service) GatewaySetSpeedLimit(ctx context.Context, identifier string, req *dto.SetSpeedLimitRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSpeedLimit,
"设备限速失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"speed_limit": req.SpeedLimit,
},
0,
0,
0,
err,
)
return err
}
if err = s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{
DeviceID: imei,
SpeedLimit: req.SpeedLimit,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSpeedLimit,
"设备限速失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"speed_limit": req.SpeedLimit},
0,
0,
0,
err,
)
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSpeedLimit,
"设备限速",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"speed_limit": req.SpeedLimit},
0,
0,
0,
nil,
)
return nil
}
// GatewaySetWiFi 通过标识符设置设备 WiFi
func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)

View File

@@ -0,0 +1,84 @@
package device
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BatchUpdateRealnamePolicy 批量更新设备实名认证策略,整批校验后在单事务内全成全败。
func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchUpdateAssetRealnamePolicyRequest) (*dto.BatchUpdateAssetRealnamePolicyResponse, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "企业账号无权修改设备实名认证策略")
}
ids, err := validateBatchRealnamePolicyRequest(req)
if err != nil {
return nil, err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var devices []model.Device
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.Device{})).Clauses(clause.Locking{Strength: "UPDATE"})
if err := query.Where("id IN ?", ids).Find(&devices).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量设备资产失败")
}
if len(devices) != len(ids) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result := tx.Model(&model.Device{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
}
return nil
})
if err != nil {
return nil, err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpAssetRealnamePolicy,
"批量更新设备实名认证策略",
constants.AssetAuditResultSuccess,
nil,
nil,
map[string]any{"asset_ids": ids, "realname_policy": req.RealnamePolicy},
len(ids),
len(ids),
0,
nil,
)
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
}
func validateBatchRealnamePolicyRequest(req *dto.BatchUpdateAssetRealnamePolicyRequest) ([]uint, error) {
if req == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > 500 || !isValidRealnamePolicy(req.RealnamePolicy) {
return nil, errors.New(errors.CodeInvalidParam)
}
seen := make(map[uint]struct{}, len(req.AssetIDs))
ids := make([]uint, 0, len(req.AssetIDs))
for _, id := range req.AssetIDs {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, exists := seen[id]; exists {
return nil, errors.New(errors.CodeInvalidParam, "资产ID不能重复")
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func isValidRealnamePolicy(policy string) bool {
return policy == constants.RealnamePolicyNone ||
policy == constants.RealnamePolicyBeforeOrder ||
policy == constants.RealnamePolicyAfterOrder
}

View File

@@ -206,6 +206,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if req.ActivationStatus != nil {
filters["activation_status"] = *req.ActivationStatus
}
if req.RealNameStatus != nil {
filters["real_name_status"] = *req.RealNameStatus
}
shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs)
if hasShopIDs {
filters["shop_ids"] = shopIDs

View File

@@ -55,3 +55,19 @@ func newDeviceImportAuditParams(
AfterData: afterData,
}
}
func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.CreateDeviceBatchAllocationRequest, resultStatus string, err error) assetAuditSvc.BuildLogParams {
afterData := map[string]any{}
if req != nil {
afterData["file_key"] = req.FileKey
afterData["operation_type"] = req.OperationType
afterData["target_id"] = req.TargetID
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID, AssetIdentifier: taskNo,
OperationType: constants.AssetAuditOpDeviceBatchTaskCreate,
OperationDesc: "创建设备CSV批量分配任务", ResultStatus: resultStatus,
ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData,
}
}

View File

@@ -3,6 +3,7 @@ package device_import
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -55,6 +56,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
task := &model.DeviceImportTask{
TaskNo: taskNo,
OperationType: constants.DeviceImportOperationCreate,
Status: model.ImportTaskStatusPending,
BatchNo: req.BatchNo,
FileName: fileName,
@@ -94,6 +96,58 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
}, nil
}
// CreateBatchAllocationTask 复用设备导入任务创建单列 CSV 批量分配任务。
func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.CreateDeviceBatchAllocationRequest) (*dto.CreateDeviceBatchAllocationResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备批量分配任务")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
}
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate || req.TargetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配参数不合法")
}
if !strings.HasPrefix(req.FileKey, constants.DeviceBatchAllocationStoragePrefix+"/") || !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") {
return nil, errors.New(errors.CodeInvalidParam, "设备批量分配文件必须是指定目录下的CSV文件")
}
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
var operatorShopID *uint
if userType == constants.UserTypeAgent {
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return nil, errors.New(errors.CodeForbidden, "代理账号缺少店铺归属")
}
operatorShopID = &shopID
}
targetID := req.TargetID
task := &model.DeviceImportTask{
TaskNo: taskNo, OperationType: req.OperationType, TargetID: &targetID,
OperatorType: userType, OperatorShopID: operatorShopID,
Status: model.ImportTaskStatusPending, StorageKey: req.FileKey, FileName: filepath.Base(req.FileKey),
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator, task.Updater = userID, userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备批量分配任务失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, DeviceImportPayload{TaskID: task.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil {
_ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "设备批量分配任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.CreateDeviceBatchAllocationResponse{
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备批量分配任务已创建Worker 将异步处理CSV文件",
}, nil
}
func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, error) {
page := req.Page
pageSize := req.PageSize
@@ -113,6 +167,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest
if req.Status != nil {
filters["status"] = *req.Status
}
if req.OperationType != "" {
filters["operation_type"] = req.OperationType
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
@@ -156,25 +213,19 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDe
for _, item := range task.SkippedItems {
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason,
})
}
for _, item := range task.FailedItems {
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason,
})
}
for _, item := range task.WarningItems {
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason,
})
}
@@ -193,7 +244,11 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport
return &dto.DeviceImportTaskResponse{
ID: task.ID,
TaskNo: task.TaskNo,
OperationType: task.OperationType,
OperationName: constants.GetDeviceImportOperationName(task.OperationType),
TargetID: task.TargetID,
Status: task.Status,
StatusName: getStatusText(task.Status),
StatusText: getStatusText(task.Status),
BatchNo: task.BatchNo,
RealnamePolicy: task.RealnamePolicy,

View File

@@ -5,6 +5,7 @@ import (
"strings"
"time"
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
@@ -20,6 +21,7 @@ import (
type Service struct {
db *gorm.DB
exchangeStore *postgres.ExchangeOrderStore
refundStore *postgres.RefundStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
@@ -28,6 +30,7 @@ type Service struct {
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
resourceTagStore *postgres.ResourceTagStore
customerBinding *customerBindingSvc.Service
shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier
logger *zap.Logger
}
@@ -47,6 +50,7 @@ func New(
return &Service{
db: db,
exchangeStore: exchangeStore,
refundStore: postgres.NewRefundStore(db),
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
@@ -59,6 +63,11 @@ func New(
}
}
// SetShippingCreatedNotifier 注入物流换货创建后的可靠通知用例。
func (s *Service) SetShippingCreatedNotifier(notifier *exchangeapp.ShippingCreatedNotifier) {
s.shippingCreatedNotifier = notifier
}
func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) {
flowType := normalizeExchangeFlowType(req.FlowType)
if !isValidExchangeFlowType(flowType) {
@@ -75,6 +84,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
if !isExchangeableAssetStatus(asset.AssetStatus) {
return nil, oldAssetStatusError(asset.AssetStatus)
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
}
if hasUnfinishedRefund {
return nil, errors.New(errors.CodeExchangeActiveRefund)
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
return nil, errors.New(errors.CodeExchangeInProgress)
@@ -105,11 +121,39 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
order.ShopID = asset.ShopID
}
if err = s.exchangeStore.Create(ctx, order); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败")
if s.shippingCreatedNotifier == nil {
return nil, errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if createErr := tx.WithContext(ctx).Create(order).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建换货单失败")
}
customerIDs, queryErr := s.customerBinding.ActiveCustomerIDsByAsset(ctx, tx, asset.AssetType, asset.AssetID)
if queryErr != nil {
return queryErr
}
for _, customerID := range customerIDs {
if notifyErr := s.shippingCreatedNotifier.Notify(ctx, tx, exchangeapp.ShippingCreatedEvent{
ExchangeID: order.ID, ExchangeNo: order.ExchangeNo, CustomerID: customerID,
AssetType: asset.AssetType, AssetID: asset.AssetID, AssetIdentifier: asset.Identifier,
RequestID: requestID, CorrelationID: requestID,
}); notifyErr != nil {
return notifyErr
}
}
return nil
})
if err != nil {
return nil, err
}
return s.toExchangeOrderResponse(order), nil
resp := s.toExchangeOrderResponse(order)
resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator)
return resp, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
@@ -120,7 +164,9 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse,
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败")
}
return s.toExchangeOrderResponse(order), nil
resp := s.toExchangeOrderResponse(order)
resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator)
return resp, nil
}
func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) {
@@ -1006,7 +1052,20 @@ func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.Excha
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
DeletedAt: deletedAt,
SubmitterID: order.Creator,
Creator: order.Creator,
Updater: order.Updater,
}
}
func (s *Service) loadExchangeSubmitterNameBestEffort(ctx context.Context, id uint) string {
accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, []uint{id})
if err != nil {
s.logger.Warn("查询换货提交人失败", zap.Uint("submitter_id", id), zap.Error(err))
return ""
}
if len(accounts) == 0 {
return ""
}
return accounts[0].Username
}

View File

@@ -0,0 +1,83 @@
package iot_card
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BatchUpdateRealnamePolicy 批量更新卡实名认证策略,整批校验后在单事务内全成全败。
func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchUpdateAssetRealnamePolicyRequest) (*dto.BatchUpdateAssetRealnamePolicyResponse, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "企业账号无权修改卡实名认证策略")
}
ids, err := validateBatchRealnamePolicyRequest(req)
if err != nil {
return nil, err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var cards []model.IotCard
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.IotCard{})).Clauses(clause.Locking{Strength: "UPDATE"})
if err := query.Where("id IN ?", ids).Find(&cards).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量卡资产失败")
}
if len(cards) != len(ids) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result := tx.Model(&model.IotCard{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败")
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试")
}
return nil
})
if err != nil {
return nil, err
}
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardRealnamePolicy,
OperationDesc: "批量更新卡实名认证策略",
ResultStatus: constants.AssetAuditResultSuccess,
BatchTotal: len(ids),
SuccessCount: len(ids),
AfterData: map[string]any{
"asset_ids": ids,
"realname_policy": req.RealnamePolicy,
},
})
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
}
func validateBatchRealnamePolicyRequest(req *dto.BatchUpdateAssetRealnamePolicyRequest) ([]uint, error) {
if req == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > 500 || !isValidRealnamePolicy(req.RealnamePolicy) {
return nil, errors.New(errors.CodeInvalidParam)
}
seen := make(map[uint]struct{}, len(req.AssetIDs))
ids := make([]uint, 0, len(req.AssetIDs))
for _, id := range req.AssetIDs {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, exists := seen[id]; exists {
return nil, errors.New(errors.CodeInvalidParam, "资产ID不能重复")
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func isValidRealnamePolicy(policy string) bool {
return policy == constants.RealnamePolicyNone ||
policy == constants.RealnamePolicyBeforeOrder ||
policy == constants.RealnamePolicyAfterOrder
}

View File

@@ -72,6 +72,7 @@ type Service struct {
cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
trafficLock *cardtrafficlock.Lock
speedTierIntegration speedTierIntegrationLog
}
// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。
@@ -84,6 +85,11 @@ func (s *Service) SetCardObservationService(service *cardapp.Service) {
s.cardObservation = service
}
// SetSpeedTierIntegrationLog 注入卡固定限速的 Integration Log 接缝。
func (s *Service) SetSpeedTierIntegrationLog(integration speedTierIntegrationLog) {
s.speedTierIntegration = integration
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
@@ -250,6 +256,9 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if req.NetworkStatus != nil {
filters["network_status"] = *req.NetworkStatus
}
if req.RealNameStatus != nil {
filters["real_name_status"] = *req.RealNameStatus
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}

View File

@@ -0,0 +1,158 @@
package iot_card
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
)
type speedTierIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
}
// SetSpeedTier 为有权限的 IoT 卡设置或恢复固定限速档位。
func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*dto.SetIotCardSpeedTierResponse, error) {
if !canManageCardSpeedTier(ctx) {
return nil, pkgerrors.New(pkgerrors.CodeForbidden, "仅平台和代理后台账号可设置卡限速档位")
}
if code == nil || !constants.IsGatewaySpeedTier(*code) {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "固定限速档位不合法")
}
if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil {
return nil, pkgerrors.New(pkgerrors.CodeServiceUnavailable, "卡限速服务未完整配置")
}
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil || card == nil || card.ICCID == "" {
return nil, pkgerrors.New(pkgerrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
tierName := constants.GetGatewaySpeedTierName(*code)
resourceID := strconv.FormatUint(uint64(card.ID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
attempt, err := s.speedTierIntegration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationGatewaySpeedTier,
ExternalID: &card.ICCID,
ResourceType: constants.AssetTypeIotCard,
ResourceID: &resourceID,
ResourceKey: &card.ICCID,
RequestID: requestID,
CorrelationID: requestID,
RequestSummary: map[string]any{
"iot_card_id": card.ID,
"iccid": card.ICCID,
"tier_code": *code,
"tier_name": tierName,
"operator_id": middleware.GetUserIDFromContext(ctx),
},
})
if err != nil {
s.logSpeedTierAudit(ctx, card, *code, "", constants.AssetAuditResultFailed, err)
return nil, err
}
startedAt := time.Now()
gatewayErr := s.gatewayClient.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{
CardNo: card.ICCID,
Code: strconv.Itoa(*code),
})
completion := speedTierCompletion(gatewayErr, time.Since(startedAt))
if _, completeErr := s.speedTierIntegration.Complete(ctx, attempt.IntegrationID, completion); completeErr != nil {
if s.logger != nil {
s.logger.Error("终结卡限速 Integration Log 失败",
zap.Uint("iot_card_id", card.ID),
zap.String("integration_id", attempt.IntegrationID),
zap.Error(completeErr),
)
}
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, completeErr)
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, completeErr, "终结卡限速外部交互记录失败")
}
if gatewayErr != nil {
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, gatewayErr)
if isGatewayTimeout(gatewayErr) {
return nil, pkgerrors.New(pkgerrors.CodeGatewayTimeout, "Gateway 卡限速请求结果未知,请核对实际档位后再操作")
}
return nil, gatewayErr
}
s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultSuccess, nil)
return &dto.SetIotCardSpeedTierResponse{
IotCardID: card.ID, ICCID: card.ICCID, Code: *code,
SpeedTierName: tierName, IntegrationID: attempt.IntegrationID,
}, nil
}
func canManageCardSpeedTier(ctx context.Context) bool {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
return middleware.GetUserIDFromContext(ctx) > 0
default:
return false
}
}
func speedTierCompletion(err error, duration time.Duration) integrationlog.Completion {
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess,
DurationMS: duration.Milliseconds(),
StateChanged: true,
ResponseSummary: map[string]any{
"result": "success",
},
}
if err == nil {
return completion
}
completion.Result = constants.IntegrationResultFailed
completion.StateChanged = false
completion.ProviderMessage = err.Error()
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(err) {
completion.Result = constants.IntegrationResultUnknown
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewaySpeedTierUnknownRecoveryStrategy
}
return completion
}
func isGatewayTimeout(err error) bool {
var appErr *pkgerrors.AppError
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == pkgerrors.CodeGatewayTimeout
}
func (s *Service) logSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID, result string, err error) {
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
OperationType: constants.AssetAuditOpCardSpeedTier,
OperationDesc: "设置 IoT 卡固定限速档位",
BeforeData: map[string]any{"card": cardSnapshot(card)},
AfterData: map[string]any{
"tier_code": code,
"tier_name": constants.GetGatewaySpeedTierName(code),
"integration_id": integrationID,
},
ResultStatus: result,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
})
}
var _ speedTierIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -1093,7 +1093,7 @@ func buildAgentWalletTransactionAssetSnapshot(order *model.Order) (string, uint,
}
// buildOrderAssetIdentifier 生成订单资产标识快照。
// 后台按标识下单时保留请求标识按ID下单时用卡ICCID或设备IMEI/虚拟号兜底。
// 后台按标识下单时保留请求标识按ID下单时用卡ICCID或设备虚拟号/IMEI兜底。
func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationResult) string {
if result == nil {
return ""
@@ -1102,7 +1102,7 @@ func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationRes
return result.Card.ICCID
}
if result.Device != nil {
return firstNonEmpty(result.Device.IMEI, result.Device.VirtualNo)
return firstNonEmpty(result.Device.VirtualNo, result.Device.IMEI)
}
return ""
}
@@ -2134,7 +2134,7 @@ func resolveAdminCarrierInfoFromVars(orderType string, iotCardID *uint, deviceID
return "", 0
}
// resolveAssetByIdentifier 通过标识符(ICCID 或 VirtualNo解析资产
// resolveAssetByIdentifier 通过ICCID 或设备 VirtualNo、IMEI、SN 解析资产
// 优先查注册表,注册表命中则直接返回对应卡或设备
func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, *model.Device, error) {
if s.assetIdentifierStore != nil {

View File

@@ -701,6 +701,9 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
if orderID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
validStatuses := []int{
constants.PackageUsageStatusPending,
@@ -708,21 +711,9 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
constants.PackageUsageStatusDepleted,
}
newAssetQuery := func() (*gorm.DB, error) {
query := s.db.WithContext(ctx).Model(&model.PackageUsage{})
switch assetType {
case "iot_card":
return query.Where("iot_card_id = ?", assetID), nil
case "device":
return query.Where("device_id = ?", assetID), nil
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
}
baseQuery, err := newAssetQuery()
if err != nil {
return err
}
// 换货会把套餐使用记录迁移到新资产,但原订单与套餐使用记录的关联保持不变。
// 因此退款必须以订单和套餐使用记录为权威定位键,不能再用旧资产 ID 缩小查询范围。
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
var targets []model.PackageUsage
if packageUsageID != nil && *packageUsageID > 0 {
@@ -772,11 +763,7 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
if len(mainUsageIDs) > 0 {
var addons []model.PackageUsage
addonQuery, queryErr := newAssetQuery()
if queryErr != nil {
return queryErr
}
if err := addonQuery.
if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("master_usage_id IN ?", mainUsageIDs).
Where("status IN ?", validStatuses).
Find(&addons).Error; err != nil {

View File

@@ -0,0 +1,134 @@
// Package paymentmethod 提供按资产类型读取和校验 C 端支付方式的能力。
package paymentmethod
import (
"context"
stderrors "errors"
"slices"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
)
// ConfigReader 提供受控系统配置读取能力。
type ConfigReader interface {
GetStrict(ctx context.Context, key string) (string, error)
}
// Policy 负责返回资产允许的支付方式并执行后端强校验。
type Policy struct {
reader ConfigReader
}
// NewPolicy 创建支付方式策略。
func NewPolicy(reader ConfigReader) *Policy {
return &Policy{reader: reader}
}
// AllowedMethods 返回指定资产类型允许的支付方式;配置异常时失败关闭。
func (p *Policy) AllowedMethods(ctx context.Context, assetType string) ([]string, error) {
if p == nil || p.reader == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
key, err := configKey(assetType)
if err != nil {
return nil, err
}
raw, err := p.reader.GetStrict(ctx, key)
if err != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "读取支付方式配置失败")
}
var methods []string
if err := sonic.Unmarshal([]byte(raw), &methods); err != nil || !validMethods(methods) {
return nil, errors.New(errors.CodeNoPaymentConfig, "支付方式配置无效")
}
return stableMethods(methods), nil
}
// EnsureAllowed 校验指定支付方式是否允许。
func (p *Policy) EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error {
methods, err := p.AllowedMethods(ctx, assetType)
if err != nil {
return err
}
if !slices.Contains(methods, paymentMethod) {
return errors.New(errors.CodePaymentMethodUnavailable)
}
return nil
}
// AllowedRechargeMethods 返回资产允许且钱包充值接口支持的第三方支付方式。
func (p *Policy) AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error) {
methods, err := p.AllowedMethods(ctx, assetType)
if err != nil {
return nil, err
}
result := make([]string, 0, 2)
for _, method := range methods {
if method == model.PaymentMethodWechat || method == model.PaymentMethodAlipay {
result = append(result, method)
}
}
return result, nil
}
// EnsureRechargeAllowed 校验普通充值只使用资产配置允许的第三方支付方式。
func (p *Policy) EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error {
methods, err := p.AllowedRechargeMethods(ctx, assetType)
if err != nil {
return err
}
if !slices.Contains(methods, paymentMethod) {
return errors.New(errors.CodePaymentMethodUnavailable)
}
return nil
}
func configKey(assetType string) (string, error) {
switch assetType {
case "card", constants.AssetTypeIotCard, model.OrderTypeSingleCard:
return constants.SystemConfigPaymentAllowedCard, nil
case constants.AssetTypeDevice:
return constants.SystemConfigPaymentAllowedDevice, nil
default:
return "", errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
}
func validMethods(methods []string) bool {
if len(methods) == 0 {
return false
}
seen := make(map[string]struct{}, len(methods))
for _, method := range methods {
if method != model.PaymentMethodWallet && method != model.PaymentMethodWechat && method != model.PaymentMethodAlipay {
return false
}
if _, exists := seen[method]; exists {
return false
}
seen[method] = struct{}{}
}
return true
}
// ValidateConfigValue 校验支付方式系统配置的 JSON 集合。
func ValidateConfigValue(value string) error {
var methods []string
if err := sonic.Unmarshal([]byte(value), &methods); err != nil || !validMethods(methods) {
return stderrors.New("支付方式配置必须是非空且不重复的合法数组")
}
return nil
}
func stableMethods(methods []string) []string {
result := make([]string, 0, len(methods))
for _, method := range []string{model.PaymentMethodWallet, model.PaymentMethodWechat, model.PaymentMethodAlipay} {
if slices.Contains(methods, method) {
result = append(result, method)
}
}
return result
}

View File

@@ -57,6 +57,15 @@ type PurchaseValidationResult struct {
}
func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
return s.validateCardPurchase(ctx, cardID, packageIDs, false)
}
// ValidatePersonalCardPurchase 校验个人客户卡套餐购买,并允许当前世代生效中套餐续费。
func (s *Service) ValidatePersonalCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
return s.validateCardPurchase(ctx, cardID, packageIDs, true)
}
func (s *Service) validateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint, allowRenewal bool) (*PurchaseValidationResult, error) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -79,7 +88,11 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package
sellerShopID = *card.ShopID
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, sellerShopID)
renewablePackageIDs, err := s.loadRenewablePackageIDs(ctx, constants.AssetWalletResourceTypeIotCard, card.ID, card.Generation, packageIDs, allowRenewal)
if err != nil {
return nil, err
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, sellerShopID, renewablePackageIDs)
if err != nil {
return nil, err
}
@@ -96,6 +109,15 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package
}
func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
return s.validateDevicePurchase(ctx, deviceID, packageIDs, false)
}
// ValidatePersonalDevicePurchase 校验个人客户设备套餐购买,并允许当前世代生效中套餐续费。
func (s *Service) ValidatePersonalDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
return s.validateDevicePurchase(ctx, deviceID, packageIDs, true)
}
func (s *Service) validateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint, allowRenewal bool) (*PurchaseValidationResult, error) {
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -114,7 +136,11 @@ func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, pac
sellerShopID = *device.ShopID
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, sellerShopID)
renewablePackageIDs, err := s.loadRenewablePackageIDs(ctx, constants.AssetWalletResourceTypeDevice, device.ID, device.Generation, packageIDs, allowRenewal)
if err != nil {
return nil, err
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, sellerShopID, renewablePackageIDs)
if err != nil {
return nil, err
}
@@ -133,7 +159,7 @@ func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, pac
// validatePackages 验证套餐列表是否可购买
// sellerShopID > 0 表示代理渠道,校验该代理的 allocation.shelf_status
// sellerShopID == 0 表示平台自营渠道,校验 package.shelf_status
func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint, sellerShopID uint) ([]*model.Package, int64, error) {
func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint, sellerShopID uint, renewablePackageIDs map[uint]struct{}) ([]*model.Package, int64, error) {
if len(packageIDs) == 0 {
return nil, 0, errors.New(errors.CodeInvalidParam, "请选择至少一个套餐")
}
@@ -164,9 +190,10 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已禁用")
}
_, canRenewOffShelf := renewablePackageIDs[pkgID]
if sellerShopID > 0 {
// 代理渠道:检查上架状态并获取分配记录,使用零售价
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID)
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID, canRenewOffShelf)
if allocErr != nil {
return nil, 0, allocErr
}
@@ -177,7 +204,7 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
}
totalPrice += effectiveRetailPrice
} else {
if pkg.ShelfStatus != constants.ShelfStatusOn {
if pkg.ShelfStatus != constants.ShelfStatusOn && !canRenewOffShelf {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
totalPrice += packageprice.PackageEffectiveRetailPrice(pkg)
@@ -230,7 +257,7 @@ func (s *Service) validatePackageUsageRules(ctx context.Context, carrierType str
}
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint, allowOffShelf bool) (*model.ShopPackageAllocation, error) {
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -239,13 +266,44 @@ func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, pac
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
if allocation.ShelfStatus != constants.ShelfStatusOn {
if allocation.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "套餐已禁用")
}
if allocation.ShelfStatus != constants.ShelfStatusOn && !allowOffShelf {
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return allocation, nil
}
func (s *Service) loadRenewablePackageIDs(ctx context.Context, assetType string, assetID uint, generation int, packageIDs []uint, allowRenewal bool) (map[uint]struct{}, error) {
result := make(map[uint]struct{})
if !allowRenewal || len(packageIDs) == 0 {
return result, nil
}
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Distinct("package_id").
Where("generation = ? AND status = ? AND refund_id IS NULL AND package_id IN ?", generation, constants.PackageUsageStatusActive, packageIDs)
switch assetType {
case constants.AssetWalletResourceTypeIotCard:
query = query.Where("usage_type = ? AND iot_card_id = ?", constants.PackageUsageTypeSingleCard, assetID)
case constants.AssetWalletResourceTypeDevice:
query = query.Where("usage_type = ? AND device_id = ?", constants.AssetWalletResourceTypeDevice, assetID)
default:
return nil, errors.New(errors.CodeInvalidParam)
}
var ids []uint
if err := query.Pluck("package_id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐续费资格失败")
}
for _, id := range ids {
result[id] = struct{}{}
}
return result, nil
}
// GetPurchasePrice 获取购买价格
// 代理渠道sellerShopID > 0返回 allocation.RetailPrice平台渠道返回 Package.SuggestedRetailPrice
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sellerShopID uint) (int64, error) {
@@ -297,7 +355,7 @@ func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID u
return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, 0)
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, 0, nil)
if err != nil {
return nil, err
}
@@ -328,7 +386,7 @@ func (s *Service) ValidateAdminOfflineDevicePurchase(ctx context.Context, device
return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐")
}
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, 0)
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, 0, nil)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,145 @@
package refund
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Handle 幂等处理退款的渠道无关审批终态。
func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if s == nil || s.db == nil || event.BusinessType != constants.ApprovalBusinessTypeRefund ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "退款审批终态参数无效")
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return s.applyApprovedDecision(ctx, event)
case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted:
return s.applyClosedDecision(ctx, event)
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
default:
return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态")
}
}
func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refund model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致")
}
if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved {
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过")
}
var order model.Order
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款关联订单失败")
}
approvedAmount := refund.RequestedRefundAmount
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil {
return err
}
if refund.Status == model.RefundStatusPending {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusApproved, "processed_at": event.OccurredAt,
"approved_refund_amount": approvedAmount, "remark": "企业微信审批通过",
"updated_at": event.OccurredAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成退款审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
}
switch order.PaymentStatus {
case model.PaymentStatusPaid:
result := tx.WithContext(ctx).Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid).
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "订单退款状态已变化")
}
order.PaymentStatus = model.PaymentStatusRefunded
case model.PaymentStatusRefunded:
default:
return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款")
}
return s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID)
})
if err != nil {
return err
}
return s.ensureApprovedPostProcessing(ctx, event.BusinessID)
}
func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
reason := map[string]string{
constants.ApprovalDecisionRejected: "企业微信审批已拒绝",
constants.ApprovalDecisionCancelled: "企业微信审批已撤销",
constants.ApprovalDecisionDeleted: "企业微信审批已删除",
}[event.Decision]
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refund model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
}
if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致")
}
if refund.Status == model.RefundStatusRejected {
return nil
}
if refund.Status != model.RefundStatusPending {
return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许结束审批")
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusRejected, "processed_at": event.OccurredAt,
"reject_reason": strings.TrimSpace(reason), "updated_at": event.OccurredAt,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束退款审批申请失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款申请状态已变化")
}
return nil
})
}
func (s *Service) ensureApprovedPostProcessing(ctx context.Context, refundID uint) error {
s.deductAllCommission(ctx, refundID)
s.handleRefundAssetProcessing(ctx, refundID)
var refund model.RefundRequest
if err := s.db.WithContext(ctx).Select("commission_deducted", "asset_reset").Where("id = ?", refundID).First(&refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核退款后处理状态失败")
}
if !refund.CommissionDeducted || !refund.AssetReset {
return errors.New(errors.CodeServiceUnavailable, "退款后处理尚未全部完成,将自动重试")
}
return nil
}
var _ approvalapp.BusinessDecisionHandler = (*Service)(nil)

View File

@@ -7,17 +7,20 @@ import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -43,6 +46,7 @@ type Service struct {
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
refundApprovalCreation *refundapprovalapp.CreationService
logger *zap.Logger
}
@@ -84,6 +88,11 @@ func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService)
s.agentWalletRefundService = service
}
// SetRefundApprovalCreationService 注入退款企微审批申请用例。
func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.CreationService) {
s.refundApprovalCreation = service
}
// Create 创建退款申请
// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
@@ -111,12 +120,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
return nil, err
}
// 检查是否存在活跃退款申请(待审批、已通过、已退回状态)
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 {
@@ -144,11 +147,22 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
refund.Creator = userID
refund.Updater = userID
if err := s.refundStore.Create(ctx, refund); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建退款申请失败")
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, SubmitterAccountID: userID,
})
if err != nil {
return nil, err
}
return buildRefundResponse(refund), nil
resp := buildRefundResponse(result.Refund)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
// List 分页查询退款申请列表
@@ -176,10 +190,21 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败")
}
submitterNames, err := s.loadSubmitterNames(ctx, refundSubmitterIDs(requests))
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
}
approvalSummaries, err := s.loadApprovalSummaries(ctx, requests)
if err != nil {
return nil, err
}
items := make([]dto.RefundResponse, 0, len(requests))
for _, r := range requests {
items = append(items, *buildRefundResponse(r))
item := buildRefundResponse(r)
item.SubmitterName = submitterNames[r.Creator]
applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID)
items = append(items, *item)
}
return &dto.RefundListResponse{
@@ -196,13 +221,23 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
return buildRefundResponse(refund), nil
resp := buildRefundResponse(refund)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, refund.Creator)
approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.RefundRequest{refund})
if err != nil {
return nil, err
}
applyApprovalSummary(resp, approvalSummaries, refund.ApprovalInstanceID)
return resp, nil
}
// Approve 审批通过退款申请
// 条件更新 WHERE status=1设置审批信息
// 事务提交成功后异步执行佣金回扣和退款后资产处理
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error {
if !legacyRefundManualEnabled() {
return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -218,6 +253,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
if refund.Status != model.RefundStatusPending {
return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
}
if refund.ApprovalInstanceID != nil {
return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批")
}
now := time.Now()
approvedAmount := refund.RequestedRefundAmount
@@ -356,6 +394,20 @@ func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) {
// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。
func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
var existing model.AssetWalletTransaction
err := tx.WithContext(ctx).
Where("reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&existing).Error
if err == nil {
if existing.Amount != amount {
return errors.New(errors.CodeConflict, "退款申请已存在不一致的资产钱包回款流水")
}
return nil
}
if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "复核资产钱包退款流水失败")
}
wallet, err := s.resolveAssetRefundWallet(tx, order)
if err != nil {
return err
@@ -463,6 +515,9 @@ func lockAssetWalletByResource(tx *gorm.DB, resourceType string, resourceID uint
// Reject 审批拒绝退款申请
// 条件更新 WHERE status=1
func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error {
if !legacyRefundManualEnabled() {
return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
@@ -474,7 +529,7 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ
now := time.Now()
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusRejected,
"processor_id": userID,
@@ -492,6 +547,11 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ
return nil
}
func legacyRefundManualEnabled() bool {
cfg := config.Get()
return cfg == nil || cfg.Approval.LegacyRefundManualEnabled
}
// Return 退回退款申请
// 条件更新 WHERE status=1退回后可重新提交
func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequest) error {
@@ -506,7 +566,7 @@ func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequ
now := time.Now()
result := s.db.WithContext(ctx).
Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusPending).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusReturned,
"processor_id": userID,
@@ -602,9 +662,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
return nil
}
// deductAllCommission 异步回扣该订单所有已入账佣金
// 查询退款单关联订单的所有已入账佣金记录,逐条从代理佣金钱包扣减
// 失败仅记录日志,不影响审批结果
// deductAllCommission 幂等回扣该订单所有已入账佣金
// 每条佣金在独立事务中锁定并失效;全部完成后才设置退款单完成标记。
func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
logger := s.logger
@@ -614,6 +673,9 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
return
}
if refund.CommissionDeducted {
return
}
// 查询该订单所有已入账佣金记录
var commissions []model.CommissionRecord
@@ -629,9 +691,11 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
return
}
allSucceeded := true
// 对每条佣金记录执行扣减
for _, commission := range commissions {
if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil {
allSucceeded = false
logger.Error("佣金回扣:单条佣金扣减失败",
zap.Uint("refund_id", refundID),
zap.Uint("commission_id", commission.ID),
@@ -642,6 +706,9 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
// 继续处理下一条,不中断
}
}
if !allSucceeded {
return
}
// 全部完成后标记退款单佣金已回扣
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil {
@@ -652,54 +719,72 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
// 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
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.CommissionRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", commission.ID).First(&current).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金记录失败")
}
if current.Status == constants.CommissionStatusInvalid {
return nil
}
if current.Status != constants.CommissionStatusReleased {
return errors.New(errors.CodeInvalidStatus, "退款佣金状态不允许回扣")
}
var existingCount int64
if err := tx.WithContext(ctx).Model(&model.AgentWalletTransaction{}).
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeCommission, current.ID, constants.AgentTransactionTypeCommissionDeduct, constants.TransactionStatusSuccess).
Count(&existingCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核退款佣金回扣流水失败")
}
if existingCount > 0 {
if err := tx.WithContext(ctx).Model(&model.CommissionRecord{}).Where("id = ?", current.ID).
Update("status", constants.CommissionStatusInvalid).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核后失效退款佣金记录失败")
}
return nil
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", current.ShopID, constants.AgentWalletTypeCommission).
First(&wallet).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败")
}
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{"balance": gorm.Expr("balance - ?", current.Amount), "version": gorm.Expr("version + 1")})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减退款佣金钱包失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款佣金钱包版本已变化")
}
refType, refID := constants.ReferenceTypeCommission, current.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: current.ShopID, UserID: refund.Creator,
TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -current.Amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - current.Amount,
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID,
Creator: refund.Creator, ShopIDTag: current.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款佣金回扣流水失败")
}
updated := tx.WithContext(ctx).Model(&model.CommissionRecord{}).
Where("id = ? AND status = ?", current.ID, constants.CommissionStatusReleased).
Update("status", constants.CommissionStatusInvalid)
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "失效退款佣金记录失败")
}
if updated.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款佣金状态已变化")
}
return nil
})
}
// handleRefundAssetProcessing 异步处理退款后的资产状态
// 包括退款套餐精准失效、尝试接续待生效主套餐必要时停机
// 失败仅记录日志,不影响审批结果
// handleRefundAssetProcessing 幂等处理退款后的资产状态
// 包括退款套餐精准失效、尝试接续待生效主套餐必要时停机;全部完成后才设置完成标记。
func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) {
logger := s.logger
@@ -709,6 +794,9 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
logger.Error("退款资产处理:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
return
}
if refund.AssetReset {
return
}
// 查询关联订单
var order model.Order
@@ -784,7 +872,9 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
}
if !hasActiveMain {
// 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。
s.stopAsset(ctx, assetType, assetID)
if !s.stopAsset(ctx, assetType, assetID) {
return
}
}
// 4. 标记退款后资产处理已完成
@@ -794,7 +884,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint
}
// stopAsset 根据资产类型执行停机操作
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) {
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) bool {
logger := s.logger
switch assetType {
@@ -803,21 +893,38 @@ func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint)
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
logger.Error("退款资产处理:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err))
return
return false
}
if s.stopResumeService != nil {
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err))
}
if s.stopResumeService == nil {
logger.Error("退款资产处理:单卡停机服务未注入", zap.Uint("card_id", assetID))
return false
}
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err))
return false
}
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))
if s.stopResumeService == nil {
logger.Error("退款资产处理:设备停机服务未注入", zap.Uint("device_id", assetID))
return false
}
var cards []model.IotCard
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Joins("JOIN tb_device_sim_binding binding ON binding.iot_card_id = tb_iot_card.id AND binding.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ?", assetID, constants.BindStatusBound).
Find(&cards).Error; err != nil {
logger.Error("退款资产处理:查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err))
return false
}
for _, card := range cards {
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("退款资产处理:设备绑定卡停机失败",
zap.Uint("device_id", assetID), zap.Uint("card_id", card.ID), zap.Error(err))
return false
}
}
}
return true
}
// generateRefundNo 生成退款单号
@@ -841,7 +948,7 @@ func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *mo
// validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。
func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error {
if approvedAmount < 0 {
if approvedAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "审批退款金额必须大于0")
}
if approvedAmount > requestedRefundAmount {
@@ -857,7 +964,15 @@ func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
if len(keys) > 5 {
return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个")
}
return model.StringJSONBArray(keys), nil
normalized := make(model.StringJSONBArray, 0, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
return nil, errors.New(errors.CodeInvalidParam, "退款凭证对象存储 Key 不能为空")
}
normalized = append(normalized, key)
}
return normalized, nil
}
// buildRefundResponse 将退款 Model 转换为 DTO 响应
@@ -893,6 +1008,8 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
Remark: r.Remark,
CommissionDeducted: r.CommissionDeducted,
AssetReset: r.AssetReset,
SubmitterID: r.Creator,
ApprovalInstanceID: r.ApprovalInstanceID,
Creator: r.Creator,
Updater: r.Updater,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -906,6 +1023,84 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
return resp
}
type approvalSummary struct {
Provider string
Status int
}
func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) {
ids := make([]uint, 0, len(refunds))
seen := make(map[uint]struct{}, len(refunds))
for _, refund := range refunds {
if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 {
continue
}
id := *refund.ApprovalInstanceID
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
summaries := make(map[uint]approvalSummary, len(ids))
if len(ids) == 0 {
return summaries, nil
}
var instances []model.ApprovalInstance
if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败")
}
for _, instance := range instances {
summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status}
}
return summaries, nil
}
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) {
if response == nil || instanceID == nil {
return
}
summary, exists := summaries[*instanceID]
if !exists {
return
}
status := summary.Status
response.ApprovalProvider = summary.Provider
response.ApprovalStatus = &status
response.ApprovalStatusName = constants.GetApprovalStatusName(status)
}
func refundSubmitterIDs(requests []*model.RefundRequest) []uint {
ids := make([]uint, 0, len(requests))
for _, request := range requests {
if request != nil && request.Creator > 0 {
ids = append(ids, request.Creator)
}
}
return ids
}
func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) {
accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids)
if err != nil {
return nil, err
}
names := make(map[uint]string, len(accounts))
for _, account := range accounts {
names[account.ID] = account.Username
}
return names, nil
}
func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string {
names, err := s.loadSubmitterNames(ctx, []uint{id})
if err != nil {
s.logger.Warn("查询退款提交人失败", zap.Uint("submitter_id", id), zap.Error(err))
return ""
}
return names[id]
}
// buildRefundWalletTransactionAssetSnapshot 从订单中生成退款流水的资产快照。
func buildRefundWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) {
if order == nil {