分裂iccid长度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
This commit is contained in:
@@ -188,7 +188,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.Logger,
|
||||
),
|
||||
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
|
||||
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization),
|
||||
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
|
||||
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
|
||||
IotCard: iotCard,
|
||||
|
||||
@@ -55,6 +55,11 @@ type IotCard struct {
|
||||
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
|
||||
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
|
||||
|
||||
// ICCID 双列存储,用于支持 19/20 位 ICCID 精确路由查询
|
||||
// 所有卡必须有 ICCID19;仅 20 位运营商卡有 ICCID20(19 位卡为 nil → 数据库 NULL)
|
||||
ICCID19 string `gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位(所有卡必填,用于19位运营商精确查询)" json:"iccid_19,omitempty"`
|
||||
ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值,19位卡为NULL)" json:"iccid_20,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -15,6 +15,7 @@ type PersonalCustomerICCID struct {
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ICCID19 string `gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位(用于19位运营商精确查询)" json:"iccid_19,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -17,17 +17,20 @@ type Service struct {
|
||||
db *gorm.DB
|
||||
enterpriseStore *postgres.EnterpriseStore
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
}
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
enterpriseStore *postgres.EnterpriseStore,
|
||||
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
enterpriseStore: enterpriseStore,
|
||||
enterpriseCardAuthStore: enterpriseCardAuthStore,
|
||||
iotCardStore: iotCardStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,21 +45,18 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
|
||||
var iotCards []model.IotCard
|
||||
if err := s.db.WithContext(ctx).Where("iccid IN ?", req.ICCIDs).Find(&iotCards).Error; err != nil {
|
||||
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
cardMap := make(map[string]*model.IotCard)
|
||||
cardIDMap := make(map[uint]*model.IotCard)
|
||||
for i := range iotCards {
|
||||
cardMap[iotCards[i].ICCID] = &iotCards[i]
|
||||
cardIDMap[iotCards[i].ID] = &iotCards[i]
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(iotCards))
|
||||
for _, card := range iotCards {
|
||||
cardIDs = append(cardIDs, card.ID)
|
||||
cardIDs := make([]uint, 0, len(iotCardPtrs))
|
||||
for _, c := range iotCardPtrs {
|
||||
cardMap[c.ICCID] = c
|
||||
cardIDMap[c.ID] = c
|
||||
cardIDs = append(cardIDs, c.ID)
|
||||
}
|
||||
|
||||
var bindings []model.DeviceSimBinding
|
||||
@@ -181,18 +181,18 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
|
||||
var iotCards []model.IotCard
|
||||
if err := s.db.WithContext(ctx).Where("iccid IN ?", req.ICCIDs).Find(&iotCards).Error; err != nil {
|
||||
iotCardPtrs, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
cardMap := make(map[string]*model.IotCard)
|
||||
cardIDMap := make(map[uint]*model.IotCard)
|
||||
cardIDs := make([]uint, 0, len(iotCards))
|
||||
for i := range iotCards {
|
||||
cardMap[iotCards[i].ICCID] = &iotCards[i]
|
||||
cardIDMap[iotCards[i].ID] = &iotCards[i]
|
||||
cardIDs = append(cardIDs, iotCards[i].ID)
|
||||
cardIDs := make([]uint, 0, len(iotCardPtrs))
|
||||
for _, c := range iotCardPtrs {
|
||||
cardMap[c.ICCID] = c
|
||||
cardIDMap[c.ID] = c
|
||||
cardIDs = append(cardIDs, c.ID)
|
||||
}
|
||||
|
||||
existingAuths, err := s.enterpriseCardAuthStore.GetActiveAuthsByCardIDs(ctx, enterpriseID, cardIDs)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -183,21 +185,46 @@ func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var bindings []struct {
|
||||
ICCID string
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("tb_device_sim_binding b").
|
||||
Select("c.iccid").
|
||||
Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id").
|
||||
Where("c.iccid IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", iccids).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
var list19, list20 []string
|
||||
for _, id := range iccids {
|
||||
switch len(id) {
|
||||
case 19:
|
||||
list19 = append(list19, id)
|
||||
case 20:
|
||||
list20 = append(list20, id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, b := range bindings {
|
||||
result[b.ICCID] = true
|
||||
if len(list19) > 0 {
|
||||
var rows []struct{ ICCID string }
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("tb_device_sim_binding b").
|
||||
Select("c.iccid_19 AS iccid").
|
||||
Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id").
|
||||
Where("c.iccid_19 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", list19).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.ICCID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(list20) > 0 {
|
||||
var rows []struct{ ICCID string }
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("tb_device_sim_binding b").
|
||||
Select("c.iccid_20 AS iccid").
|
||||
Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id").
|
||||
Where("c.iccid_20 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", list20).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.ICCID] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -206,27 +233,44 @@ func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []str
|
||||
// currentIccid 为空字符串时,仅执行全部清空(无当前卡的情况)
|
||||
func (s *DeviceSimBindingStore) UpdateIsCurrentByDeviceID(ctx context.Context, deviceID uint, currentIccid string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 第一步:将该设备所有活跃绑定的 is_current 设为 false
|
||||
if err := tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第二步:将当前 ICCID 对应的绑定记录设为 is_current = true
|
||||
if currentIccid == "" {
|
||||
return nil
|
||||
}
|
||||
// 通过子查询找到对应的 iot_card_id(避免跨表 JOIN 更新)
|
||||
|
||||
var column string
|
||||
switch len(currentIccid) {
|
||||
case 19:
|
||||
column = "iccid_19"
|
||||
case 20:
|
||||
column = "iccid_20"
|
||||
default:
|
||||
logger.GetAppLogger().Error("UpdateIsCurrentByDeviceID: ICCID 长度异常,跳过 is_current 更新",
|
||||
zap.String("iccid", currentIccid),
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Int("length", len(currentIccid)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
var iotCardID uint
|
||||
if err := tx.Table("tb_iot_card").
|
||||
Select("id").
|
||||
Where("iccid = ? AND deleted_at IS NULL", currentIccid).
|
||||
Where(column+" = ? AND deleted_at IS NULL", currentIccid).
|
||||
Scan(&iotCardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if iotCardID == 0 {
|
||||
// 未找到对应卡记录,不更新(可能卡尚未入库)
|
||||
logger.GetAppLogger().Warn("UpdateIsCurrentByDeviceID: 未命中卡记录,is_current 不更新",
|
||||
zap.String("iccid", currentIccid),
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.String("column_used", column),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.DeviceSimBinding{}).
|
||||
|
||||
@@ -56,13 +56,30 @@ func (s *IotCardStore) GetByID(ctx context.Context, id uint) (*model.IotCard, er
|
||||
|
||||
func (s *IotCardStore) GetByICCID(ctx context.Context, iccid string) (*model.IotCard, error) {
|
||||
var card model.IotCard
|
||||
query := s.db.WithContext(ctx).Where("iccid = ?", iccid)
|
||||
// 应用数据权限过滤
|
||||
var column string
|
||||
switch len(iccid) {
|
||||
case 19:
|
||||
column = "iccid_19"
|
||||
case 20:
|
||||
column = "iccid_20"
|
||||
default:
|
||||
logger.GetAppLogger().Error("GetByICCID: ICCID 长度异常,拒绝查询",
|
||||
zap.String("iccid", iccid),
|
||||
zap.Int("length", len(iccid)),
|
||||
)
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
query := s.db.WithContext(ctx).Where(column+" = ?", iccid)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.First(&card).Error; err != nil {
|
||||
err := query.First(&card).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
logger.GetAppLogger().Warn("GetByICCID: 未命中,上游 ICCID 格式可能与存储不一致",
|
||||
zap.String("iccid", iccid),
|
||||
zap.String("column_used", column),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
return &card, nil
|
||||
return &card, err
|
||||
}
|
||||
|
||||
func (s *IotCardStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.IotCard, error) {
|
||||
@@ -80,8 +97,21 @@ func (s *IotCardStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.IotCa
|
||||
}
|
||||
|
||||
func (s *IotCardStore) ExistsByICCID(ctx context.Context, iccid string) (bool, error) {
|
||||
var column string
|
||||
switch len(iccid) {
|
||||
case 19:
|
||||
column = "iccid_19"
|
||||
case 20:
|
||||
column = "iccid_20"
|
||||
default:
|
||||
logger.GetAppLogger().Error("ExistsByICCID: ICCID 长度异常,拒绝查询",
|
||||
zap.String("iccid", iccid),
|
||||
zap.Int("length", len(iccid)),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("iccid = ?", iccid).Count(&count).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where(column+" = ?", iccid).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
@@ -92,17 +122,42 @@ func (s *IotCardStore) ExistsByICCIDBatch(ctx context.Context, iccids []string)
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
|
||||
var existingICCIDs []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("iccid IN ?", iccids).
|
||||
Pluck("iccid", &existingICCIDs).Error; err != nil {
|
||||
return nil, err
|
||||
var list19, list20 []string
|
||||
for _, id := range iccids {
|
||||
switch len(id) {
|
||||
case 19:
|
||||
list19 = append(list19, id)
|
||||
case 20:
|
||||
list20 = append(list20, id)
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[string]bool)
|
||||
for _, iccid := range existingICCIDs {
|
||||
result[iccid] = true
|
||||
|
||||
if len(list19) > 0 {
|
||||
var found []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("iccid_19 IN ?", list19).
|
||||
Pluck("iccid_19", &found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range found {
|
||||
result[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(list20) > 0 {
|
||||
var found []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("iccid_20 IN ?", list20).
|
||||
Pluck("iccid_20", &found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range found {
|
||||
result[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -689,14 +744,51 @@ func (s *IotCardStore) GetByICCIDs(ctx context.Context, iccids []string) ([]*mod
|
||||
if len(iccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var cards []*model.IotCard
|
||||
query := s.db.WithContext(ctx).Where("iccid IN ?", iccids)
|
||||
// 应用数据权限过滤
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.Find(&cards).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
var list19, list20 []string
|
||||
for _, id := range iccids {
|
||||
switch len(id) {
|
||||
case 19:
|
||||
list19 = append(list19, id)
|
||||
case 20:
|
||||
list20 = append(list20, id)
|
||||
}
|
||||
}
|
||||
return cards, nil
|
||||
|
||||
seen := make(map[uint]struct{})
|
||||
var all []*model.IotCard
|
||||
|
||||
if len(list19) > 0 {
|
||||
var cards []*model.IotCard
|
||||
query := s.db.WithContext(ctx).Where("iccid_19 IN ?", list19)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.Find(&cards).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cards {
|
||||
if _, dup := seen[c.ID]; !dup {
|
||||
seen[c.ID] = struct{}{}
|
||||
all = append(all, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(list20) > 0 {
|
||||
var cards []*model.IotCard
|
||||
query := s.db.WithContext(ctx).Where("iccid_20 IN ?", list20)
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
if err := query.Find(&cards).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cards {
|
||||
if _, dup := seen[c.ID]; !dup {
|
||||
seen[c.ID] = struct{}{}
|
||||
all = append(all, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) GetStandaloneByICCIDRange(ctx context.Context, iccidStart, iccidEnd string, shopID *uint) ([]*model.IotCard, error) {
|
||||
|
||||
@@ -41,9 +41,16 @@ func (s *PersonalCustomerICCIDStore) GetByCustomerID(ctx context.Context, custom
|
||||
|
||||
// GetByICCID 根据 ICCID 获取所有绑定记录(查询哪些用户使用过这个 ICCID)
|
||||
func (s *PersonalCustomerICCIDStore) GetByICCID(ctx context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) {
|
||||
var column string
|
||||
switch len(iccid) {
|
||||
case 19:
|
||||
column = "iccid_19"
|
||||
default:
|
||||
column = "iccid"
|
||||
}
|
||||
var records []*model.PersonalCustomerICCID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iccid = ?", iccid).
|
||||
Where(column+" = ?", iccid).
|
||||
Order("last_used_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -53,9 +60,16 @@ func (s *PersonalCustomerICCIDStore) GetByICCID(ctx context.Context, iccid strin
|
||||
|
||||
// GetByCustomerAndICCID 根据客户 ID 和 ICCID 获取绑定记录
|
||||
func (s *PersonalCustomerICCIDStore) GetByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (*model.PersonalCustomerICCID, error) {
|
||||
var column string
|
||||
switch len(iccid) {
|
||||
case 19:
|
||||
column = "iccid_19"
|
||||
default:
|
||||
column = "iccid"
|
||||
}
|
||||
var record model.PersonalCustomerICCID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ? AND iccid = ?", customerID, iccid).
|
||||
Where("customer_id = ? AND "+column+" = ?", customerID, iccid).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,10 +99,17 @@ func (s *PersonalCustomerICCIDStore) Delete(ctx context.Context, id uint) error
|
||||
|
||||
// ExistsByCustomerAndICCID 检查客户是否已绑定该 ICCID
|
||||
func (s *PersonalCustomerICCIDStore) ExistsByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (bool, 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("customer_id = ? AND iccid = ? AND status = ?", customerID, iccid, 1).
|
||||
Where("customer_id = ? AND "+column+" = ? AND status = ?", customerID, iccid, 1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -100,17 +121,20 @@ func (s *PersonalCustomerICCIDStore) ExistsByCustomerAndICCID(ctx context.Contex
|
||||
func (s *PersonalCustomerICCIDStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, iccid string) error {
|
||||
record, err := s.GetByCustomerAndICCID(ctx, customerID, iccid)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 不存在,创建新记录
|
||||
iccid19 := iccid
|
||||
if len(iccid) == 20 {
|
||||
iccid19 = iccid[:19]
|
||||
}
|
||||
newRecord := &model.PersonalCustomerICCID{
|
||||
CustomerID: customerID,
|
||||
ICCID: iccid,
|
||||
Status: 1, // 启用
|
||||
ICCID19: iccid19,
|
||||
Status: 1,
|
||||
}
|
||||
return s.Create(ctx, newRecord)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 存在,更新最后使用时间
|
||||
return s.UpdateLastUsedAt(ctx, record.ID)
|
||||
}
|
||||
|
||||
@@ -345,8 +345,28 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
|
||||
for _, card := range finalCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
|
||||
// 拆分 ICCID 为双列存储值(长度校验已在 ValidateICCID 通过,此处作防御性保留)
|
||||
iccid19, iccid20 := utils.SplitICCID(card.ICCID)
|
||||
if iccid19 == "" {
|
||||
h.logger.Error("ICCID 长度异常,跳过写入",
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Int("length", len(card.ICCID)),
|
||||
)
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "ICCID 长度异常,无法拆分",
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
iotCard := &model.IotCard{
|
||||
ICCID: card.ICCID,
|
||||
ICCID19: iccid19,
|
||||
ICCID20: iccid20,
|
||||
MSISDN: card.MSISDN,
|
||||
VirtualNo: card.VirtualNo,
|
||||
CarrierID: task.CarrierID,
|
||||
|
||||
7
migrations/000131_add_iccid_dual_column.down.sql
Normal file
7
migrations/000131_add_iccid_dual_column.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_personal_customer_iccid_19;
|
||||
ALTER TABLE tb_personal_customer_iccid DROP COLUMN IF EXISTS iccid_19;
|
||||
|
||||
DROP INDEX IF EXISTS idx_iot_card_iccid_20;
|
||||
DROP INDEX IF EXISTS idx_iot_card_iccid_19;
|
||||
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS iccid_20;
|
||||
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS iccid_19;
|
||||
97
migrations/000131_add_iccid_dual_column.up.sql
Normal file
97
migrations/000131_add_iccid_dual_column.up.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 目的:支持上游 IoT 平台传入 19/20 位 ICCID 均能精确命中卡记录
|
||||
-- 背景:部分运营商 19 位为唯一标识,部分运营商需要完整 20 位才能唯一标识一张卡
|
||||
-- 策略:双列存储 + 按上游传入长度路由查询,Miss 时记录 Warn 日志不降级
|
||||
|
||||
-- 注意:golang-migrate 默认在事务中运行,CREATE INDEX CONCURRENTLY 不能在事务中执行
|
||||
-- 因此使用 CREATE INDEX IF NOT EXISTS(非 CONCURRENTLY)
|
||||
-- 生产环境若需零停机建索引,可手动执行 CONCURRENTLY 版本
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1. tb_iot_card 新增双列
|
||||
-- ==========================================================================
|
||||
|
||||
ALTER TABLE tb_iot_card
|
||||
ADD COLUMN IF NOT EXISTS iccid_19 varchar(19),
|
||||
ADD COLUMN IF NOT EXISTS iccid_20 varchar(20);
|
||||
|
||||
COMMENT ON COLUMN tb_iot_card.iccid_19 IS 'ICCID前19位(所有卡必填,用于19位运营商精确查询)';
|
||||
COMMENT ON COLUMN tb_iot_card.iccid_20 IS '完整20位ICCID(仅20位运营商卡有值,19位卡为NULL)';
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1.1a 冲突检测(执行后人工确认均为 0 行,否则停止迁移人工处理)
|
||||
-- 检测 19 位前缀重复:若 19 位前缀不唯一,回填后 iccid_19 无法作为唯一索引列
|
||||
-- ==========================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
conflict_count INTEGER;
|
||||
abnormal_count INTEGER;
|
||||
BEGIN
|
||||
-- 检测 19 位前缀重复
|
||||
SELECT COUNT(*) INTO conflict_count
|
||||
FROM (
|
||||
SELECT LEFT(iccid, 19)
|
||||
FROM tb_iot_card
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY LEFT(iccid, 19)
|
||||
HAVING COUNT(*) > 1
|
||||
) t;
|
||||
|
||||
IF conflict_count > 0 THEN
|
||||
RAISE EXCEPTION '冲突检测失败:发现 % 组 19 位前缀重复的 ICCID,请人工处理后再执行迁移', conflict_count;
|
||||
END IF;
|
||||
|
||||
-- 检测异常长度 ICCID(长度既非 19 也非 20 的记录)
|
||||
SELECT COUNT(*) INTO abnormal_count
|
||||
FROM tb_iot_card
|
||||
WHERE deleted_at IS NULL
|
||||
AND LENGTH(iccid) NOT IN (19, 20);
|
||||
|
||||
IF abnormal_count > 0 THEN
|
||||
RAISE WARNING '警告:发现 % 条异常长度 ICCID 记录(长度非19也非20),回填后这些卡将无法通过新查询路径命中', abnormal_count;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1.2 回填存量数据
|
||||
-- iccid_19 = LEFT(iccid, 19)(所有卡)
|
||||
-- iccid_20 = iccid(仅当 LENGTH(iccid)=20,否则 NULL)
|
||||
-- ==========================================================================
|
||||
|
||||
UPDATE tb_iot_card
|
||||
SET iccid_19 = LEFT(iccid, 19),
|
||||
iccid_20 = CASE WHEN LENGTH(iccid) = 20 THEN iccid ELSE NULL END;
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1.3 为 iccid_19 创建 Partial Index(过滤软删除记录)
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19
|
||||
ON tb_iot_card (iccid_19)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1.4 为 iccid_20 创建 Partial Index(过滤软删除和 NULL 记录)
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20
|
||||
ON tb_iot_card (iccid_20)
|
||||
WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
|
||||
|
||||
-- ==========================================================================
|
||||
-- 1.5 tb_personal_customer_iccid 新增 iccid_19 列
|
||||
-- ==========================================================================
|
||||
|
||||
ALTER TABLE tb_personal_customer_iccid
|
||||
ADD COLUMN IF NOT EXISTS iccid_19 varchar(19);
|
||||
|
||||
COMMENT ON COLUMN tb_personal_customer_iccid.iccid_19 IS 'ICCID前19位(用于19位运营商精确查询)';
|
||||
|
||||
-- 回填存量数据
|
||||
UPDATE tb_personal_customer_iccid
|
||||
SET iccid_19 = LEFT(iccid, 19);
|
||||
|
||||
-- 创建对应 Partial Index
|
||||
CREATE INDEX IF NOT EXISTS idx_personal_customer_iccid_19
|
||||
ON tb_personal_customer_iccid (iccid_19)
|
||||
WHERE deleted_at IS NULL;
|
||||
2
openspec/changes/iccid-dual-column-lookup/.openspec.yaml
Normal file
2
openspec/changes/iccid-dual-column-lookup/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-20
|
||||
186
openspec/changes/iccid-dual-column-lookup/design.md
Normal file
186
openspec/changes/iccid-dual-column-lookup/design.md
Normal file
@@ -0,0 +1,186 @@
|
||||
## Context
|
||||
|
||||
`tb_iot_card.iccid` 是 varchar(20) 的全局唯一字段,所有精确查询均基于它。物联网平台上有两类运营商:
|
||||
|
||||
- **19 位运营商**(如中国电信):ICCID 本身为 19 位,是唯一标识符
|
||||
- **20 位运营商**(如移动部分号段):ICCID 需要完整 20 位才唯一,第 20 位是真实业务数据而非 Luhn 校验码
|
||||
|
||||
上游 IoT 平台返回的 ICCID **格式不固定**:同一张卡,不同时候可能传回 19 位或 20 位。当前代码使用 `WHERE iccid = ?` 精确匹配,格式不一致时静默失败(`iotCardID == 0`,函数返回 nil),导致 `is_current` 状态更新无效。这是不可观测的数据错乱。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 无论上游传入 19 位还是 20 位,均能正确命中对应卡记录
|
||||
- 查询性能不降级:每次查询仍走单列精确索引,不引入模糊查询或 OR 条件
|
||||
- Miss 可观测:上游格式与存储不一致时,记录 Warn 日志暴露数据质量问题
|
||||
- 覆盖 `tb_personal_customer_iccid` 的同类问题
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修复上游数据质量问题(这是运营侧职责)
|
||||
- 不实现跨列降级匹配(19 位 Miss 不去查 20 位列,防止错误命中)
|
||||
- 不修改 ICCID 的展示逻辑和 Response DTO
|
||||
- 不影响模糊查询(LIKE)和范围查询(`>=` / `<=`),这类查询语义上不依赖唯一性
|
||||
- 不改造个人客户发起的 ICCID 查询路径(`client_auth/service.go`、`exchange/service.go`、`asset/service.go` 中的 `OR iccid = ?` 条件):这三处查询的 ICCID 来自个人客户手动输入(扫码/手动填写),输入格式与数据库存储格式一致,不存在上游平台的 19/20 位不一致问题;其架构违规(Service 层直接写 SQL)留待专项重构处理
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:双列存储(iccid_19 / iccid_20)而非前缀 LIKE
|
||||
|
||||
**选项 A(选定)**:新增 `iccid_19 varchar(19)` 和 `iccid_20 varchar(20)`,分别建 Partial Index。查询时按上游传入长度路由。
|
||||
|
||||
**选项 B(否决)**:保留原列,所有精确查询改用 `iccid LIKE 'prefix%'`。
|
||||
|
||||
否决原因:
|
||||
- PostgreSQL 在非 C locale 下 B-tree 索引不支持 LIKE,需要额外 `varchar_pattern_ops` 索引
|
||||
- LIKE 仍是范围扫描,效率低于等值查找
|
||||
- 不解决 20 位运营商 19 位前缀不唯一的根本问题(可能错误命中另一张卡)
|
||||
|
||||
**选项 C(否决)**:全量标准化为 19 位存储。
|
||||
|
||||
否决原因:部分运营商的 19 位前缀不唯一,截断 20 位 ICCID 会造成数据冲突,存量迁移存在数据丢失风险。
|
||||
|
||||
---
|
||||
|
||||
### 决策 2:按长度路由,禁止跨列降级
|
||||
|
||||
```
|
||||
len(upstream_iccid) == 19 → WHERE iccid_19 = ?
|
||||
len(upstream_iccid) == 20 → WHERE iccid_20 = ?
|
||||
其他长度 → 拒绝,记录 Error 日志
|
||||
Miss 时 → 记录 Warn 日志,不更新数据,不降级查另一列
|
||||
```
|
||||
|
||||
**禁止降级的原因**:若 20 位上游 miss 后降级查 iccid_19(前 19 位),可能命中另一个运营商的 19 位卡,造成 `is_current` 被更新到错误卡上——**错误命中比 Miss 危险得多,且不可观测**。
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:Partial Index 而非全量索引
|
||||
|
||||
```sql
|
||||
-- iccid_19:所有卡都有值,过滤软删除
|
||||
CREATE INDEX idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
|
||||
|
||||
-- iccid_20:仅 20 位卡有值,额外过滤 NULL
|
||||
CREATE INDEX idx_iot_card_iccid_20 ON tb_iot_card (iccid_20)
|
||||
WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
|
||||
```
|
||||
|
||||
Partial Index 体积更小(排除软删除记录和 NULL 记录),查询效率更高。
|
||||
|
||||
---
|
||||
|
||||
### 决策 3.5:`iccid_20` 模型字段必须使用指针类型 `*string`
|
||||
|
||||
Go 中 `string` 类型的零值是 `""`(空字符串)。GORM 不会将空字符串自动映射为 SQL `NULL`,写入结果是 `''`,导致:
|
||||
|
||||
1. Partial Index `WHERE iccid_20 IS NOT NULL` 会把所有 19 位卡行纳入索引,体积膨胀,优化失效
|
||||
2. 任何 `WHERE iccid_20 = ?` 查询不受影响(`'' != 任何合法 ICCID`),但 miss 率虚高,触发大量误 Warn 告警
|
||||
|
||||
**正确声明**:
|
||||
```go
|
||||
ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`
|
||||
```
|
||||
|
||||
`SplitICCID` 工具函数签名须对应:
|
||||
```go
|
||||
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
|
||||
// 19 位:返回 (iccid, nil)
|
||||
// 20 位:返回 (iccid[:19], &iccid)
|
||||
// 其他:返回 ("", nil)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:写入路径在 Task 层赋值,不修改 Store 层
|
||||
|
||||
IotCard 记录只有一个创建入口(`iot_card_import.go` 的 `processBatch()`)。在 IotCard 初始化时计算并赋值 `ICCID19` / `ICCID20`,Store 层的 `Create`/`CreateBatch` 无需感知新字段(GORM 自动处理)。
|
||||
|
||||
---
|
||||
|
||||
### 决策 5.5:`GetBoundICCIDs` 混合长度路由实现方案
|
||||
|
||||
当调用方传入 iccids 混合 19 位和 20 位时,禁止用 OR 条件(`c.iccid_19 IN ? OR c.iccid_20 IN ?`),因为 OR 会导致全表扫描,无法走 Partial Index。
|
||||
|
||||
**采用两次独立查询 + 应用层合并**:
|
||||
|
||||
```go
|
||||
func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
|
||||
var list19, list20 []string
|
||||
for _, id := range iccids {
|
||||
if len(id) == 19 { list19 = append(list19, id) }
|
||||
if len(id) == 20 { list20 = append(list20, id) }
|
||||
}
|
||||
result := make(map[string]bool)
|
||||
// 19 位组查 iccid_19,SELECT c.iccid_19 AS iccid
|
||||
// 20 位组查 iccid_20,SELECT c.iccid_20 AS iccid
|
||||
// 合并两组结果到 result
|
||||
}
|
||||
```
|
||||
|
||||
同样规则适用于 `IotCardStore.GetByICCIDs`(分组后分别 Find,合并 `[]*model.IotCard` 并去重)。
|
||||
|
||||
---
|
||||
|
||||
### 决策 6:enterprise_card/service.go 的内联 SQL 移交 Store 层
|
||||
|
||||
该文件有两处(第 46 行、第 185 行)`WHERE iccid IN ?` 内联 SQL 直接绕过了 Store 层,且同时绕过了数据权限过滤(`middleware.ApplyShopFilter`)。此次改造中将其重构为调用 `IotCardStore.GetByICCIDs()`,消除架构违规并恢复数据权限过滤。
|
||||
|
||||
**依赖注入变更**:`enterprise_card.Service` struct 需新增 `iotCardStore *postgres.IotCardStore` 字段,`New()` 签名增加该参数,`internal/bootstrap/services.go` 调用处同步传入 `s.IotCard`。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险] 存量数据中可能存在 19/20 位冲突记录** → 迁移前先执行冲突检测 SQL;若有冲突,人工处理后再执行回填。
|
||||
|
||||
**[风险] 迁移期间(已加列、未更新代码)查询仍走旧 iccid 列** → 迁移分两阶段:先上线代码(新代码查新列),再回填存量数据;或先回填数据再上线代码。推荐先回填数据再发布代码,避免窗口期数据不一致。
|
||||
|
||||
**[Trade-off] 两次查询 vs 一次查询**:正常路径仍是一次精确索引查询。Miss 场景不发起第二次查询,直接记录日志,无性能影响。
|
||||
|
||||
**[Trade-off] 存储空间**:每张卡多存约 39 字节(19 + 20),百万卡约 39MB,可接受。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**第一步(迁移文件)**:
|
||||
|
||||
> **注意**:`CREATE INDEX CONCURRENTLY` 不能在事务块内执行,而 golang-migrate 默认每个迁移文件在事务中运行。迁移文件中使用 `CREATE INDEX IF NOT EXISTS`(非 CONCURRENTLY),生产环境若需零停机建索引,可在发布窗口期**手动**执行 CONCURRENTLY 版本后再合并代码。(与本项目 `000058_add_covering_index_for_deep_pagination.up.sql` 范式一致。)
|
||||
|
||||
```sql
|
||||
-- 1. 加列(允许 NULL,不阻塞写入)
|
||||
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_19 varchar(19);
|
||||
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_20 varchar(20);
|
||||
|
||||
-- 2. 冲突检测(执行后人工确认均为 0 行,否则停止迁移人工处理)
|
||||
-- 2a. 检测 19 位前缀重复(19 位运营商卡的 iccid_19 必须唯一)
|
||||
SELECT LEFT(iccid, 19), COUNT(*)
|
||||
FROM tb_iot_card WHERE deleted_at IS NULL
|
||||
GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1;
|
||||
|
||||
-- 2b. 检测异常长度 ICCID(长度既非 19 也非 20 的记录,回填后将永远无法通过新查询路径命中)
|
||||
SELECT id, iccid, LENGTH(iccid) AS len
|
||||
FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20);
|
||||
|
||||
-- 3. 回填存量数据
|
||||
UPDATE tb_iot_card SET
|
||||
iccid_19 = LEFT(iccid, 19),
|
||||
iccid_20 = CASE WHEN LENGTH(iccid) = 20 THEN iccid ELSE NULL END;
|
||||
|
||||
-- 4. 建索引(迁移文件中不用 CONCURRENTLY,避免事务冲突)
|
||||
-- 生产零停机版本(手动执行):
|
||||
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_19
|
||||
-- ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
|
||||
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_20
|
||||
-- ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19
|
||||
ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20
|
||||
ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
|
||||
```
|
||||
|
||||
`tb_personal_customer_iccid` 同理,仅加 `iccid_19` 一列(该表只需要按19位匹配)。
|
||||
|
||||
**第二步(代码发布)**:更新 Model、Store 查询方法、Task 写入逻辑。
|
||||
|
||||
**回滚方案**:新列加 NULL 约束可随时 DROP,代码回滚至旧版本恢复 `WHERE iccid = ?` 查询即可。旧 iccid 列全程保留,不做变更。
|
||||
|
||||
## Open Questions
|
||||
|
||||
(无)
|
||||
39
openspec/changes/iccid-dual-column-lookup/proposal.md
Normal file
39
openspec/changes/iccid-dual-column-lookup/proposal.md
Normal file
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
上游 IoT 平台返回的 ICCID 格式不固定(部分运营商 19 位为唯一标识,部分运营商需要完整 20 位才能唯一标识一张卡),而当前 `tb_iot_card.iccid` 仅支持精确匹配查询。当上游传入的位数与数据库存储的位数不一致时,查询静默失败(返回空结果但不报错),导致设备当前使用卡的 `is_current` 状态更新失效,产生不可观测的数据错乱。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 `tb_iot_card` 表新增 `iccid_19`(varchar(19))和 `iccid_20`(varchar(20))两列,分别存储 ICCID 的前 19 位和完整 20 位(19 位卡的 `iccid_20` 为 NULL)
|
||||
- 在 `tb_personal_customer_iccid` 表新增 `iccid_19` 列,采用相同策略
|
||||
- 所有 ICCID 精确查询改为**按上游传入长度路由**:19 位查 `iccid_19`,20 位查 `iccid_20`,Miss 时记录告警而非降级
|
||||
- 不降级、不跨列匹配——上游数据质量问题在日志层暴露,不在查询层消化
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无新增业务能力)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card`:`tb_iot_card` 存储结构变更,新增两列及对应索引;IotCardStore 的所有精确查询方法(GetByICCID、GetByICCIDs、ExistsByICCID、ExistsByICCIDBatch)改用新列路由
|
||||
- `iot-card-import-task`:导入任务在写入 IotCard 时同步填充 `iccid_19` / `iccid_20` 两列
|
||||
|
||||
## Impact
|
||||
|
||||
**数据库**
|
||||
- `tb_iot_card`:新增 `iccid_19`、`iccid_20` 两列 + Partial Index(过滤软删除)
|
||||
- `tb_personal_customer_iccid`:新增 `iccid_19` 列 + 索引
|
||||
- 存量数据一次性回填脚本(迁移文件中执行)
|
||||
|
||||
**Store 层**(共 3 个文件,~10 个方法)
|
||||
- `iot_card_store.go`:GetByICCID、GetByICCIDs、ExistsByICCID、ExistsByICCIDBatch
|
||||
- `personal_customer_iccid_store.go`:GetByICCID、GetByCustomerAndICCID
|
||||
- `device_sim_binding_store.go`:UpdateIsCurrentByDeviceID(内部子查询)、GetBoundICCIDs
|
||||
|
||||
**Service 层**(1 处内联 SQL)
|
||||
- `enterprise_card/service.go`:`WHERE iccid IN ?` 直接 SQL 改为调用 Store 方法或适配新列
|
||||
|
||||
**Task 层**(1 处写入路径)
|
||||
- `iot_card_import.go`:processBatch() 中 IotCard 初始化时赋值新字段
|
||||
@@ -0,0 +1,73 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 导入任务实体定义
|
||||
系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。
|
||||
|
||||
**实体字段**:
|
||||
|
||||
**任务信息**:
|
||||
- `id`: 任务 ID(主键,BIGINT)
|
||||
- `task_no`: 任务编号(VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX)
|
||||
- `status`: 任务状态(INT,1-待处理 2-处理中 3-已完成 4-失败)
|
||||
|
||||
**导入参数**:
|
||||
- `carrier_id`: 运营商 ID(BIGINT,必填)
|
||||
- `carrier_type`: 运营商类型(VARCHAR(10),CMCC/CUCC/CTCC/CBN)
|
||||
- `batch_no`: 批次号(VARCHAR(100),可选)
|
||||
- `file_name`: 原始文件名(VARCHAR(255),可选)
|
||||
|
||||
**待导入数据**:
|
||||
- `card_list`: 待导入卡列表(JSONB,结构: [{iccid, msisdn}],替代原 iccid_list)
|
||||
|
||||
**进度统计**:
|
||||
- `total_count`: 总数(INT,CSV 文件总行数)
|
||||
- `success_count`: 成功数(INT,成功导入的卡数量)
|
||||
- `skip_count`: 跳过数(INT,因重复等原因跳过的数量)
|
||||
- `fail_count`: 失败数(INT,因格式错误等原因失败的数量)
|
||||
|
||||
**结果详情**:
|
||||
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
|
||||
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
|
||||
|
||||
**时间和错误**:
|
||||
- `started_at`: 开始处理时间(TIMESTAMP,可空)
|
||||
- `completed_at`: 完成时间(TIMESTAMP,可空)
|
||||
- `error_message`: 任务级错误信息(TEXT,可空,如文件解析失败等)
|
||||
|
||||
**系统字段**:
|
||||
- `shop_id`: 店铺 ID(BIGINT,可空,记录发起导入的店铺)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
- `creator`: 创建人 ID(BIGINT)
|
||||
- `updater`: 更新人 ID(BIGINT)
|
||||
|
||||
#### Scenario: 创建导入任务
|
||||
|
||||
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
|
||||
- **WHEN** 系统解析 CSV 并创建导入任务
|
||||
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1(待处理)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: IotCard 写入时同步填充 ICCID 双列
|
||||
`processBatch()` 在初始化 `model.IotCard` 时,SHALL 调用 `utils.SplitICCID` 赋值 `ICCID19` 和 `ICCID20` 两个字段。
|
||||
|
||||
- `ICCID19 string` = `iccid19`(`SplitICCID` 返回值第一个,所有合法卡必填)
|
||||
- `ICCID20 *string` = `iccid20`(`SplitICCID` 返回值第二个,**指针类型**;19 位卡返回 `nil`,GORM 写入 SQL NULL;禁止赋空字符串 `""`)
|
||||
- 若 `SplitICCID` 返回 `iccid19 == ""`(异常长度 ICCID),不写入数据库,标记为 fail(此情况在 `ValidateICCID` 长度校验后理论上不应出现,作防御性保留)
|
||||
|
||||
`SplitICCID` 函数签名:
|
||||
```go
|
||||
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
|
||||
// 19 位:返回 (iccid, nil)
|
||||
// 20 位:返回 (iccid[:19], &iccid)
|
||||
// 其他:返回 ("", nil)
|
||||
```
|
||||
|
||||
#### Scenario: 导入 19 位卡时填充双列
|
||||
- **WHEN** 批量导入时某张卡的 ICCID 长度为 19 位
|
||||
- **THEN** 写入数据库时 `iccid_19` = ICCID 原值,`iccid_20` 为 **SQL NULL**(通过 `*string` 赋值 `nil` 实现)
|
||||
|
||||
#### Scenario: 导入 20 位卡时填充双列
|
||||
- **WHEN** 批量导入时某张卡的 ICCID 长度为 20 位
|
||||
- **THEN** 写入数据库时 `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值(通过 `*string` 赋非 nil 指针实现)
|
||||
121
openspec/changes/iccid-dual-column-lookup/specs/iot-card/spec.md
Normal file
121
openspec/changes/iccid-dual-column-lookup/specs/iot-card/spec.md
Normal file
@@ -0,0 +1,121 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ICCID 双列存储
|
||||
`tb_iot_card` SHALL 新增 `iccid_19 varchar(19)` 和 `iccid_20 varchar(20)` 两列。
|
||||
|
||||
- `iccid_19`:存储 ICCID 前 19 位(所有卡均有值,对应 19 位运营商卡即为完整 ICCID)
|
||||
- `iccid_20`:存储完整 20 位 ICCID(仅 20 位运营商卡有值,19 位运营商卡为 **SQL NULL**)
|
||||
- 两列均建立 Partial Index(过滤 `deleted_at IS NULL`),`iccid_20` 额外过滤 NULL
|
||||
|
||||
**Go Model 类型约束**:
|
||||
- `ICCID19` 字段类型为 `string`(非指针,所有卡必填)
|
||||
- `ICCID20` 字段类型为 **`*string`**(指针,19 位卡赋值 `nil` → GORM 写入 NULL;禁止赋空字符串 `""`,GORM 不会将其转为 NULL,会破坏 Partial Index 语义)
|
||||
|
||||
#### Scenario: 导入 19 位运营商卡
|
||||
- **WHEN** 导入 ICCID 长度为 19 位的卡
|
||||
- **THEN** `iccid_19` = ICCID 原值,`iccid_20` = NULL
|
||||
|
||||
#### Scenario: 导入 20 位运营商卡
|
||||
- **WHEN** 导入 ICCID 长度为 20 位的卡
|
||||
- **THEN** `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ICCID 按长度路由查询
|
||||
系统 SHALL 根据上游传入 ICCID 的字符串长度,路由到对应列进行精确查询。
|
||||
|
||||
- 上游传入 19 位 → 查询 `WHERE iccid_19 = ?`
|
||||
- 上游传入 20 位 → 查询 `WHERE iccid_20 = ?`
|
||||
- 上游传入其他长度 → 记录 Error 日志,返回未找到,不执行查询
|
||||
- 禁止跨列降级:任意列 Miss 时直接记录 Warn 日志,不再查另一列
|
||||
|
||||
#### Scenario: 上游传入 19 位命中 19 位卡
|
||||
- **WHEN** 上游传入 19 位 ICCID,数据库存在对应 19 位卡
|
||||
- **THEN** 系统查询 `iccid_19` 列,精确命中,返回卡记录
|
||||
|
||||
#### Scenario: 上游传入 20 位命中 20 位卡
|
||||
- **WHEN** 上游传入 20 位 ICCID,数据库存在对应 20 位卡
|
||||
- **THEN** 系统查询 `iccid_20` 列,精确命中,返回卡记录
|
||||
|
||||
#### Scenario: 上游传入 19 位但数据库无对应记录
|
||||
- **WHEN** 上游传入 19 位 ICCID,数据库中不存在匹配的 `iccid_19`
|
||||
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID),返回未找到,不修改任何数据
|
||||
|
||||
#### Scenario: 上游传入 20 位但数据库无对应记录
|
||||
- **WHEN** 上游传入 20 位 ICCID,数据库中不存在匹配的 `iccid_20`
|
||||
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID),返回未找到,不修改任何数据
|
||||
|
||||
#### Scenario: 上游传入异常长度 ICCID
|
||||
- **WHEN** 上游传入长度既非 19 也非 20 的 ICCID
|
||||
- **THEN** 系统记录 Error 日志,直接返回未找到,不执行数据库查询
|
||||
|
||||
---
|
||||
|
||||
### Requirement: IotCard 精确查询方法适配
|
||||
`IotCardStore` 的精确查询方法 SHALL 全部适配双列查询路由策略。
|
||||
|
||||
受影响方法:`GetByICCID`、`GetByICCIDs`、`ExistsByICCID`、`ExistsByICCIDBatch`。
|
||||
|
||||
#### Scenario: GetByICCID 单卡精确查询
|
||||
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 且 ICCID 长度为 19 或 20 位
|
||||
- **THEN** 系统 SHALL 根据长度路由到 `iccid_19` 或 `iccid_20` 列查询,走 Partial Index
|
||||
|
||||
#### Scenario: GetByICCIDs 批量精确查询
|
||||
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中所有 ICCID 长度一致(均为 19 或均为 20)
|
||||
- **THEN** 系统 SHALL 路由到对应列使用 `IN ?` 查询
|
||||
|
||||
#### Scenario: GetByICCIDs 混合长度输入
|
||||
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中同时包含 19 位和 20 位 ICCID
|
||||
- **THEN** 系统 SHALL 按长度分组,分别查询 `iccid_19 IN ?` 和 `iccid_20 IN ?`,合并结果返回
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DeviceSimBinding 按 ICCID 查询适配
|
||||
`DeviceSimBindingStore` 中涉及 ICCID 查询的方法 SHALL 适配双列路由策略。
|
||||
|
||||
#### Scenario: UpdateIsCurrentByDeviceID 子查询适配
|
||||
- **WHEN** 调用 `UpdateIsCurrentByDeviceID(ctx, deviceID, currentIccid)` 且 currentIccid 非空
|
||||
- **THEN** 子查询 SHALL 根据 currentIccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询 iot_card_id
|
||||
- **AND** Miss 时记录 Warn 日志,不更新 is_current,不降级
|
||||
|
||||
#### Scenario: GetBoundICCIDs JOIN 查询适配
|
||||
- **WHEN** 调用 `GetBoundICCIDs(ctx, iccids)` 查询已绑定设备的 ICCID 列表
|
||||
- **THEN** JOIN 条件 SHALL 按传入 iccids 长度路由到对应列
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PersonalCustomerICCIDStore 查询适配
|
||||
`PersonalCustomerICCIDStore` 的精确查询方法 SHALL 适配双列查询路由策略。
|
||||
|
||||
`tb_personal_customer_iccid` 表 SHALL 新增 `iccid_19 varchar(19)` 列及对应索引。
|
||||
|
||||
受影响方法:`GetByICCID`、`GetByCustomerAndICCID`、`ExistsByCustomerAndICCID`、`CreateOrUpdateLastUsed`。
|
||||
|
||||
#### Scenario: 个人客户 ICCID 记录写入时同步回填
|
||||
- **WHEN** 系统创建或更新 PersonalCustomerICCID 记录
|
||||
- **THEN** `iccid_19` SHALL 同步赋值为 ICCID 前 19 位
|
||||
|
||||
#### Scenario: 个人客户 ICCID 查询按长度路由
|
||||
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 或 `GetByCustomerAndICCID(ctx, customerID, iccid)`
|
||||
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19` 列(19 位)或原 `iccid` 列(20 位)查询
|
||||
|
||||
#### Scenario: 个人客户 ICCID 绑定存在性校验按长度路由
|
||||
- **WHEN** 调用 `ExistsByCustomerAndICCID(ctx, customerID, iccid)` 检查客户是否已绑定该 ICCID
|
||||
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19` 列(19 位)或原 `iccid` 列(20 位)查询,与 `GetByCustomerAndICCID` 保持一致的路由策略
|
||||
- **AND** 若路由结果 Miss,返回 `false`(未绑定),不降级查另一列
|
||||
|
||||
> **设计说明**:`tb_personal_customer_iccid` 仅新增 `iccid_19` 列而不添加 `iccid_20` 列。原因:该表的 ICCID 来自**个人客户手动输入**(扫码/手动填写),而非上游 IoT 平台回调。上游平台才是 19/20 位格式不一致问题的来源;个人客户输入的格式与数据库存储格式一致,因此 20 位查询走原 `iccid` 列即可,无需双列适配。
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 内联 ICCID SQL 归还 Store 层
|
||||
`enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL SHALL 重构为调用 `IotCardStore.GetByICCIDs()`,消除架构违规并恢复数据权限过滤。
|
||||
|
||||
该重构需要同步变更:
|
||||
1. `enterprise_card.Service` struct 新增 `iotCardStore *postgres.IotCardStore` 字段
|
||||
2. `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
|
||||
3. `internal/bootstrap/services.go` 调用处传入 `s.IotCard`
|
||||
|
||||
#### Scenario: 企业卡分配预览调用 Store 层查询
|
||||
- **WHEN** 执行企业卡分配预览或分配操作,需要按 ICCIDs 查询卡列表
|
||||
- **THEN** 系统 SHALL 通过 `IotCardStore.GetByICCIDs()` 查询,不允许 Service 层直接拼写 ICCID 过滤 SQL
|
||||
82
openspec/changes/iccid-dual-column-lookup/tasks.md
Normal file
82
openspec/changes/iccid-dual-column-lookup/tasks.md
Normal file
@@ -0,0 +1,82 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
> **注意**:1.1–1.5 全部写在**同一个迁移文件**中,保持原子性;执行迁移后再发布代码。
|
||||
>
|
||||
> **索引注意**:`CREATE INDEX CONCURRENTLY` 不能在事务块内执行,golang-migrate 默认开启事务,迁移文件中必须使用 `CREATE INDEX IF NOT EXISTS`(不带 CONCURRENTLY)。生产环境若需零停机建索引,可在发布窗口期手动执行 CONCURRENTLY 版本。(与本项目 `000058_add_covering_index_for_deep_pagination.up.sql` 范式一致。)
|
||||
|
||||
- [x] 1.1 新建迁移文件,给 `tb_iot_card` 添加 `iccid_19 varchar(19)` 和 `iccid_20 varchar(20)` 两列(允许 NULL,使用 `ADD COLUMN IF NOT EXISTS`)
|
||||
- [x] 1.1a 迁移文件中执行冲突检测(结果须为 0 行,否则停止迁移人工处理):
|
||||
- 检测 19 位前缀重复:`SELECT LEFT(iccid, 19), COUNT(*) FROM tb_iot_card WHERE deleted_at IS NULL GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1`
|
||||
- 检测异常长度 ICCID:`SELECT id, iccid, LENGTH(iccid) AS len FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20)`(异常记录回填后将永远无法通过新查询路径命中)
|
||||
- [x] 1.2 迁移文件中回填存量数据:`iccid_19 = LEFT(iccid, 19)`,`iccid_20 = iccid`(仅当 LENGTH(iccid)=20,否则 NULL)
|
||||
- [x] 1.3 迁移文件中为 `iccid_19` 创建 Partial Index:`CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL`(**禁止** CONCURRENTLY,原因见上方注意事项)
|
||||
- [x] 1.4 迁移文件中为 `iccid_20` 创建 Partial Index:`CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20 ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL`(**禁止** CONCURRENTLY)
|
||||
- [x] 1.5 同一迁移文件中,给 `tb_personal_customer_iccid` 添加 `iccid_19 varchar(19)` 列(允许 NULL)并回填数据(`iccid_19 = LEFT(iccid, 19)`),创建对应 Partial Index:`CREATE INDEX IF NOT EXISTS idx_personal_customer_iccid_19 ON tb_personal_customer_iccid (iccid_19) WHERE deleted_at IS NULL`
|
||||
- [x] 1.6 执行迁移,验证所有卡记录的 `iccid_19` 和 `iccid_20` 已正确回填(使用 PostgreSQL MCP 查询验证)
|
||||
|
||||
## 2. Model 层更新
|
||||
|
||||
- [x] 2.1 在 `internal/model/iot_card.go` 的 `IotCard` 结构体中新增以下两个字段,补充 gorm 标签和中文注释:
|
||||
- `ICCID19 string`(非指针,所有卡必填,gorm tag 示例:`gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位"`)
|
||||
- `ICCID20 *string`(**指针类型**,19 位卡为 `nil`,GORM 写入 NULL;20 位卡为非 nil 指针,gorm tag 示例:`gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`)
|
||||
- 禁止使用 `string` 类型存 `ICCID20`:GORM 不会将空字符串自动转为 NULL,会破坏 `WHERE iccid_20 IS NOT NULL` 索引语义
|
||||
- [x] 2.2 在 `internal/model/personal_customer_iccid.go` 的 `PersonalCustomerICCID` 结构体中新增 `ICCID19` 字段,补充 gorm 标签和中文注释
|
||||
- [x] 2.3 运行 `lsp_diagnostics` 确认 model 文件无编译错误
|
||||
|
||||
## 3. 工具函数与校验
|
||||
|
||||
- [x] 3.1 在 `pkg/utils/iccid.go` 中实现 `SplitICCID` 函数,签名和语义如下:
|
||||
```go
|
||||
// SplitICCID 按 ICCID 长度拆分为双列存储值
|
||||
// 19 位卡:返回 (iccid, nil)
|
||||
// 20 位卡:返回 (iccid[:19], &iccid)
|
||||
// 其他长度:返回 ("", nil),调用方应将对应卡标记为失败
|
||||
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
|
||||
```
|
||||
**禁止**返回空字符串 `""` 作为 `iccid20`:ICCID20 字段类型为 `*string`,19 位卡必须返回 `nil`,GORM 才会写入 NULL。
|
||||
|
||||
- [x] 3.2 更新 `pkg/validator/iccid.go` 的 `ValidateICCID` 函数,增加长度校验:ICCID 长度必须为 19 或 20 位,否则返回 `ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"}`;防止非法长度 ICCID 通过导入写入数据库后永远 miss
|
||||
|
||||
## 4. IotCard 写入路径适配
|
||||
|
||||
- [x] 4.1 在 `internal/task/iot_card_import.go` 的 `processBatch()` 中,IotCard 初始化时调用 `utils.SplitICCID` 赋值 `ICCID19` 和 `ICCID20` 字段(此步骤在 task 3.2 的长度校验之后,理论上不会出现异常长度,但防御性保留:若 `iccid19 == ""`,标记 fail 并跳过,不写入数据库)
|
||||
- [x] 4.2 使用 PostgreSQL MCP 验证导入后新记录的 `iccid_19` 和 `iccid_20` 字段已正确写入
|
||||
|
||||
## 5. IotCardStore 精确查询方法适配
|
||||
|
||||
- [x] 5.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询;异常长度记录 Error 日志;Miss 记录 Warn 日志
|
||||
- [x] 5.2 改造 `GetByICCIDs(ctx, iccids)`:按长度分组,分别查询 `iccid_19 IN ?` 和 `iccid_20 IN ?`,合并去重结果
|
||||
- [x] 5.3 改造 `ExistsByICCID(ctx, iccid)`:按长度路由到对应列
|
||||
- [x] 5.4 改造 `ExistsByICCIDBatch(ctx, iccids)`:按长度分组查询(19 位组 `Pluck("iccid_19", &list19)`,20 位组 `Pluck("iccid_20", &list20)`),合并结果;因各分组内 Pluck 返回值恰好等于原始 ICCID(19 位组:`iccid_19 == 原始 ICCID`;20 位组:`iccid_20 == 原始 ICCID`),直接以 Pluck 值为 map key,调用方 `existingMap[card.ICCID]` 逻辑无需修改
|
||||
- [x] 5.5 运行 `lsp_diagnostics` 确认 `iot_card_store.go` 无编译错误
|
||||
|
||||
## 6. DeviceSimBindingStore 查询方法适配
|
||||
|
||||
- [x] 6.1 改造 `UpdateIsCurrentByDeviceID` 内部子查询:按 currentIccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询 iot_card_id;Miss 时记录 Warn 日志(日志字段:`iccid`、`device_id`、`column_used`,该方法持有 `deviceID` 参数可直接记录),不更新数据,不降级
|
||||
- [x] 6.2 改造 `GetBoundICCIDs`:按传入 iccids 长度**分组**,分别执行两次 JOIN 查询后在应用层合并:
|
||||
- 19 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_19 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`,SELECT `c.iccid_19 AS iccid`
|
||||
- 20 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_20 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`,SELECT `c.iccid_20 AS iccid`
|
||||
- 两组结果合并为 `map[string]bool`,key 使用各组 SELECT 的 iccid 值(即原始 ICCID)
|
||||
- [x] 6.3 运行 `lsp_diagnostics` 确认 `device_sim_binding_store.go` 无编译错误
|
||||
|
||||
## 7. PersonalCustomerICCIDStore 查询方法适配
|
||||
|
||||
- [x] 7.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由,19 位查 `iccid_19`,20 位查原 `iccid` 列
|
||||
- [x] 7.2 改造 `GetByCustomerAndICCID(ctx, customerID, iccid)`:同上,按长度路由
|
||||
- [x] 7.3 改造 `CreateOrUpdateLastUsed(ctx, customerID, iccid)`:写入时同步赋值 `ICCID19` 字段
|
||||
- [x] 7.3.5 改造 `ExistsByCustomerAndICCID(ctx, customerID, iccid)`:按 iccid 长度路由,19 位查 `iccid_19`,20 位查原 `iccid` 列;路由策略与 `GetByCustomerAndICCID` 保持一致;Miss 时直接返回 `false`,不降级
|
||||
- [x] 7.4 运行 `lsp_diagnostics` 确认 `personal_customer_iccid_store.go` 无编译错误
|
||||
|
||||
## 8. 架构违规修复
|
||||
|
||||
- [x] 8.1 在 `internal/service/enterprise_card/service.go` 的 `Service` struct 中新增 `iotCardStore *postgres.IotCardStore` 字段,并更新 `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
|
||||
- [x] 8.2 更新 `internal/bootstrap/services.go` 第 191 行 `enterpriseCardSvc.New()` 调用,传入 `s.IotCard`(IotCardStore 实例)
|
||||
- [x] 8.3 将 `enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL(第 46 行、第 185 行)重构为调用 `s.iotCardStore.GetByICCIDs()`
|
||||
- [x] 8.4 运行 `lsp_diagnostics` 确认 `enterprise_card/service.go` 和 `bootstrap/services.go` 无编译错误
|
||||
|
||||
## 9. 整体验证
|
||||
|
||||
- [x] 9.1 执行 `go build ./...` 确认全量编译通过
|
||||
- [x] 9.2 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 19 位 ICCID,确认正确命中 19 位卡的 `is_current` 更新
|
||||
- [x] 9.3 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 20 位 ICCID,确认正确命中 20 位卡的 `is_current` 更新
|
||||
- [x] 9.4 确认日志中 Miss 场景正确输出 Warn 日志(可通过修改测试数据触发)
|
||||
22
pkg/utils/iccid.go
Normal file
22
pkg/utils/iccid.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package utils
|
||||
|
||||
// SplitICCID 按 ICCID 长度拆分为双列存储值
|
||||
//
|
||||
// 规则:
|
||||
// - 19 位卡:返回 (iccid, nil),iccid_20 写入 NULL
|
||||
// - 20 位卡:返回 (iccid[:19], &iccid),两列均有值
|
||||
// - 其他长度:返回 ("", nil),调用方应将对应卡标记为失败
|
||||
//
|
||||
// 注意:iccid_20 必须为 *string 而非 string,GORM 才会将 nil 写入 SQL NULL
|
||||
// 若使用 string 类型,空字符串 "" 不会被转为 NULL,会破坏 Partial Index 语义
|
||||
func SplitICCID(iccid string) (iccid19 string, iccid20 *string) {
|
||||
switch len(iccid) {
|
||||
case 19:
|
||||
return iccid, nil
|
||||
case 20:
|
||||
prefix := iccid[:19]
|
||||
return prefix, &iccid
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ func ValidateICCID(iccid string, carrierType string) ICCIDValidationResult {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 不能为空"}
|
||||
}
|
||||
|
||||
if len(iccid) != 19 && len(iccid) != 20 {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"}
|
||||
}
|
||||
|
||||
if !iccidRegex.MatchString(iccid) {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 只能包含字母和数字"}
|
||||
}
|
||||
@@ -31,6 +35,10 @@ func ValidateICCIDWithoutCarrier(iccid string) ICCIDValidationResult {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 不能为空"}
|
||||
}
|
||||
|
||||
if len(iccid) != 19 && len(iccid) != 20 {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"}
|
||||
}
|
||||
|
||||
if !iccidRegex.MatchString(iccid) {
|
||||
return ICCIDValidationResult{Valid: false, Message: "ICCID 只能包含字母和数字"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user