Files
junhong_cmp_fiber/internal/service/exchange/service.go
break ba435dd6a6
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
关于绑定的问题
2026-06-22 12:08:41 +09:00

986 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package exchange
import (
"context"
"strings"
"time"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
exchangeStore *postgres.ExchangeOrderStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
assetWalletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
resourceTagStore *postgres.ResourceTagStore
customerBinding *customerBindingSvc.Service
logger *zap.Logger
}
func New(
db *gorm.DB,
exchangeStore *postgres.ExchangeOrderStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
assetWalletStore *postgres.AssetWalletStore,
assetWalletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore,
resourceTagStore *postgres.ResourceTagStore,
customerBinding *customerBindingSvc.Service,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
exchangeStore: exchangeStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
assetWalletTransactionStore: assetWalletTransactionStore,
packageUsageStore: packageUsageStore,
packageUsageDailyRecordStore: packageUsageDailyRecordStore,
resourceTagStore: resourceTagStore,
customerBinding: customerBinding,
logger: logger,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) {
flowType := normalizeExchangeFlowType(req.FlowType)
if !isValidExchangeFlowType(flowType) {
return nil, errors.New(errors.CodeInvalidParam, "换货流程类型不合法")
}
if flowType == constants.ExchangeFlowTypeDirect && strings.TrimSpace(req.NewIdentifier) == "" {
return nil, errors.New(errors.CodeInvalidParam, "直接换货必须填写新资产标识")
}
asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier)
if err != nil {
return nil, err
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
return nil, oldAssetStatusError(asset.AssetStatus)
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
return nil, errors.New(errors.CodeExchangeInProgress)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
if flowType == constants.ExchangeFlowTypeDirect {
return s.createDirectExchange(ctx, req, asset)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeShipping,
OldAssetType: asset.AssetType,
OldAssetID: asset.AssetID,
OldAssetIdentifier: asset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: false,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if asset.ShopID != nil {
order.ShopID = asset.ShopID
}
if err = s.exchangeStore.Create(ctx, order); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败")
}
return s.toExchangeOrderResponse(order), nil
}
func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
page := req.Page
page = max(page, 1)
pageSize := req.PageSize
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
filters := make(map[string]any)
if req.Status != nil {
filters["status"] = *req.Status
}
if req.FlowType != "" {
filters["flow_type"] = normalizeExchangeFlowType(req.FlowType)
}
if req.Identifier != "" {
filters["identifier"] = req.Identifier
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
}
orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
}
list := make([]*dto.ExchangeOrderResponse, 0, len(orders))
for _, item := range orders {
list = append(list, s.toExchangeOrderResponse(item))
}
return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败")
}
return s.toExchangeOrderResponse(order), nil
}
func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingShip {
return nil, errors.New(errors.CodeExchangeStatusInvalid)
}
if !isShippingExchangeFlow(order.FlowType) {
return nil, errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
}
if err = s.shipWithTx(ctx, order, req); err != nil {
return nil, err
}
return s.Get(ctx, id)
}
func (s *Service) Complete(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id)
if lockErr != nil {
return lockErr
}
if !isShippingExchangeFlow(lockedOrder.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
}
if lockedOrder.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped)
})
}
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
}
updates := map[string]any{
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}
if req != nil {
updates["remark"] = req.Remark
}
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
}
return nil
}
func (s *Service) Renew(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusCompleted {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
}
if card.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
}
cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo})
if cardKey != "" {
if err = tx.Where("virtual_no = ?", cardKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
}
var device model.Device
if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败")
}
if device.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败")
}
deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo})
if deviceKey != "" {
if err = tx.Where("virtual_no = ?", deviceKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
}
shopTag := uint(0)
if device.ShopID != nil {
shopTag = *device.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
})
}
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
asset, err := s.resolveAssetByIdentifier(ctx, "", identifier)
if err != nil {
return nil, err
}
if !s.customerOwnsAsset(ctx, asset) {
return nil, errors.New(errors.CodeAssetNotFound)
}
order, err := s.exchangeStore.FindShippingPendingByOldAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败")
}
return &dto.ClientExchangePendingResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
FlowType: effectiveExchangeFlowType(order.FlowType),
Status: order.Status,
StatusName: constants.GetExchangeStatusName(order.Status),
StatusText: constants.GetExchangeStatusName(order.Status),
ExchangeReason: order.ExchangeReason,
CreatedAt: order.CreatedAt,
}, nil
}
func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
}
if order.Status != constants.ExchangeStatusPendingInfo {
return errors.New(errors.CodeExchangeStatusInvalid)
}
oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if err != nil {
return err
}
if !s.customerOwnsAsset(ctx, oldAsset) {
return errors.New(errors.CodeExchangeOrderNotFound)
}
updates := map[string]any{
"recipient_name": req.RecipientName,
"recipient_phone": req.RecipientPhone,
"recipient_address": req.RecipientAddress,
"updated_at": time.Now(),
}
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
}
return nil
}
type resolvedExchangeAsset struct {
AssetType string
AssetID uint
Identifier string
VirtualNo string
AssetStatus int
ShopID *uint
Card *model.IotCard
Device *model.Device
}
func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
card, err := s.iotCardStore.GetByIdentifier(ctx, identifier)
if err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
}
return nil, errors.New(errors.CodeAssetNotFound)
}
// isExchangeableAssetStatus 判断旧资产状态是否允许发起换货(在库和已销售均可换货)
func isExchangeableAssetStatus(status int) bool {
return status == constants.AssetStatusInStock || status == constants.AssetStatusSold
}
// oldAssetStatusError 根据旧资产的实际状态返回具体的错误原因
func oldAssetStatusError(status int) error {
switch status {
case constants.AssetStatusExchanged:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产已完成过换货,如需再次换货请使用换货后的新资产")
case constants.AssetStatusDeactivated:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产已停用,无法发起换货")
default:
return errors.New(errors.CodeExchangeStatusInvalid, "该资产当前状态不允许换货")
}
}
func normalizeExchangeFlowType(flowType string) string {
flowType = strings.TrimSpace(flowType)
if flowType == "" {
return constants.ExchangeFlowTypeShipping
}
return flowType
}
func effectiveExchangeFlowType(flowType string) string {
return normalizeExchangeFlowType(flowType)
}
func isValidExchangeFlowType(flowType string) bool {
return flowType == constants.ExchangeFlowTypeShipping || flowType == constants.ExchangeFlowTypeDirect
}
func isShippingExchangeFlow(flowType string) bool {
return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping
}
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset) (*dto.ExchangeOrderResponse, error) {
var orderID uint
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID)
if err != nil {
return err
}
if !isExchangeableAssetStatus(lockedOldAsset.AssetStatus) {
return oldAssetStatusError(lockedOldAsset.AssetStatus)
}
if err = s.ensureNoActiveExchangeWithTx(ctx, tx, lockedOldAsset.AssetType, lockedOldAsset.AssetID); err != nil {
return err
}
newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier)
if err != nil {
return err
}
if newAsset.AssetType != lockedOldAsset.AssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeDirect,
OldAssetType: lockedOldAsset.AssetType,
OldAssetID: lockedOldAsset.AssetID,
OldAssetIdentifier: lockedOldAsset.Identifier,
NewAssetType: newAsset.AssetType,
NewAssetID: &newAsset.AssetID,
NewAssetIdentifier: newAsset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if lockedOldAsset.ShopID != nil {
order.ShopID = lockedOldAsset.ShopID
}
if err = tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败")
}
if err = s.completeExchangeWithTx(ctx, tx, order, constants.ExchangeStatusPendingInfo); err != nil {
return err
}
orderID = order.ID
return nil
})
if err != nil {
return nil, err
}
return s.Get(ctx, orderID)
}
func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, err := s.lockExchangeOrderByID(ctx, tx, order.ID)
if err != nil {
return err
}
if !isShippingExchangeFlow(lockedOrder.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
}
if lockedOrder.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
}
oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, lockedOrder.OldAssetType, lockedOrder.OldAssetID)
if err != nil {
return err
}
newAsset, err := s.resolveAssetByIdentifierWithTx(ctx, tx, "", req.NewIdentifier)
if err != nil {
return err
}
if newAsset.AssetType != lockedOrder.OldAssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
if err = s.validateExchangeAssetsWithTx(ctx, tx, lockedOrder.ID, oldAsset, newAsset); err != nil {
return err
}
now := time.Now()
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", lockedOrder.ID, constants.ExchangeStatusPendingShip).
Updates(map[string]any{
"new_asset_type": newAsset.AssetType,
"new_asset_id": newAsset.AssetID,
"new_asset_identifier": newAsset.Identifier,
"express_company": req.ExpressCompany,
"express_no": req.ExpressNo,
"migrate_data": req.MigrateData,
"shipped_at": now,
"status": constants.ExchangeStatusShipped,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新换货单发货状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
})
}
func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, fromStatus int) error {
if order.NewAssetID == nil || *order.NewAssetID == 0 || order.NewAssetIdentifier == "" {
return errors.New(errors.CodeInvalidParam, "新资产信息缺失")
}
oldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.OldAssetType, order.OldAssetID)
if err != nil {
return err
}
newAsset, err := s.resolveAssetByIDWithTx(ctx, tx, order.NewAssetType, *order.NewAssetID)
if err != nil {
return err
}
if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil {
return err
}
if err = s.switchCustomerBindingWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
if err = s.updateAssetStatusesForCompletion(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
var migrationBalance int64
if order.MigrateData {
migrationBalance, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return err
}
}
now := time.Now()
updates := map[string]any{
"status": constants.ExchangeStatusCompleted,
"completed_at": now,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": now,
}
if order.MigrateData {
updates["migration_completed"] = true
updates["migration_balance"] = migrationBalance
}
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", order.ID, fromStatus).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "确认换货完成失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
}
func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&order).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
return &order, nil
}
func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetID uint) (*resolvedExchangeAsset, error) {
if assetType == constants.ExchangeAssetTypeIotCard {
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
}
if assetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*resolvedExchangeAsset, error) {
if assetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
}
if assetType == constants.ExchangeAssetTypeDevice {
var device model.Device
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", assetID)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(&device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.DB, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
var device model.Device
query := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
query := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("virtual_no = ? OR iccid = ? OR msisdn = ? OR iccid_19 = ? OR iccid_20 = ?", identifier, identifier, identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
}
return nil, errors.New(errors.CodeAssetNotFound)
}
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("old_asset_type = ? AND old_asset_id = ?", assetType, assetID).
Where("status IN ?", []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped})
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
if count > 0 {
return errors.New(errors.CodeExchangeInProgress)
}
return nil
}
func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB, orderID uint, oldAsset, newAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType != newAsset.AssetType {
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
if !isExchangeableAssetStatus(oldAsset.AssetStatus) {
return oldAssetStatusError(oldAsset.AssetStatus)
}
if newAsset.AssetStatus != constants.AssetStatusInStock {
return errors.New(errors.CodeExchangeNewAssetNotInStock)
}
if !sameShopID(oldAsset.ShopID, newAsset.ShopID) {
return errors.New(errors.CodeForbidden, "新旧资产归属不一致")
}
if err := s.ensureNewAssetBindingAvailableWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
occupied, err := s.hasShippingNewAssetOccupiedWithTx(ctx, tx, newAsset.AssetType, newAsset.AssetID, orderID)
if err != nil {
return err
}
if occupied {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产已被其他换货单占用")
}
return nil
}
func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
newKey := exchangeAssetBindingKey(newAsset)
// 新资产无虚拟号时不再拦截Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形
if newKey == "" {
return nil
}
var newBindCount int64
if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ? AND status = ?", newKey, constants.StatusEnabled).
Count(&newBindCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询新资产客户绑定失败")
}
if newBindCount > 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产已存在有效客户绑定")
}
return nil
}
func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
return s.customerBinding.Migrate(ctx, tx, oldAsset.AssetType, oldAsset.AssetID, newAsset.AssetType, newAsset.AssetID)
}
func (s *Service) updateAssetStatusesForCompletion(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
now := time.Now()
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
result := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}).
Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧卡状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试")
}
result = tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status = ?", newAsset.AssetID, constants.AssetStatusInStock).
Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新卡状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeNewAssetNotInStock)
}
return nil
}
result := tx.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status IN ?", oldAsset.AssetID, []int{constants.AssetStatusInStock, constants.AssetStatusSold}).
Updates(map[string]any{"asset_status": constants.AssetStatusExchanged, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新旧设备状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid, "旧资产状态已被修改,请重试")
}
result = tx.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status = ?", newAsset.AssetID, constants.AssetStatusInStock).
Updates(map[string]any{"asset_status": constants.AssetStatusSold, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新新设备状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeNewAssetNotInStock)
}
return nil
}
func (s *Service) hasShippingNewAssetOccupiedWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, excludeOrderID uint) (bool, error) {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("new_asset_type = ? AND new_asset_id = ?", assetType, assetID).
Where("status = ?", constants.ExchangeStatusShipped).
Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, constants.ExchangeFlowTypeShipping)
if excludeOrderID > 0 {
query = query.Where("id <> ?", excludeOrderID)
}
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产占用失败")
}
return count > 0, nil
}
func (s *Service) customerOwnsAsset(ctx context.Context, asset *resolvedExchangeAsset) bool {
customerID := middleware.GetCustomerIDFromContext(ctx)
if customerID == 0 || asset == nil {
return false
}
key := exchangeAssetBindingKey(asset)
if key == "" {
return false
}
var count int64
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("customer_id = ? AND virtual_no = ? AND status = ?", customerID, key, constants.StatusEnabled).
Count(&count).Error; err != nil {
return false
}
return count > 0
}
func exchangeAssetBindingKey(asset *resolvedExchangeAsset) string {
if asset == nil {
return ""
}
if asset.AssetType == constants.ExchangeAssetTypeIotCard {
if asset.Card != nil {
return asset.Card.VirtualNo
}
return asset.VirtualNo
}
if asset.Device == nil {
return asset.VirtualNo
}
if asset.Device.VirtualNo != "" {
return asset.Device.VirtualNo
}
return asset.Device.IMEI
}
func preferredDeviceIdentifier(device *model.Device) string {
if device == nil {
return ""
}
if device.VirtualNo != "" {
return device.VirtualNo
}
if device.IMEI != "" {
return device.IMEI
}
return device.SN
}
func sameShopID(left, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
if order == nil {
return nil
}
var deletedAt *time.Time
if order.DeletedAt.Valid {
deletedAt = &order.DeletedAt.Time
}
return &dto.ExchangeOrderResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
FlowType: effectiveExchangeFlowType(order.FlowType),
FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
OldAssetType: order.OldAssetType,
OldAssetID: order.OldAssetID,
OldAssetIdentifier: order.OldAssetIdentifier,
NewAssetType: order.NewAssetType,
NewAssetID: order.NewAssetID,
NewAssetIdentifier: order.NewAssetIdentifier,
RecipientName: order.RecipientName,
RecipientPhone: order.RecipientPhone,
RecipientAddress: order.RecipientAddress,
ExpressCompany: order.ExpressCompany,
ExpressNo: order.ExpressNo,
MigrateData: order.MigrateData,
MigrationCompleted: order.MigrationCompleted,
MigrationBalance: order.MigrationBalance,
ShippedAt: order.ShippedAt,
CompletedAt: order.CompletedAt,
ExchangeReason: order.ExchangeReason,
Remark: order.Remark,
Status: order.Status,
StatusName: constants.GetExchangeStatusName(order.Status),
StatusText: constants.GetExchangeStatusName(order.Status),
ShopID: order.ShopID,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
DeletedAt: deletedAt,
Creator: order.Creator,
Updater: order.Updater,
}
}