关于绑定的问题
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

@@ -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)
}
}