七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
163
internal/service/asset_package_batch_order/service.go
Normal file
163
internal/service/asset_package_batch_order/service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_iccid(Issue 02)
|
||||
|
||||
@@ -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)
|
||||
|
||||
84
internal/service/device/realname_policy_batch.go
Normal file
84
internal/service/device/realname_policy_batch.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
83
internal/service/iot_card/realname_policy_batch.go
Normal file
83
internal/service/iot_card/realname_policy_batch.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
158
internal/service/iot_card/speed_tier.go
Normal file
158
internal/service/iot_card/speed_tier.go
Normal 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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
134
internal/service/paymentmethod/policy.go
Normal file
134
internal/service/paymentmethod/policy.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
145
internal/service/refund/approval_decision.go
Normal file
145
internal/service/refund/approval_decision.go
Normal 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)
|
||||
@@ -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(¤t).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 {
|
||||
|
||||
Reference in New Issue
Block a user