关于绑定的问题
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-06-22 12:08:41 +09:00
parent 5f960daf78
commit ba435dd6a6
13 changed files with 1306 additions and 210 deletions

View File

@@ -17,7 +17,6 @@ import (
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
@@ -56,7 +55,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
rechargeOrderStore,
paymentStore,
assetWalletStore,
personalCustomerDeviceStore,
svc.CustomerBinding,
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
@@ -78,12 +77,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop),
ShopRole: admin.NewShopRoleHandler(svc.Shop),

View File

@@ -14,6 +14,7 @@ import (
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
@@ -109,9 +110,13 @@ type services struct {
TrafficQuery *trafficSvc.QueryService
OperationPassword *operationPasswordSvc.Service
AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
// CustomerBinding 模块依赖最少,最先初始化
customerBinding := customerBindingSvc.New(deps.DB, s.IotCard, s.Device)
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
@@ -210,7 +215,6 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.DB,
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerDevice,
s.PersonalCustomerPhone,
s.IotCard,
s.Device,
@@ -219,6 +223,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.JWTManager,
deps.Redis,
deps.Logger,
customerBinding,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
@@ -264,7 +269,7 @@ func initServices(s *stores, deps *Dependencies) *services {
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger),
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
@@ -308,5 +313,6 @@ func initServices(s *stores, deps *Dependencies) *services {
s.AssetWallet,
deps.Logger,
),
CustomerBinding: customerBinding,
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -27,7 +28,7 @@ import (
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
type ClientAssetHandler struct {
assetService *asset.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBinding.Service
assetWalletStore *postgres.AssetWalletStore
packageStore *postgres.PackageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
@@ -40,7 +41,7 @@ type ClientAssetHandler struct {
// NewClientAssetHandler 创建 C 端资产信息处理器
func NewClientAssetHandler(
assetService *asset.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
binding *customerBinding.Service,
assetWalletStore *postgres.AssetWalletStore,
packageStore *postgres.PackageStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
@@ -51,7 +52,7 @@ func NewClientAssetHandler(
) *ClientAssetHandler {
return &ClientAssetHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
customerBinding: binding,
assetWalletStore: assetWalletStore,
packageStore: packageStore,
shopPackageAllocationStore: shopPackageAllocationStore,
@@ -102,7 +103,7 @@ func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
@@ -422,21 +423,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
return response.Success(c, resp)
}
func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {

View File

@@ -5,6 +5,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -27,7 +28,7 @@ type deviceAssetInfo struct {
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
type ClientDeviceHandler struct {
assetService *assetSvc.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBinding.Service
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
@@ -38,7 +39,7 @@ type ClientDeviceHandler struct {
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
binding *customerBinding.Service,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
@@ -47,7 +48,7 @@ func NewClientDeviceHandler(
) *ClientDeviceHandler {
return &ClientDeviceHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
customerBinding: binding,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
@@ -79,11 +80,11 @@ func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier strin
}
// 校验个人客户对该设备的所有权
owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
owns, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID)
if err != nil {
h.logger.Error("校验设备所有权失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Uint("asset_id", asset.AssetID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
@@ -29,7 +30,7 @@ var clientRealnameValidator = validator.New()
// ClientRealnameHandler C 端实名认证处理器
type ClientRealnameHandler struct {
assetService *assetService.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBinding.Service
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
@@ -41,7 +42,7 @@ type ClientRealnameHandler struct {
// NewClientRealnameHandler 创建 C 端实名认证处理器
func NewClientRealnameHandler(
assetSvc *assetService.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
binding *customerBinding.Service,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
carrierStore *postgres.CarrierStore,
@@ -51,7 +52,7 @@ func NewClientRealnameHandler(
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
personalDeviceStore: personalDeviceStore,
customerBinding: binding,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
carrierStore: carrierStore,
@@ -89,11 +90,11 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
}
// 4. 验证资产归属(个人客户必须绑定过该资产)
owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
owned, err := h.customerBinding.OwnsAsset(ctx, customerID, asset.AssetType, asset.AssetID)
if err != nil {
logger.GetAppLogger().Error("查询资产归属失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Uint("asset_id", asset.AssetID),
zap.Error(err))
return errors.New(errors.CodeInternalError, "查询资产归属失败")
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -31,7 +32,7 @@ import (
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
type ClientWalletHandler struct {
assetService *asset.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBinding.Service
walletStore *postgres.AssetWalletStore
transactionStore *postgres.AssetWalletTransactionStore
rechargeOrderStore *postgres.RechargeOrderStore
@@ -49,7 +50,7 @@ type ClientWalletHandler struct {
// NewClientWalletHandler 创建 C 端钱包处理器
func NewClientWalletHandler(
assetService *asset.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
binding *customerBinding.Service,
walletStore *postgres.AssetWalletStore,
transactionStore *postgres.AssetWalletTransactionStore,
rechargeOrderStore *postgres.RechargeOrderStore,
@@ -65,7 +66,7 @@ func NewClientWalletHandler(
) *ClientWalletHandler {
return &ClientWalletHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
customerBinding: binding,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeOrderStore: rechargeOrderStore,
@@ -544,7 +545,7 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
@@ -572,21 +573,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
}, nil
}
func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {

View File

@@ -10,6 +10,7 @@ import (
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -41,7 +42,6 @@ type Service struct {
db *gorm.DB
openidStore *postgres.PersonalCustomerOpenIDStore
customerStore *postgres.PersonalCustomerStore
deviceBindStore *postgres.PersonalCustomerDeviceStore
phoneStore *postgres.PersonalCustomerPhoneStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
@@ -51,6 +51,7 @@ type Service struct {
redis *redis.Client
logger *zap.Logger
wechatCache kernel.CacheInterface
customerBinding *customerBinding.Service
}
// New 创建 C 端认证服务实例
@@ -58,7 +59,6 @@ func New(
db *gorm.DB,
openidStore *postgres.PersonalCustomerOpenIDStore,
customerStore *postgres.PersonalCustomerStore,
deviceBindStore *postgres.PersonalCustomerDeviceStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
@@ -67,12 +67,12 @@ func New(
jwtManager *auth.JWTManager,
redisClient *redis.Client,
logger *zap.Logger,
binding *customerBinding.Service,
) *Service {
return &Service{
db: db,
openidStore: openidStore,
customerStore: customerStore,
deviceBindStore: deviceBindStore,
phoneStore: phoneStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
@@ -82,6 +82,7 @@ func New(
redis: redisClient,
logger: logger,
wechatCache: wechat.NewRedisCache(redisClient),
customerBinding: binding,
}
}
@@ -630,99 +631,9 @@ func (s *Service) checkCardBoundToDevice(ctx context.Context, cardID uint) (bool
return !card.IsStandalone || card.DeviceVirtualNo != "", nil
}
// bindAsset 绑定客户与资产关系
// bindAsset 委托 CustomerBinding 模块处理客户与资产的绑定关系
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID)
if err != nil {
return err
}
var bindCount int64
if err := tx.WithContext(ctx).
Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ?", assetKey).
Count(&bindCount).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败")
}
firstEverBind := bindCount == 0
bindStore := postgres.NewPersonalCustomerDeviceStore(tx)
exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
}
if !exists {
record := &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: assetKey,
Status: 1,
}
if err := bindStore.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败")
}
}
if firstEverBind {
if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
return nil
}
func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) {
if assetType == assetTypeIotCard {
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
return card.VirtualNo, nil
}
if assetType == assetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
if device.VirtualNo != "" {
return device.VirtualNo, nil
}
return device.IMEI, nil
}
return "", errors.New(errors.CodeInvalidParam)
}
func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
if assetType == assetTypeIotCard {
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Update("asset_status", 2).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败")
}
return nil
}
if assetType == assetTypeDevice {
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Update("asset_status", 2).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败")
}
return nil
}
return errors.New(errors.CodeInvalidParam)
return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID)
}
func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) {

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
@@ -55,7 +56,7 @@ type Service struct {
rechargeOrderStore *postgres.RechargeOrderStore
paymentStore *postgres.PaymentStore
walletStore *postgres.AssetWalletStore
personalDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBinding.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
@@ -78,7 +79,7 @@ func New(
rechargeOrderStore *postgres.RechargeOrderStore,
paymentStore *postgres.PaymentStore,
walletStore *postgres.AssetWalletStore,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
binding *customerBinding.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
personalCustomerStore *postgres.PersonalCustomerStore,
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore,
@@ -99,7 +100,7 @@ func New(
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
walletStore: walletStore,
personalDeviceStore: personalDeviceStore,
customerBinding: binding,
openIDStore: openIDStore,
personalCustomerStore: personalCustomerStore,
personalCustomerPhoneStore: personalCustomerPhoneStore,
@@ -132,8 +133,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return nil, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); 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, "无权限操作该资产或资源不存在")
}
validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs)
@@ -256,29 +259,25 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
// getOrderAssetInfo 从订单中提取资产类型和 ID无需额外 DB 查询
func getOrderAssetInfo(order *model.Order) (string, uint, error) {
if order == nil {
return "", 0, errors.New(errors.CodeNotFound, "订单不存在")
}
if owned {
return nil
}
records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
}
for _, record := range records {
if record == nil {
continue
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil || *order.IotCardID == 0 {
return "", 0, errors.New(errors.CodeInvalidParam)
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return nil
return "iot_card", *order.IotCardID, nil
case model.OrderTypeDevice:
if order.DeviceID == nil || *order.DeviceID == 0 {
return "", 0, errors.New(errors.CodeInvalidParam)
}
return "device", *order.DeviceID, nil
default:
return "", 0, errors.New(errors.CodeInvalidParam)
}
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) {
@@ -1066,8 +1065,10 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie
return nil, 0, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, 0, err
if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
} else if !owned {
return nil, 0, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
generation, err := s.getAssetGeneration(skipCtx, assetInfo.AssetType, assetInfo.AssetID)
@@ -1150,13 +1151,14 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u
}
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
virtualNo, err := s.getOrderVirtualNo(skipCtx, order)
orderAssetType, orderAssetID, err := getOrderAssetInfo(order)
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, virtualNo); err != nil {
return nil, err
if owned, ownErr := s.customerBinding.OwnsAsset(skipCtx, customerID, orderAssetType, orderAssetID); ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
} else if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
packages := make([]dto.ClientOrderPackageItem, 0, len(items))

View File

@@ -0,0 +1,473 @@
// Package customer_binding 封装客户与资产之间的绑定创建和归属验证逻辑
// 屏蔽"有虚拟号走 tb_personal_customer_device / 无虚拟号走 tb_personal_customer_iccid"的路由细节
package customer_binding
import (
"context"
"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/errors"
)
const (
assetTypeIotCard = "iot_card"
assetTypeDevice = "device"
)
// normalizeAssetType 统一资产类型字符串
// assetService.Resolve 返回 "card"client_auth 内部使用 "iot_card",两者等价
func normalizeAssetType(assetType string) string {
if assetType == "card" {
return assetTypeIotCard
}
return assetType
}
// cardReader 按 ID 查询 IoT 卡OwnsAsset 使用,无需感知事务)
type cardReader interface {
GetByID(ctx context.Context, id uint) (*model.IotCard, error)
}
// deviceReader 按 ID 查询设备OwnsAsset 使用,无需感知事务)
type deviceReader interface {
GetByID(ctx context.Context, id uint) (*model.Device, error)
}
// pcdOps tb_personal_customer_device 操作接口
type pcdOps interface {
ExistsByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (bool, error)
Create(ctx context.Context, record *model.PersonalCustomerDevice) error
CountByVirtualNo(ctx context.Context, virtualNo string) (int64, error)
GetByDeviceNo(ctx context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error)
UpdateStatus(ctx context.Context, id uint, status int) error
UpdateVirtualNo(ctx context.Context, id uint, newVirtualNo string) error
}
// pciOps tb_personal_customer_iccid 操作接口
type pciOps interface {
ExistsByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (bool, error)
Create(ctx context.Context, record *model.PersonalCustomerICCID) error
CountByICCID(ctx context.Context, iccid string) (int64, error)
GetByICCID(ctx context.Context, iccid string) ([]*model.PersonalCustomerICCID, error)
UpdateStatus(ctx context.Context, id uint, status int) error
}
// Service 客户绑定服务
type Service struct {
db *gorm.DB
cards cardReader
devices deviceReader
makePCD func(*gorm.DB) pcdOps
makePCI func(*gorm.DB) pciOps
markAsSold func(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error
// readCard/readDevice 用于 Bind/Migrate 内部,接收事务 db 以保持读写在同一事务内
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
}
// New 创建客户绑定服务实例
func New(db *gorm.DB, iotCardStore cardReader, deviceStore deviceReader) *Service {
return &Service{
db: db,
cards: iotCardStore,
devices: deviceStore,
makePCD: func(db *gorm.DB) pcdOps {
return postgres.NewPersonalCustomerDeviceStore(db)
},
makePCI: func(db *gorm.DB) pciOps {
return postgres.NewPersonalCustomerICCIDStore(db)
},
markAsSold: realMarkAssetAsSold,
readCard: func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error) {
var card model.IotCard
if err := db.WithContext(ctx).First(&card, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
return &card, nil
},
readDevice: func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error) {
var device model.Device
if err := db.WithContext(ctx).First(&device, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeAssetNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
return &device, nil
},
}
}
// OwnsAsset 验证客户是否持有指定资产的有效绑定status=1
// 内部根据 assetType + assetID 自动路由到对应绑定表,调用方无需感知底层存储细节
func (s *Service) OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error) {
pcd := s.makePCD(s.db)
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
pci := s.makePCI(s.db)
card, err := s.cards.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, errors.New(errors.CodeAssetNotFound)
}
return false, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
if card.VirtualNo != "" {
return pcd.ExistsByCustomerAndDevice(ctx, customerID, card.VirtualNo)
}
// 无虚拟号:查 pci 表Issue 02 完整实现,此处已路由到正确路径)
return pci.ExistsByCustomerAndICCID(ctx, customerID, card.ICCID)
case assetTypeDevice:
device, err := s.devices.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, errors.New(errors.CodeAssetNotFound)
}
return false, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
key := device.VirtualNo
if key == "" {
key = device.IMEI
}
return pcd.ExistsByCustomerAndDevice(ctx, customerID, key)
}
return false, errors.New(errors.CodeInvalidParam)
}
// Bind 在事务中创建客户与资产的绑定关系
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccidIssue 02
func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
if tx == nil {
tx = s.db
}
pcd := s.makePCD(tx)
pci := s.makePCI(tx)
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, assetID)
if err != nil {
return err
}
if card.VirtualNo != "" {
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID)
}
// 无虚拟号路径Issue 02
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID)
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
if err != nil {
return err
}
key := device.VirtualNo
if key == "" {
key = device.IMEI
}
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID)
}
return errors.New(errors.CodeInvalidParam)
}
// bindViaPCD 通过 tb_personal_customer_device 创建绑定
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint) error {
count, err := pcd.CountByVirtualNo(ctx, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定数量失败")
}
firstEverBind := count == 0
exists, err := pcd.ExistsByCustomerAndDevice(ctx, customerID, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
}
if !exists {
record := &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: virtualNo,
Status: 1,
}
if err := pcd.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败")
}
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
}
return nil
}
// bindViaPCI 通过 tb_personal_customer_iccid 创建绑定无虚拟号卡专用Issue 02
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint) error {
count, err := pci.CountByICCID(ctx, iccid)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询 ICCID 绑定数量失败")
}
firstEverBind := count == 0
exists, err := pci.ExistsByCustomerAndICCID(ctx, customerID, iccid)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询客户 ICCID 绑定关系失败")
}
if !exists {
iccid19 := iccid
if len(iccid) == 20 {
iccid19 = iccid[:19]
}
record := &model.PersonalCustomerICCID{
CustomerID: customerID,
ICCID: iccid,
ICCID19: iccid19,
Status: 1,
}
if err := pci.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建 ICCID 绑定关系失败")
}
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
}
return nil
}
// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用)
// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci
func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
if tx == nil {
tx = s.db
}
pcd := s.makePCD(tx)
pci := s.makePCI(tx)
switch normalizeAssetType(oldAssetType) {
case assetTypeIotCard:
oldCard, err := s.readCard(ctx, tx, oldAssetID)
if err != nil {
return err
}
if oldCard.VirtualNo != "" {
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, newAssetType, newAssetID)
}
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, newAssetType, newAssetID)
case assetTypeDevice:
oldDevice, err := s.readDevice(ctx, tx, oldAssetID)
if err != nil {
return err
}
key := oldDevice.VirtualNo
if key == "" {
key = oldDevice.IMEI
}
return s.migrateFromPCD(ctx, tx, pcd, pci, key, newAssetType, newAssetID)
}
return errors.New(errors.CodeInvalidParam)
}
// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, newAssetType string, newAssetID uint) error {
records, err := pcd.GetByDeviceNo(ctx, oldKey)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧资产绑定记录失败")
}
// 仅处理 status=1 的有效记录
var active []*model.PersonalCustomerDevice
for _, r := range records {
if r.Status == 1 {
active = append(active, r)
}
}
if len(active) == 0 {
return nil
}
switch normalizeAssetType(newAssetType) {
case assetTypeIotCard:
newCard, err := s.readCard(ctx, db, newAssetID)
if err != nil {
return err
}
if newCard.VirtualNo != "" {
// 新卡有虚拟号:更新 pcd virtual_no
for _, rec := range active {
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newCard.VirtualNo); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移客户绑定关系失败")
}
}
} else {
// 新卡无虚拟号:禁用旧 pcd + 创建新 pci
iccid19 := newCard.ICCID
if len(newCard.ICCID) == 20 {
iccid19 = newCard.ICCID[:19]
}
for _, rec := range active {
if err := pcd.UpdateStatus(ctx, rec.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用旧客户绑定关系失败")
}
newPCI := &model.PersonalCustomerICCID{
CustomerID: rec.CustomerID,
ICCID: newCard.ICCID,
ICCID19: iccid19,
Status: 1,
}
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
}
}
case assetTypeDevice:
newDevice, err := s.readDevice(ctx, db, newAssetID)
if err != nil {
return err
}
newKey := newDevice.VirtualNo
if newKey == "" {
newKey = newDevice.IMEI
}
for _, rec := range active {
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newKey); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移设备客户绑定关系失败")
}
}
default:
return errors.New(errors.CodeInvalidParam)
}
return nil
}
// migrateFromPCI 将 tb_personal_customer_iccid 中 oldICCID 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, newAssetType string, newAssetID uint) error {
records, err := pci.GetByICCID(ctx, oldICCID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 绑定记录失败")
}
var active []*model.PersonalCustomerICCID
for _, r := range records {
if r.Status == 1 {
active = append(active, r)
}
}
if len(active) == 0 {
return nil
}
switch normalizeAssetType(newAssetType) {
case assetTypeIotCard:
newCard, err := s.readCard(ctx, db, newAssetID)
if err != nil {
return err
}
if newCard.VirtualNo != "" {
// 新卡有虚拟号:禁用旧 pci + 创建新 pcd
for _, rec := range active {
if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败")
}
newPCD := &model.PersonalCustomerDevice{
CustomerID: rec.CustomerID,
VirtualNo: newCard.VirtualNo,
Status: 1,
}
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
}
} else {
// 新卡也无虚拟号:禁用旧 pci + 创建新 pci新 ICCID
iccid19 := newCard.ICCID
if len(newCard.ICCID) == 20 {
iccid19 = newCard.ICCID[:19]
}
for _, rec := range active {
if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败")
}
newPCI := &model.PersonalCustomerICCID{
CustomerID: rec.CustomerID,
ICCID: newCard.ICCID,
ICCID19: iccid19,
Status: 1,
}
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新 ICCID 绑定关系失败")
}
}
}
case assetTypeDevice:
newDevice, err := s.readDevice(ctx, db, newAssetID)
if err != nil {
return err
}
newKey := newDevice.VirtualNo
if newKey == "" {
newKey = newDevice.IMEI
}
for _, rec := range active {
if err := pci.UpdateStatus(ctx, rec.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用旧 ICCID 绑定关系失败")
}
newPCD := &model.PersonalCustomerDevice{
CustomerID: rec.CustomerID,
VirtualNo: newKey,
Status: 1,
}
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
}
default:
return errors.New(errors.CodeInvalidParam)
}
return nil
}
// realMarkAssetAsSold 将资产状态从在库1更新为已销售2
func realMarkAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
now := time.Now()
switch assetType {
case assetTypeIotCard:
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Updates(map[string]any{"asset_status": 2, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败")
}
case assetTypeDevice:
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Updates(map[string]any{"asset_status": 2, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败")
}
default:
return errors.New(errors.CodeInvalidParam)
}
return nil
}

View File

@@ -0,0 +1,715 @@
package customer_binding
import (
"context"
"fmt"
"testing"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// ---- 测试工具 ----
// bindKey 构建 mock 中使用的查找键
func bindKey(customerID uint, key string) string {
return fmt.Sprintf("%d:%s", customerID, key)
}
// ---- mock 实现 ----
type mockCardReader struct {
cards map[uint]*model.IotCard
}
func (m *mockCardReader) GetByID(_ context.Context, id uint) (*model.IotCard, error) {
if c, ok := m.cards[id]; ok {
return c, nil
}
return nil, gorm.ErrRecordNotFound
}
type mockDeviceReader struct {
devices map[uint]*model.Device
}
func (m *mockDeviceReader) GetByID(_ context.Context, id uint) (*model.Device, error) {
if d, ok := m.devices[id]; ok {
return d, nil
}
return nil, gorm.ErrRecordNotFound
}
// mockPCDOps 模拟 tb_personal_customer_device 操作
type mockPCDOps struct {
// bindings["customerID:virtualNo"] = true 表示有效status=1绑定
bindings map[string]bool
created []*model.PersonalCustomerDevice
counts map[string]int64 // virtualNo → count用于首绑判断
allRecords []*model.PersonalCustomerDevice // GetByDeviceNo 和 UpdateStatus/UpdateVirtualNo 使用
}
func (m *mockPCDOps) ExistsByCustomerAndDevice(_ context.Context, customerID uint, deviceNo string) (bool, error) {
return m.bindings[bindKey(customerID, deviceNo)], nil
}
func (m *mockPCDOps) Create(_ context.Context, record *model.PersonalCustomerDevice) error {
m.created = append(m.created, record)
return nil
}
func (m *mockPCDOps) CountByVirtualNo(_ context.Context, virtualNo string) (int64, error) {
if m.counts != nil {
return m.counts[virtualNo], nil
}
return 0, nil
}
func (m *mockPCDOps) GetByDeviceNo(_ context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) {
var result []*model.PersonalCustomerDevice
for _, r := range m.allRecords {
if r.VirtualNo == deviceNo {
result = append(result, r)
}
}
return result, nil
}
func (m *mockPCDOps) UpdateStatus(_ context.Context, id uint, status int) error {
for _, r := range m.allRecords {
if r.ID == id {
r.Status = status
return nil
}
}
return nil
}
func (m *mockPCDOps) UpdateVirtualNo(_ context.Context, id uint, newVirtualNo string) error {
for _, r := range m.allRecords {
if r.ID == id {
r.VirtualNo = newVirtualNo
return nil
}
}
return nil
}
// mockPCIOps 模拟 tb_personal_customer_iccid 操作
type mockPCIOps struct {
bindings map[string]bool
created []*model.PersonalCustomerICCID
counts map[string]int64 // iccid → count
allRecords []*model.PersonalCustomerICCID // GetByICCID 和 UpdateStatus 使用
}
func (m *mockPCIOps) ExistsByCustomerAndICCID(_ context.Context, customerID uint, iccid string) (bool, error) {
return m.bindings[bindKey(customerID, iccid)], nil
}
func (m *mockPCIOps) Create(_ context.Context, record *model.PersonalCustomerICCID) error {
m.created = append(m.created, record)
return nil
}
func (m *mockPCIOps) CountByICCID(_ context.Context, iccid string) (int64, error) {
if m.counts != nil {
return m.counts[iccid], nil
}
return 0, nil
}
func (m *mockPCIOps) GetByICCID(_ context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) {
var result []*model.PersonalCustomerICCID
for _, r := range m.allRecords {
if r.ICCID == iccid {
result = append(result, r)
}
}
return result, nil
}
func (m *mockPCIOps) UpdateStatus(_ context.Context, id uint, status int) error {
for _, r := range m.allRecords {
if r.ID == id {
r.Status = status
return nil
}
}
return nil
}
// ---- 测试 Service 构造器 ----
// soldCalls 记录 markAsSold 调用
type soldCalls struct {
items []string
}
func (s *soldCalls) mark(_ context.Context, _ *gorm.DB, assetType string, _ uint) error {
s.items = append(s.items, assetType)
return nil
}
func newTestService(cards cardReader, devices deviceReader, pcd pcdOps, pci pciOps) (*Service, *soldCalls) {
sold := &soldCalls{}
return &Service{
cards: cards,
devices: devices,
readCard: func(ctx context.Context, _ *gorm.DB, id uint) (*model.IotCard, error) {
return cards.GetByID(ctx, id)
},
readDevice: func(ctx context.Context, _ *gorm.DB, id uint) (*model.Device, error) {
return devices.GetByID(ctx, id)
},
makePCD: func(_ *gorm.DB) pcdOps { return pcd },
makePCI: func(_ *gorm.DB) pciOps { return pci },
markAsSold: sold.mark,
}, sold
}
// ---- OwnsAsset 测试 ----
// 验证:有虚拟号卡 + 客户有有效绑定 → truetracer bullet
func TestOwnsAsset_有虚拟号卡_有效绑定_返回true(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN001"}
card.ID = 1
pcd := &mockPCDOps{
bindings: map[string]bool{bindKey(10, "VN001"): true},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{1: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 1)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if !owned {
t.Fatal("期望返回 true实际返回 false")
}
}
// 验证:有虚拟号卡 + status=0 的绑定 → false修复安全缺口
func TestOwnsAsset_有虚拟号卡_禁用绑定_返回false(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN002"}
card.ID = 2
pcd := &mockPCDOps{
// status=0 的绑定:在 ExistsByCustomerAndDevice 中会过滤掉bindings 中不存在)
bindings: map[string]bool{},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{2: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 2)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if owned {
t.Fatal("期望返回 falsestatus=0实际返回 true")
}
}
// 验证:有虚拟号卡 + 无绑定 → false
func TestOwnsAsset_有虚拟号卡_无绑定_返回false(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN003"}
card.ID = 3
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{3: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
&mockPCIOps{},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 3)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if owned {
t.Fatal("期望返回 false无绑定实际返回 true")
}
}
// 验证assetType="card"(来自 assetService.Resolve等价于 "iot_card"
func TestOwnsAsset_assetType_card_等价iot_card(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN_CARD"}
card.ID = 9
pcd := &mockPCDOps{
bindings: map[string]bool{bindKey(10, "VN_CARD"): true},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{9: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "card", 9)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if !owned {
t.Fatal("期望 card 类型等价 iot_card 返回 true实际 false")
}
}
// 验证:设备资产 + 有效绑定 → true
func TestOwnsAsset_设备_有效绑定_返回true(t *testing.T) {
device := &model.Device{VirtualNo: "DEV001"}
device.ID = 5
pcd := &mockPCDOps{
bindings: map[string]bool{bindKey(10, "DEV001"): true},
}
svc, _ := newTestService(
&mockCardReader{},
&mockDeviceReader{devices: map[uint]*model.Device{5: device}},
pcd,
&mockPCIOps{},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "device", 5)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if !owned {
t.Fatal("期望返回 true实际返回 false")
}
}
// ---- Bind 测试 ----
// 验证:首次绑定有虚拟号卡 → 写入 pcd 记录 + 触发 markAssetAsSold
func TestBind_有虚拟号卡_首次绑定_创建记录并标记已售(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN010"}
card.ID = 10
pcd := &mockPCDOps{
bindings: map[string]bool{},
counts: map[string]int64{"VN010": 0}, // 首次绑定
}
svc, sold := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{10: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
err := svc.Bind(context.Background(), nil, 20, "iot_card", 10)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pcd.created) != 1 {
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
}
if pcd.created[0].VirtualNo != "VN010" {
t.Errorf("期望 VirtualNo=VN010实际: %s", pcd.created[0].VirtualNo)
}
if pcd.created[0].CustomerID != 20 {
t.Errorf("期望 CustomerID=20实际: %d", pcd.created[0].CustomerID)
}
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
}
}
// 验证:重复绑定 → 不创建新记录
func TestBind_有虚拟号卡_已有绑定_不重复创建(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN011"}
card.ID = 11
pcd := &mockPCDOps{
bindings: map[string]bool{bindKey(20, "VN011"): true},
counts: map[string]int64{"VN011": 1},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{11: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
err := svc.Bind(context.Background(), nil, 20, "iot_card", 11)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pcd.created) != 0 {
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pcd.created))
}
}
// 验证:非首次绑定(已有其他客户绑定)→ 创建记录但不触发 markAssetAsSold
func TestBind_有虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
card := &model.IotCard{VirtualNo: "VN012"}
card.ID = 12
pcd := &mockPCDOps{
bindings: map[string]bool{},
counts: map[string]int64{"VN012": 1}, // 已有其他绑定
}
svc, sold := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{12: card}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
err := svc.Bind(context.Background(), nil, 30, "iot_card", 12)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pcd.created) != 1 {
t.Fatalf("期望创建 1 条记录,实际: %d", len(pcd.created))
}
if len(sold.items) != 0 {
t.Errorf("期望不触发 markAssetAsSold实际触发了: %v", sold.items)
}
}
// ---- Issue 02: 无虚拟号卡 pci 路径测试 ----
// 验证:无虚拟号卡 + 客户有有效 PCI 绑定 → true
func TestOwnsAsset_无虚拟号卡_有效PCI绑定_返回true(t *testing.T) {
card := &model.IotCard{ICCID: "89860000000000000001"} // VirtualNo 为空
card.ID = 20
pci := &mockPCIOps{
bindings: map[string]bool{bindKey(10, "89860000000000000001"): true},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{20: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
pci,
)
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 20)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if !owned {
t.Fatal("期望返回 true实际返回 false")
}
}
// 验证:无虚拟号卡 + 无 PCI 绑定 → false
func TestOwnsAsset_无虚拟号卡_无PCI绑定_返回false(t *testing.T) {
card := &model.IotCard{ICCID: "89860000000000000002"} // VirtualNo 为空
card.ID = 21
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{21: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
&mockPCIOps{bindings: map[string]bool{}},
)
owned, err := svc.OwnsAsset(context.Background(), 10, "iot_card", 21)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if owned {
t.Fatal("期望返回 false无 PCI 绑定),实际返回 true")
}
}
// 验证:无虚拟号卡首次绑定 → 写入 pci 记录 + 触发 markAssetAsSold
func TestBind_无虚拟号卡_首次绑定_创建PCI记录并标记已售(t *testing.T) {
card := &model.IotCard{ICCID: "89860000000000000010"} // VirtualNo 为空
card.ID = 30
pci := &mockPCIOps{
bindings: map[string]bool{},
counts: map[string]int64{"89860000000000000010": 0}, // 首次绑定
}
svc, sold := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{30: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
pci,
)
err := svc.Bind(context.Background(), nil, 50, "iot_card", 30)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pci.created) != 1 {
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
}
if pci.created[0].ICCID != "89860000000000000010" {
t.Errorf("期望 ICCID=89860000000000000010实际: %s", pci.created[0].ICCID)
}
if pci.created[0].CustomerID != 50 {
t.Errorf("期望 CustomerID=50实际: %d", pci.created[0].CustomerID)
}
if len(sold.items) != 1 || sold.items[0] != "iot_card" {
t.Errorf("期望触发 markAssetAsSold(iot_card),实际: %v", sold.items)
}
}
// 验证:无虚拟号卡已有绑定 → 不重复创建
func TestBind_无虚拟号卡_已有绑定_不重复创建(t *testing.T) {
card := &model.IotCard{ICCID: "89860000000000000011"} // VirtualNo 为空
card.ID = 31
pci := &mockPCIOps{
bindings: map[string]bool{bindKey(50, "89860000000000000011"): true},
counts: map[string]int64{"89860000000000000011": 1},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{31: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
pci,
)
err := svc.Bind(context.Background(), nil, 50, "iot_card", 31)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pci.created) != 0 {
t.Fatalf("期望不创建新记录,实际创建了 %d 条", len(pci.created))
}
}
// ---- Issue 03: Migrate 迁移测试 ----
// newMockPCDRecord 构建一条带 ID 的 pcd 记录(用于迁移测试)
func newMockPCDRecord(id, customerID uint, virtualNo string, status int) *model.PersonalCustomerDevice {
r := &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: virtualNo,
Status: status,
}
r.ID = id
return r
}
// newMockPCIRecord 构建一条带 ID 的 pci 记录(用于迁移测试)
func newMockPCIRecord(id, customerID uint, iccid string, status int) *model.PersonalCustomerICCID {
r := &model.PersonalCustomerICCID{
CustomerID: customerID,
ICCID: iccid,
Status: status,
}
r.ID = id
return r
}
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡有虚拟号 → pcd.virtual_no 更新为新卡虚拟号
func TestMigrate_有虚拟号旧卡_有绑定_换有虚拟号新卡_更新VirtualNo(t *testing.T) {
oldCard := &model.IotCard{VirtualNo: "OLD_VN"}
oldCard.ID = 100
newCard := &model.IotCard{VirtualNo: "NEW_VN"}
newCard.ID = 101
existing := newMockPCDRecord(1, 50, "OLD_VN", 1)
pcd := &mockPCDOps{
bindings: map[string]bool{},
allRecords: []*model.PersonalCustomerDevice{existing},
}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{100: oldCard, 101: newCard}},
&mockDeviceReader{},
pcd,
&mockPCIOps{},
)
err := svc.Migrate(context.Background(), nil, "iot_card", 100, "iot_card", 101)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if existing.VirtualNo != "NEW_VN" {
t.Errorf("期望 VirtualNo 更新为 NEW_VN实际: %s", existing.VirtualNo)
}
}
// 验证:旧卡有虚拟号 + pcd 有绑定 + 新卡无虚拟号 → 旧 pcd status=0新 pci 创建
func TestMigrate_有虚拟号旧卡_有绑定_换无虚拟号新卡_迁移到PCI(t *testing.T) {
oldCard := &model.IotCard{VirtualNo: "OLD_VN2"}
oldCard.ID = 110
newCard := &model.IotCard{ICCID: "89860000000000000099"} // 无虚拟号
newCard.ID = 111
existing := newMockPCDRecord(2, 60, "OLD_VN2", 1)
pcd := &mockPCDOps{
allRecords: []*model.PersonalCustomerDevice{existing},
}
pci := &mockPCIOps{}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{110: oldCard, 111: newCard}},
&mockDeviceReader{},
pcd,
pci,
)
err := svc.Migrate(context.Background(), nil, "iot_card", 110, "iot_card", 111)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if existing.Status != 0 {
t.Errorf("期望旧 pcd 记录 status=0实际: %d", existing.Status)
}
if len(pci.created) != 1 {
t.Fatalf("期望创建 1 条 pci 记录,实际: %d", len(pci.created))
}
if pci.created[0].CustomerID != 60 {
t.Errorf("期望 pci CustomerID=60实际: %d", pci.created[0].CustomerID)
}
if pci.created[0].ICCID != "89860000000000000099" {
t.Errorf("期望 pci ICCID=89860000000000000099实际: %s", pci.created[0].ICCID)
}
}
// 验证:旧卡有虚拟号 + pcd 无绑定 + 新卡无虚拟号 → 跳过,无写入
func TestMigrate_有虚拟号旧卡_无绑定_换无虚拟号新卡_跳过(t *testing.T) {
oldCard := &model.IotCard{VirtualNo: "OLD_VN3"}
oldCard.ID = 120
newCard := &model.IotCard{ICCID: "89860000000000000088"} // 无虚拟号
newCard.ID = 121
pcd := &mockPCDOps{allRecords: []*model.PersonalCustomerDevice{}} // 空
pci := &mockPCIOps{}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{120: oldCard, 121: newCard}},
&mockDeviceReader{},
pcd,
pci,
)
err := svc.Migrate(context.Background(), nil, "iot_card", 120, "iot_card", 121)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pci.created) != 0 {
t.Fatalf("期望无写入,实际创建了 %d 条 pci 记录", len(pci.created))
}
}
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡有虚拟号 → 旧 pci status=0新 pcd 创建
func TestMigrate_无虚拟号旧卡_有绑定_换有虚拟号新卡_迁移到PCD(t *testing.T) {
oldCard := &model.IotCard{ICCID: "89860000000000000077"} // 无虚拟号
oldCard.ID = 130
newCard := &model.IotCard{VirtualNo: "NEW_VN3"}
newCard.ID = 131
existingPCI := newMockPCIRecord(3, 70, "89860000000000000077", 1)
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
pcd := &mockPCDOps{}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{130: oldCard, 131: newCard}},
&mockDeviceReader{},
pcd,
pci,
)
err := svc.Migrate(context.Background(), nil, "iot_card", 130, "iot_card", 131)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if existingPCI.Status != 0 {
t.Errorf("期望旧 pci 记录 status=0实际: %d", existingPCI.Status)
}
if len(pcd.created) != 1 {
t.Fatalf("期望创建 1 条 pcd 记录,实际: %d", len(pcd.created))
}
if pcd.created[0].CustomerID != 70 {
t.Errorf("期望 pcd CustomerID=70实际: %d", pcd.created[0].CustomerID)
}
if pcd.created[0].VirtualNo != "NEW_VN3" {
t.Errorf("期望 pcd VirtualNo=NEW_VN3实际: %s", pcd.created[0].VirtualNo)
}
}
// 验证:旧卡无虚拟号 + pci 有绑定 + 新卡也无虚拟号 → 旧 pci status=0新 pci 创建新 ICCID
func TestMigrate_无虚拟号旧卡_有绑定_换无虚拟号新卡_迁移PCI(t *testing.T) {
oldCard := &model.IotCard{ICCID: "89860000000000000066"} // 无虚拟号
oldCard.ID = 140
newCard := &model.IotCard{ICCID: "89860000000000000055"} // 无虚拟号
newCard.ID = 141
existingPCI := newMockPCIRecord(4, 80, "89860000000000000066", 1)
pci := &mockPCIOps{allRecords: []*model.PersonalCustomerICCID{existingPCI}}
pcd := &mockPCDOps{}
svc, _ := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{140: oldCard, 141: newCard}},
&mockDeviceReader{},
pcd,
pci,
)
err := svc.Migrate(context.Background(), nil, "iot_card", 140, "iot_card", 141)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if existingPCI.Status != 0 {
t.Errorf("期望旧 pci 记录 status=0实际: %d", existingPCI.Status)
}
if len(pci.created) != 1 {
t.Fatalf("期望创建 1 条新 pci 记录,实际: %d", len(pci.created))
}
if pci.created[0].CustomerID != 80 {
t.Errorf("期望 pci CustomerID=80实际: %d", pci.created[0].CustomerID)
}
if pci.created[0].ICCID != "89860000000000000055" {
t.Errorf("期望 pci ICCID=89860000000000000055实际: %s", pci.created[0].ICCID)
}
}
// 验证:无虚拟号卡非首次绑定(已有其他客户)→ 创建记录但不触发 markAssetAsSold
func TestBind_无虚拟号卡_非首次绑定_创建记录不标记已售(t *testing.T) {
card := &model.IotCard{ICCID: "89860000000000000012"} // VirtualNo 为空
card.ID = 32
pci := &mockPCIOps{
bindings: map[string]bool{},
counts: map[string]int64{"89860000000000000012": 1}, // 已有其他客户绑定
}
svc, sold := newTestService(
&mockCardReader{cards: map[uint]*model.IotCard{32: card}},
&mockDeviceReader{},
&mockPCDOps{bindings: map[string]bool{}},
pci,
)
err := svc.Bind(context.Background(), nil, 60, "iot_card", 32)
if err != nil {
t.Fatalf("期望无错误,实际: %v", err)
}
if len(pci.created) != 1 {
t.Fatalf("期望创建 1 条记录,实际: %d", len(pci.created))
}
if len(sold.items) != 0 {
t.Errorf("期望不触发 markAssetAsSold实际触发了: %v", sold.items)
}
}

