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

474 lines
15 KiB
Go
Raw Permalink 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 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
}