Files
junhong_cmp_fiber/internal/service/customer_binding/service.go
邹进 9ced14fa1b fix: 第1批 F1、M12 一资产一号与换货按手机号关联校验
- F1 一个资产同时只能有一个有效手机号关联,他号绑定返回 1192(不含号码),同号幂等
- 登录 need_bind_phone 改看资产是否已有有效关联,新增 asset_phone_bound、asset_phone_is_own;H5 绑定按客户/资产两侧状态分别写入
- 客户换号不再迁移资产关联
- M12 换货新资产有有效手机号关联即拒绝,号码只在 data.phone;换货完成清空旧资产客户绑定并失效旧资产关联(exchange_completed),均不迁移
- OpenSpec Change enforce-single-phone-per-asset:11/16,H5 短信与换货端到端验证待测试部署后完成
2026-10-12 00:10:41 +09:00

475 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package customer_binding 封装客户与资产之间的绑定创建和归属验证逻辑
// 屏蔽"有虚拟号走 tb_personal_customer_device / 无虚拟号走 tb_personal_customer_iccid"的路由细节
package customer_binding
import (
"context"
"sort"
"time"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
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)
accessAudit accessauditapp.Writer
}
// 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)
}
// ActiveCustomerIDsByAsset 返回当前启用且关联指定资产的个人客户 ID,结果去重并稳定排序。
func (s *Service) ActiveCustomerIDsByAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) ([]uint, error) {
if tx == nil {
tx = s.db
}
customerIDs := make(map[uint]struct{})
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, assetID)
if err != nil {
return nil, err
}
if card.VirtualNo != "" {
records, err := s.makePCD(tx).GetByDeviceNo(ctx, card.VirtualNo)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败")
}
collectActiveDeviceCustomerIDs(customerIDs, records)
} else {
records, err := s.makePCI(tx).GetByICCID(ctx, card.ICCID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败")
}
collectActiveCardCustomerIDs(customerIDs, records)
}
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
if err != nil {
return nil, err
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
records, err := s.makePCD(tx).GetByDeviceNo(ctx, identifier)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备关联客户失败")
}
collectActiveDeviceCustomerIDs(customerIDs, records)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
result := make([]uint, 0, len(customerIDs))
for customerID := range customerIDs {
result = append(result, customerID)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result, nil
}
func collectActiveDeviceCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerDevice) {
for _, record := range records {
if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 {
target[record.CustomerID] = struct{}{}
}
}
}
func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerICCID) {
for _, record := range records {
if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 {
target[record.CustomerID] = struct{}{}
}
}
}
// Bind 在事务中创建客户与资产的绑定关系
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccid(Issue 02)
func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
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, card, nil)
}
// 无虚拟号路径(Issue 02)
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID, card)
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, nil, device)
}
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, card *model.IotCard, device *model.Device) 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, "查询客户资产绑定关系失败")
}
var record *model.PersonalCustomerDevice
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 {
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
if record == nil {
return nil
}
cards := []accessauditapp.IotCardChange(nil)
devices := []accessauditapp.DeviceChange(nil)
if card != nil {
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
if device != nil {
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
}}, nil, cards, devices)
}
// 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, card *model.IotCard) 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 绑定关系失败")
}
var record *model.PersonalCustomerICCID
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 {
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
if record == nil {
return nil
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
nil, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"iccid": record.ICCID, "status": record.Status},
}}, []accessauditapp.IotCardChange{
cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil),
}, nil)
}
// ClearForExchange 换货完成时在同一事务内把旧资产的全部有效个人客户绑定置为无效,不迁移到新资产。
// 定位口径同原换货迁移:卡的虚拟号绑定与 ICCID 绑定两边都清;设备按虚拟号,无虚拟号时按 IMEI。
// 原客户之后重新扫码旧资产仍可按既有登录规则重新建立绑定。
func (s *Service) ClearForExchange(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
switch normalizeAssetType(oldAssetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, oldAssetID)
if err != nil {
return err
}
assetChange := []accessauditapp.IotCardChange{cardAuditChange(card, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerOldAsset, nil, nil)}
if card.VirtualNo != "" {
if err := s.clearDeviceBindings(ctx, tx, card.VirtualNo, assetChange, nil); err != nil {
return err
}
}
return s.clearICCIDBindings(ctx, tx, card.ICCID, assetChange)
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, oldAssetID)
if err != nil {
return err
}
key := device.VirtualNo
if key == "" {
key = device.IMEI
}
if key == "" {
return nil
}
assetChange := []accessauditapp.DeviceChange{deviceAuditChange(device, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerOldAsset, nil, nil)}
return s.clearDeviceBindings(ctx, tx, key, nil, assetChange)
}
return errors.New(errors.CodeInvalidParam)
}
// clearDeviceBindings 把按虚拟号(或 IMEI)定位的有效绑定置为无效并逐条写审计。
func (s *Service) clearDeviceBindings(ctx context.Context, tx *gorm.DB, key string, cards []accessauditapp.IotCardChange, devices []accessauditapp.DeviceChange) error {
pcd := s.makePCD(tx)
records, err := pcd.GetByDeviceNo(ctx, key)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧资产客户绑定失败")
}
for _, record := range records {
if record == nil || record.Status != 1 {
continue
}
if err := pcd.UpdateStatus(ctx, record.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "清空旧资产客户绑定失败")
}
if err := s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetUnbound, "换货完成清空旧资产个人客户绑定", record.CustomerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
AfterData: map[string]any{"virtual_no": record.VirtualNo, "status": 0},
}}, nil, cards, devices,
); err != nil {
return err
}
}
return nil
}
// clearICCIDBindings 把按 ICCID 定位的有效绑定置为无效并逐条写审计。
func (s *Service) clearICCIDBindings(ctx context.Context, tx *gorm.DB, iccid string, cards []accessauditapp.IotCardChange) error {
if iccid == "" {
return nil
}
pci := s.makePCI(tx)
records, err := pci.GetByICCID(ctx, iccid)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 客户绑定失败")
}
for _, record := range records {
if record == nil || record.Status != 1 {
continue
}
if err := pci.UpdateStatus(ctx, record.ID, 0); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "清空旧 ICCID 客户绑定失败")
}
if err := s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetUnbound, "换货完成清空旧资产个人客户绑定", record.CustomerID,
nil, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": record.ICCID, "status": record.Status},
AfterData: map[string]any{"iccid": record.ICCID, "status": 0},
}}, cards, nil,
); err != nil {
return err
}
}
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
}