View File

@@ -5,6 +5,7 @@ import (
"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"
@@ -26,7 +27,7 @@ type Service struct {
packageUsageStore *postgres.PackageUsageStore
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
resourceTagStore *postgres.ResourceTagStore
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore
customerBinding *customerBindingSvc.Service
logger *zap.Logger
}
@@ -40,7 +41,7 @@ func New(
packageUsageStore *postgres.PackageUsageStore,
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore,
resourceTagStore *postgres.ResourceTagStore,
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore,
customerBinding *customerBindingSvc.Service,
logger *zap.Logger,
) *Service {
return &Service{
@@ -53,7 +54,7 @@ func New(
packageUsageStore: packageUsageStore,
packageUsageDailyRecordStore: packageUsageDailyRecordStore,
resourceTagStore: resourceTagStore,
personalCustomerDeviceStore: personalCustomerDeviceStore,
customerBinding: customerBinding,
logger: logger,
}
}
@@ -802,19 +803,8 @@ func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB,
}
func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
oldKey := exchangeAssetBindingKey(oldAsset)
newKey := exchangeAssetBindingKey(newAsset)
oldBindCount := int64(0)
if oldKey != "" {
if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ? AND status = ?", oldKey, constants.StatusEnabled).
Count(&oldBindCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产客户绑定失败")
}
}
if oldBindCount > 0 && newKey == "" {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产无法承接客户绑定")
}
// 新资产无虚拟号时不再拦截Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形
if newKey == "" {
return nil
}
@@ -831,20 +821,7 @@ func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *
}
func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
oldKey := exchangeAssetBindingKey(oldAsset)
if oldKey == "" {
return nil
}
newKey := exchangeAssetBindingKey(newAsset)
if newKey == "" {
return errors.New(errors.CodeExchangeStatusInvalid, "新资产无法承接客户绑定")
}
if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ? AND status = ?", oldKey, constants.StatusEnabled).
Updates(map[string]any{"virtual_no": newKey, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新客户绑定关系失败")
}
return nil
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 {

View File

@@ -78,6 +78,14 @@ func (s *PersonalCustomerDeviceStore) UpdateStatus(ctx context.Context, id uint,
Update("status", status).Error
}
// UpdateVirtualNo 更新指定记录的虚拟号(换货迁移专用)
func (s *PersonalCustomerDeviceStore) UpdateVirtualNo(ctx context.Context, id uint, newVirtualNo string) error {
return s.db.WithContext(ctx).
Model(&model.PersonalCustomerDevice{}).
Where("id = ?", id).
Update("virtual_no", newVirtualNo).Error
}
// Delete 软删除绑定记录
func (s *PersonalCustomerDeviceStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.PersonalCustomerDevice{}, id).Error
@@ -95,6 +103,18 @@ func (s *PersonalCustomerDeviceStore) ExistsByCustomerAndDevice(ctx context.Cont
return count > 0, nil
}
// CountByVirtualNo 统计指定虚拟号的绑定记录数量(不过滤 status
func (s *PersonalCustomerDeviceStore) CountByVirtualNo(ctx context.Context, virtualNo string) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).
Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ?", virtualNo).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间
// 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录
func (s *PersonalCustomerDeviceStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, deviceNo string) error {

View File

@@ -116,6 +116,25 @@ func (s *PersonalCustomerICCIDStore) ExistsByCustomerAndICCID(ctx context.Contex
return count > 0, nil
}
// CountByICCID 统计指定 ICCID 的绑定记录数量(不过滤 status
func (s *PersonalCustomerICCIDStore) CountByICCID(ctx context.Context, iccid string) (int64, error) {
var column string
switch len(iccid) {
case 19:
column = "iccid_19"
default:
column = "iccid"
}
var count int64
if err := s.db.WithContext(ctx).
Model(&model.PersonalCustomerICCID{}).
Where(column+" = ?", iccid).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间
// 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录
func (s *PersonalCustomerICCIDStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, iccid string) error {