diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 44875e04..a23de14b 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -370,6 +370,7 @@ func initServices(s *stores, deps *Dependencies) *services { exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) exchangeService.SetAccessAudit(auditWriter) + exchangeService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService.SetAccessAudit(auditWriter) diff --git a/internal/model/dto/client_auth_dto.go b/internal/model/dto/client_auth_dto.go index 3f3e98a1..e549cbee 100644 --- a/internal/model/dto/client_auth_dto.go +++ b/internal/model/dto/client_auth_dto.go @@ -29,9 +29,11 @@ type WechatLoginRequest struct { // WechatLoginResponse A2/A3 登录统一响应 type WechatLoginResponse struct { - Token string `json:"token" description:"登录JWT令牌"` - NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号"` - IsNewUser bool `json:"is_new_user" description:"是否新创建用户"` + Token string `json:"token" description:"登录JWT令牌"` + NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号(仅前端提示):强制绑定开关开启时,客户无手机号或当前资产尚无有效手机号关联为 true;开关关闭恒为 false"` + AssetPhoneBound bool `json:"asset_phone_bound" description:"当前资产是否已绑定手机号(一资产一号);不返回号码"` + AssetPhoneIsOwn bool `json:"asset_phone_is_own" description:"当前资产绑定的手机号是否为当前客户自己的手机号;不返回号码"` + IsNewUser bool `json:"is_new_user" description:"是否新创建用户"` } // ======================================== diff --git a/internal/routes/exchange.go b/internal/routes/exchange.go index 31bfa7ea..8c4aa5d5 100644 --- a/internal/routes/exchange.go +++ b/internal/routes/exchange.go @@ -10,10 +10,12 @@ import ( func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHandler, doc *openapi.Generator, basePath string) { Register(router, doc, basePath, "POST", "/exchanges", handler.Create, RouteSpec{ Summary: "创建换货单", - Tags: []string{"换货管理"}, - Input: new(dto.CreateExchangeRequest), - Output: new(dto.ExchangeOrderResponse), - Auth: true, + Description: "新资产存在有效手机号关联时拒绝(错误码 1202),提示先在后台解除该资产的手机号绑定;完整号码只在错误响应 data.phone 中返回,不进日志。" + + "不再以“有人扫过码”拒绝。直接换货完成时清空旧资产的个人客户绑定并失效旧资产手机号关联(exchange_completed),均不迁移到新资产。", + Tags: []string{"换货管理"}, + Input: new(dto.CreateExchangeRequest), + Output: new(dto.ExchangeOrderResponse), + Auth: true, }) Register(router, doc, basePath, "GET", "/exchanges", handler.List, RouteSpec{ @@ -43,7 +45,9 @@ func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHan Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{ Summary: "确认换货完成", Description: "确认换货完成;仅超级管理员或平台用户可对迁移状态为 failed 的物流换货单重试," + - "其他账号对 failed 单返回 403/1005;非 failed 单沿用既有完成权限。", + "其他账号对 failed 单返回 403/1005;非 failed 单沿用既有完成权限。" + + "新资产存在有效手机号关联时拒绝并停在已发货待确认,完整号码在错误响应 data.phone;后台解除后可重新确认。" + + "完成时清空旧资产个人客户绑定并失效旧资产手机号关联,均不迁移到新资产。", Tags: []string{"换货管理"}, Input: new(dto.ExchangeIDRequest), Output: nil, diff --git a/internal/routes/personal.go b/internal/routes/personal.go index f1fca3dd..230c2765 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -93,18 +93,21 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{ Summary: "绑定手机号", - Tags: []string{"个人客户 - 认证"}, - Auth: true, - Input: &dto.BindPhoneRequest{}, - Output: &dto.BindPhoneResponse{}, + Description: "客户手机号与资产手机号相互独立:无手机号且资产无关联时同时写入两者;有手机号且资产无关联时只为资产建立关联,号码可与客户手机号不同;" + + "无手机号而资产已绑定时只写客户手机号;资产已绑定其他号码时返回 1192“该资产已绑定手机号,如需更换请联系管理员”,响应不含号码。", + Tags: []string{"个人客户 - 认证"}, + Auth: true, + Input: &dto.BindPhoneRequest{}, + Output: &dto.BindPhoneResponse{}, }) Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/change-phone", handlers.ClientAuth.ChangePhone, RouteSpec{ - Summary: "更换手机号", - Tags: []string{"个人客户 - 认证"}, - Auth: true, - Input: &dto.ChangePhoneRequest{}, - Output: &dto.ChangePhoneResponse{}, + Summary: "更换手机号", + Description: "同时验证旧、新号码,只更换客户手机号,不迁移、复制或失效任何资产手机号关联。", + Tags: []string{"个人客户 - 认证"}, + Auth: true, + Input: &dto.ChangePhoneRequest{}, + Output: &dto.ChangePhoneResponse{}, }) Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/logout", handlers.ClientAuth.Logout, RouteSpec{ diff --git a/internal/service/client_auth/phone_asset_association.go b/internal/service/client_auth/phone_asset_association.go index 0a82040e..343fe733 100644 --- a/internal/service/client_auth/phone_asset_association.go +++ b/internal/service/client_auth/phone_asset_association.go @@ -7,7 +7,6 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -88,15 +87,14 @@ func (s *Service) establishAssociation( return s.associationWriter.Establish(ctx, tx, customer, phone, assetType, assetID) } -// migrateAssociations 委托关联写入规则完成换绑原子迁移与上限/冲突判定。 -// 开关关闭时完全短路,不迁移任何关联。 -func (s *Service) migrateAssociations( - ctx context.Context, - tx *gorm.DB, - oldPhone, newPhone string, -) ([]accessauditapp.PhoneAssetAssociationChange, error) { - if !requirePhoneBinding() { - return nil, nil +// assetHasValidAssociation 判断当前访问资产是否已有任一有效手机号关联;开关关闭或无资产身份时返回 false。 +func (s *Service) assetHasValidAssociation(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) { + if !requirePhoneBinding() || assetType == "" || assetID == 0 { + return false, nil } - return s.associationWriter.Migrate(ctx, tx, oldPhone, newPhone) + phones, err := s.associationStore.WithTx(tx).ListValidByAsset(ctx, assetType, assetID) + if err != nil { + return false, errors.Wrap(errors.CodeInternalError, err, "查询资产手机号关联失败") + } + return len(phones) > 0, nil } diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index a651f696..afb89f9c 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -197,7 +197,7 @@ func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, return nil, err } - token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) + token, bindState, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) if err != nil { return nil, err } @@ -208,9 +208,11 @@ func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, ) return &dto.WechatLoginResponse{ - Token: token, - NeedBindPhone: needBindPhone, - IsNewUser: isNewUser, + Token: token, + NeedBindPhone: bindState.NeedBindPhone, + AssetPhoneBound: bindState.AssetPhoneBound, + AssetPhoneIsOwn: bindState.AssetPhoneIsOwn, + IsNewUser: isNewUser, }, nil } @@ -256,7 +258,7 @@ func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest return nil, err } - token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) + token, bindState, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) if err != nil { return nil, err } @@ -267,9 +269,11 @@ func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest ) return &dto.WechatLoginResponse{ - Token: token, - NeedBindPhone: needBindPhone, - IsNewUser: isNewUser, + Token: token, + NeedBindPhone: bindState.NeedBindPhone, + AssetPhoneBound: bindState.AssetPhoneBound, + AssetPhoneIsOwn: bindState.AssetPhoneIsOwn, + IsNewUser: isNewUser, }, nil } @@ -299,8 +303,12 @@ func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, // BindPhone A5 绑定手机号 // POST /api/c/v1/auth/bind-phone -// 无主手机号时建立账号手机号;已有主手机号且提交号码与主号一致、验证码有效时, -// 幂等建立当前访问资产与该手机号的关联且不修改账号手机号;提交号码与主号不一致仍拒绝。 +// 客户手机号与资产手机号相互独立,按两侧状态分别写入: +// - 无主手机号、资产无有效关联:同时写客户手机号与资产关联; +// - 无主手机号、资产已有有效关联:只写客户手机号,不动资产关联; +// - 已有主手机号、资产无有效关联:只为资产建立关联,号码可与主号不同,不改客户手机号; +// - 已有主手机号、资产已有有效关联:同号幂等成功,其他号码按一资产一号拒绝。 +// // 请求不含当前访问资产身份时只完成账号手机号绑定,不建立关联。 func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType string, assetID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) { if req == nil { @@ -380,6 +388,11 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType stri )); err != nil { return err } + // 资产已有有效关联(无论号码属于谁)时只写客户手机号,不动资产关联。 + assetBound, err := s.assetHasValidAssociation(ctx, tx, assetType, assetID) + if err != nil || assetBound { + return err + } // 建联与账号手机号同事务:超限或写入失败时账号手机号一并回滚。 if _, err := s.establishAssociation(ctx, tx, customer, record.Phone, assetType, assetID); err != nil { return err @@ -397,8 +410,8 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType stri }, nil } -// bindExistingPrimaryPhone 处理已有主手机号的绑定请求。 -// 提交号码等于主号且验证码有效时幂等建立关联且不改账号手机号;号码不一致仍拒绝换号。 +// bindExistingPrimaryPhone 处理已有主手机号的绑定请求:只为当前访问资产建立关联,不改账号手机号。 +// 强制绑定开启且带资产身份时,资产侧号码可与主号不同;否则提交号码必须等于主号(不支持经绑定入口换号)。 func (s *Service) bindExistingPrimaryPhone( ctx context.Context, customer *model.PersonalCustomer, @@ -407,7 +420,8 @@ func (s *Service) bindExistingPrimaryPhone( assetID uint, req *dto.BindPhoneRequest, ) (*dto.BindPhoneResponse, error) { - if primary.Phone != req.Phone { + assetBinding := requirePhoneBinding() && assetType != "" && assetID != 0 + if primary.Phone != req.Phone && !assetBinding { appErr := errors.New(errors.CodeAlreadyBoundPhone) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr) return nil, appErr @@ -421,7 +435,7 @@ func (s *Service) bindExistingPrimaryPhone( now := time.Now() err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 固定加锁次序:先取手机号串行化点(advisory),再按 id ASC 锁行。 - if err := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone); err != nil { + if err := s.lockPhoneScopesInOrder(ctx, tx, req.Phone); err != nil { return err } if err := s.lockPhoneRowsInIDOrder(ctx, tx, []uint{primary.ID}); err != nil { @@ -436,11 +450,11 @@ func (s *Service) bindExistingPrimaryPhone( } return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } - if locked.Phone != req.Phone { + if !assetBinding && locked.Phone != req.Phone { // 并发换绑已把主号改成其他号码:提交号码不再等于账号手机号,按既有语义拒绝。 return errors.New(errors.CodeAlreadyBoundPhone) } - _, err := s.establishAssociation(ctx, tx, customer, locked.Phone, assetType, assetID) + _, err := s.establishAssociation(ctx, tx, customer, req.Phone, assetType, assetID) return err }) if err != nil { @@ -498,9 +512,8 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha now := time.Now() var beforeData map[string]any var failurePhone *model.PersonalCustomerPhone - var migrationChanges []accessauditapp.PhoneAssetAssociationChange err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 统一加锁顺序:先按手机号行 id ASC 锁定旧、新手机号行,再由关联迁移按 id ASC 锁定两侧有效关系行。 + // 统一加锁顺序:先取旧、新手机号串行化点,再按手机号行 id ASC 锁定旧、新手机号行。 // 换绑两行的加锁顺序与 bind-phone 的单行加锁一致,两条路径不会形成 A→B / B→A 死锁环。 if idErr := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone, req.NewPhone); idErr != nil { return idErr @@ -533,12 +546,7 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha } else if err != nil && err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败") } - // 关联迁移先于账号手机号改写:超限或与既有关系冲突时整次失败,旧、新关系均保持原状。 - changes, migrateErr := s.migrateAssociations(ctx, tx, primary.Phone, req.NewPhone) - if migrateErr != nil { - return migrateErr - } - migrationChanges = changes + // 客户手机号与资产手机号相互独立:换号只改客户手机号,不迁移、复制或失效任何资产关联。 if err := tx.Model(primary).Updates(map[string]any{ "phone": req.NewPhone, "verified_at": now, @@ -548,22 +556,10 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha } primary.Phone = req.NewPhone primary.VerifiedAt = &now - if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( + return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData, map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}, "手机号已更换", constants.AuditResultSuccess, - )); err != nil { - return err - } - if len(migrationChanges) == 0 { - return nil - } - return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ - ActionCode: constants.AuditActionPhoneAssetAssociationMigrated, - Summary: "换绑手机号并迁移资产关联", - OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, - Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, - PhoneAssociations: migrationChanges, - }) + )) }) if err != nil { if failurePhone == nil { @@ -1014,10 +1010,10 @@ func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, a // 已存在有效关系 → false。全局开关关闭时恒为 false,且不查询、不创建、不删除任何关系。 // 关联查询使用当前访问资产的 asset_type/asset_id,不使用 phone claim:phone 是登录时快照, // 换绑后到下次登录前仍是旧号。该字段只作前端提示,不改变任何资源授权。 -func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) { +func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, loginBindState, error) { // 查询用户已绑定的主手机号,写入 JWT,供后续接口直接从 context 取用 var boundPhone string - needBindPhone := false + state := loginBindState{} cfg := config.Get() requirePhoneBinding := true if cfg != nil { @@ -1027,30 +1023,26 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp if phoneErr == nil { boundPhone = primaryPhone.Phone } else if phoneErr != gorm.ErrRecordNotFound { - return "", false, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败") + return "", state, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败") } // 开关关闭必须完全短路:既不查询也不写任何关系,否则会出现「关闭开关却写库」的越权写入。 if requirePhoneBinding { - if boundPhone == "" { - needBindPhone = true - } else { - associated, err := s.associationStore.ExistsValid(ctx, boundPhone, assetType, assetID) - if err != nil { - return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号资产关联失败") - } - needBindPhone = !associated + resolved, err := s.resolveLoginBindState(ctx, boundPhone, assetType, assetID) + if err != nil { + return "", state, err } + state = resolved } token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID) if err != nil { - return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败") + return "", state, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败") } claims, err := s.jwtManager.VerifyPersonalCustomerToken(token) if err != nil { - return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败") + return "", state, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败") } ttl := time.Until(claims.ExpiresAt.Time) @@ -1060,10 +1052,38 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp redisKey := constants.RedisPersonalCustomerTokenKey(customerID) if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil { - return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败") + return "", state, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败") } - return token, needBindPhone, nil + return token, state, nil +} + +// loginBindState 是登录响应的手机号绑定提示,只含布尔值,不含号码。 +type loginBindState struct { + NeedBindPhone bool + AssetPhoneBound bool + AssetPhoneIsOwn bool +} + +// resolveLoginBindState 按客户主手机号与当前资产有效关联判定绑定提示: +// 客户无主手机号仍需绑定;资产尚无有效关联需为资产绑定;资产已有有效关联(无论号码属于谁)且客户有主手机号时不需绑定。 +func (s *Service) resolveLoginBindState(ctx context.Context, boundPhone, assetType string, assetID uint) (loginBindState, error) { + state := loginBindState{} + if assetType != "" && assetID != 0 { + assetPhones, err := s.associationStore.ListValidByAsset(ctx, assetType, assetID) + if err != nil { + return state, errors.Wrap(errors.CodeInternalError, err, "查询资产手机号关联失败") + } + state.AssetPhoneBound = len(assetPhones) > 0 + for _, phone := range assetPhones { + if boundPhone != "" && phone == boundPhone { + state.AssetPhoneIsOwn = true + break + } + } + } + state.NeedBindPhone = boundPhone == "" || !state.AssetPhoneBound + return state, nil } // DevLogin 开发环境测试登录 diff --git a/internal/service/customer_binding/audit.go b/internal/service/customer_binding/audit.go index 1fc5dbda..6bb16df2 100644 --- a/internal/service/customer_binding/audit.go +++ b/internal/service/customer_binding/audit.go @@ -98,58 +98,6 @@ func deviceAuditChange(device *model.Device, relation, role string, beforeData, } } -func (s *Service) loadAuditAssets(ctx context.Context, tx *gorm.DB, oldType string, oldID uint, newType string, newID uint) ([]accessauditapp.IotCardChange, []accessauditapp.DeviceChange, error) { - cards := make([]accessauditapp.IotCardChange, 0, 2) - devices := make([]accessauditapp.DeviceChange, 0, 2) - appendAsset := func(assetType string, assetID uint, role string) error { - switch normalizeAssetType(assetType) { - case assetTypeIotCard: - card, err := s.readCard(ctx, tx, assetID) - if err != nil { - return err - } - cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, role, nil, nil)) - case assetTypeDevice: - device, err := s.readDevice(ctx, tx, assetID) - if err != nil { - return err - } - devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, role, nil, nil)) - default: - return errors.New(errors.CodeInvalidParam, "无效的资产类型") - } - return nil - } - if err := appendAsset(oldType, oldID, constants.AuditResourceRolePersonalCustomerOldAsset); err != nil { - return nil, nil, err - } - if err := appendAsset(newType, newID, constants.AuditResourceRolePersonalCustomerNewAsset); err != nil { - return nil, nil, err - } - return cards, devices, nil -} - -func (s *Service) writeMigrationAudit( - ctx context.Context, - tx *gorm.DB, - customerID uint, - oldType string, - oldID uint, - newType string, - newID uint, - personalDevices []accessauditapp.PersonalCustomerDeviceChange, - personalICCIDs []accessauditapp.PersonalCustomerICCIDChange, -) error { - cards, devices, err := s.loadAuditAssets(ctx, tx, oldType, oldID, newType, newID) - if err != nil { - return err - } - return s.writeBindingAudit( - ctx, tx, constants.AuditActionPersonalCustomerAssetBindingMigrated, "换货迁移个人客户资产绑定", - customerID, personalDevices, personalICCIDs, cards, devices, - ) -} - // UnbindByVirtualNo 按现有换货重置语义删除设备号绑定,并在同一事务记录实际删除关系。 func (s *Service) UnbindByVirtualNo(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, virtualNo string) error { if s.accessAudit == nil { diff --git a/internal/service/customer_binding/service.go b/internal/service/customer_binding/service.go index 17d0e928..255e55a7 100644 --- a/internal/service/customer_binding/service.go +++ b/internal/service/customer_binding/service.go @@ -349,273 +349,103 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo }, nil) } -// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用) -// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci -func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error { +// 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 } - pcd := s.makePCD(tx) - pci := s.makePCI(tx) - switch normalizeAssetType(oldAssetType) { case assetTypeIotCard: - oldCard, err := s.readCard(ctx, tx, oldAssetID) + card, err := s.readCard(ctx, tx, oldAssetID) if err != nil { return err } - if oldCard.VirtualNo != "" { - return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, oldAssetType, oldAssetID, newAssetType, newAssetID) + 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.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, oldAssetType, oldAssetID, newAssetType, newAssetID) - + return s.clearICCIDBindings(ctx, tx, card.ICCID, assetChange) case assetTypeDevice: - oldDevice, err := s.readDevice(ctx, tx, oldAssetID) + device, err := s.readDevice(ctx, tx, oldAssetID) if err != nil { return err } - key := oldDevice.VirtualNo + key := device.VirtualNo if key == "" { - key = oldDevice.IMEI + key = device.IMEI } - return s.migrateFromPCD(ctx, tx, pcd, pci, key, oldAssetType, oldAssetID, newAssetType, newAssetID) + 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) } -// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产 -func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error { - records, err := pcd.GetByDeviceNo(ctx, oldKey) +// 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, "查询旧资产绑定记录失败") + return errors.Wrap(errors.CodeInternalError, err, "查询旧资产客户绑定失败") } - - // 仅处理 status=1 的有效记录 - var active []*model.PersonalCustomerDevice - for _, r := range records { - if r.Status == 1 { - active = append(active, r) + for _, record := range records { + if record == nil || record.Status != 1 { + continue } - } - if len(active) == 0 { - return nil - } - - switch normalizeAssetType(newAssetType) { - case assetTypeIotCard: - newCard, err := s.readCard(ctx, db, newAssetID) - if err != nil { + 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 } - 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, "迁移客户绑定关系失败") - } - after := *rec - after.VirtualNo = newCard.VirtualNo - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - []accessauditapp.PersonalCustomerDeviceChange{{ - Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding, - BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status}, - AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status}, - }}, nil); err != nil { - return 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, "创建新客户绑定关系失败") - } - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - []accessauditapp.PersonalCustomerDeviceChange{{ - Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding, - BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status}, - AfterData: map[string]any{"virtual_no": rec.VirtualNo, "status": 0}, - }}, []accessauditapp.PersonalCustomerICCIDChange{{ - Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding, - AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status}, - }}); err != nil { - return 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, "迁移设备客户绑定关系失败") - } - after := *rec - after.VirtualNo = newKey - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - []accessauditapp.PersonalCustomerDeviceChange{{ - Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding, - BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status}, - AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status}, - }}, nil); err != nil { - return 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, oldAssetType string, oldAssetID uint, 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 { +// clearICCIDBindings 把按 ICCID 定位的有效绑定置为无效并逐条写审计。 +func (s *Service) clearICCIDBindings(ctx context.Context, tx *gorm.DB, iccid string, cards []accessauditapp.IotCardChange) error { + if iccid == "" { 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, "创建新客户绑定关系失败") - } - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - []accessauditapp.PersonalCustomerDeviceChange{{ - Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding, - AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status}, - }}, []accessauditapp.PersonalCustomerICCIDChange{{ - Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding, - BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status}, - AfterData: map[string]any{"iccid": rec.ICCID, "status": 0}, - }}); err != nil { - return 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 绑定关系失败") - } - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - nil, []accessauditapp.PersonalCustomerICCIDChange{ - { - Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding, - BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status}, - AfterData: map[string]any{"iccid": rec.ICCID, "status": 0}, - }, - { - Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding, - AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status}, - }, - }); err != nil { - return 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 := 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, "创建新客户绑定关系失败") - } - if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID, - []accessauditapp.PersonalCustomerDeviceChange{{ - Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding, - AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status}, - }}, []accessauditapp.PersonalCustomerICCIDChange{{ - Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding, - BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status}, - AfterData: map[string]any{"iccid": rec.ICCID, "status": 0}, - }}); err != nil { - return err - } - } - - default: - return errors.New(errors.CodeInvalidParam) + 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 } diff --git a/internal/service/exchange/phone_association.go b/internal/service/exchange/phone_association.go new file mode 100644 index 00000000..83dd80ea --- /dev/null +++ b/internal/service/exchange/phone_association.go @@ -0,0 +1,93 @@ +package exchange + +import ( + "context" + "time" + + "gorm.io/gorm" + + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + "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" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/sanitizer" +) + +// exchangeNewAssetPhoneBoundMessage 新资产已绑定手机号时的拒绝提示,不含号码。 +const exchangeNewAssetPhoneBoundMessage = "新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请" + +// SetPhoneAssetAssociationStore 注入手机号—资产关联存储,用于换货校验与换货完成失效旧资产关联。 +func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) { + s.phoneAssociationStore = store +} + +// ensureNewAssetPhoneUnboundWithTx 新资产存在有效手机号关联时拒绝换货。 +// 完整号码只放在错误响应 data.phone 中,错误信息与日志不含号码。 +func (s *Service) ensureNewAssetPhoneUnboundWithTx(ctx context.Context, tx *gorm.DB, newAsset *resolvedExchangeAsset) error { + if s.phoneAssociationStore == nil { + return errors.New(errors.CodeInvalidStatus, "手机号资产关联存储未配置") + } + phones, err := s.phoneAssociationStore.WithTx(tx).ListValidByAsset(ctx, newAsset.AssetType, newAsset.AssetID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询新资产手机号关联失败") + } + if len(phones) == 0 { + return nil + } + return errors.NewWithData(errors.CodeExchangeStatusInvalid, map[string]any{"phone": phones[0]}, exchangeNewAssetPhoneBoundMessage) +} + +// invalidateOldAssetPhoneAssociationsWithTx 换货完成时在同一事务内失效旧资产全部有效手机号关联,释放号码名额。 +// 旧资产无有效关联时跳过;操作人为确认换货完成的账号,审计只写脱敏手机号。 +func (s *Service) invalidateOldAssetPhoneAssociationsWithTx(ctx context.Context, tx *gorm.DB, oldAsset *resolvedExchangeAsset) error { + if s.phoneAssociationStore == nil { + return errors.New(errors.CodeInvalidStatus, "手机号资产关联存储未配置") + } + store := s.phoneAssociationStore.WithTx(tx) + rows, err := store.LockValidByAsset(ctx, tx, oldAsset.AssetType, oldAsset.AssetID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定旧资产手机号关联失败") + } + if len(rows) == 0 { + return nil + } + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "换货审计接缝未配置") + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + now := time.Now() + method, reason := constants.PhoneAssetAssociationInvalidateMethodExchangeCompleted, constants.PhoneAssetAssociationExchangeCompletedReason + operatorID, operatorName := middleware.GetUserIDFromContext(ctx), middleware.GetUsernameFromContext(ctx) + affected, err := store.InvalidateByIDs(ctx, tx, ids, method, reason, operatorID, operatorName, now) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "失效旧资产手机号关联失败") + } + if int(affected) != len(rows) { + return errors.New(errors.CodeConflict, "旧资产手机号关联已变化,请重试") + } + changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(rows)) + for _, row := range rows { + changes = append(changes, accessauditapp.PhoneAssetAssociationChange{ + AssociationID: row.ID, PhoneMasked: sanitizer.MaskPhone(row.Phone), + AssetType: row.AssetType, AssetID: row.AssetID, AssetDisplayName: oldAsset.Identifier, + Status: constants.PhoneAssetAssociationStatusInvalid, Source: row.Source, + InvalidatedAt: &now, InvalidationMethod: method, InvalidationReason: reason, + BeforeData: map[string]any{"status": constants.PhoneAssetAssociationStatusValid}, + AfterData: map[string]any{ + "status": constants.PhoneAssetAssociationStatusInvalid, + "invalidated_at": now, "invalidation_method": method, "invalidation_reason": reason, + }, + }) + } + return s.auditWriter.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionPhoneAssetAssociationUnbound, + Summary: "换货完成失效旧资产手机号关联", + OperatorID: operatorID, ActorKind: constants.AuditActorAccount, ActorName: operatorName, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + PhoneAssociations: changes, + }) +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 651d970f..5dc43473 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -31,6 +31,7 @@ type Service struct { packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore resourceTagStore *postgres.ResourceTagStore customerBinding *customerBindingSvc.Service + phoneAssociationStore *postgres.PhoneAssetAssociationStore shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier auditWriter *audit.Writer logger *zap.Logger @@ -1068,26 +1069,17 @@ func (s *Service) validateExchangeAssetsWithTx(ctx context.Context, tx *gorm.DB, return nil } +// ensureNewAssetBindingAvailableWithTx 新资产已绑定手机号时拒绝换货;不再以“有人扫过码”(客户绑定)作为拒绝依据。 func (s *Service) ensureNewAssetBindingAvailableWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { - newKey := exchangeAssetBindingKey(newAsset) - // 新资产无虚拟号时不再拦截:Migrate() 已能正确处理无绑定跳过、有绑定迁移到 pci 的情形 - if newKey == "" { - return nil - } - var newBindCount int64 - if err := tx.WithContext(ctx).Model(&model.PersonalCustomerDevice{}). - Where("virtual_no = ? AND status = ?", newKey, constants.StatusEnabled). - Count(&newBindCount).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询新资产客户绑定失败") - } - if newBindCount > 0 { - return errors.New(errors.CodeExchangeStatusInvalid, "新资产存在有效客户绑定,不可用于换货") - } - return nil + return s.ensureNewAssetPhoneUnboundWithTx(ctx, tx, newAsset) } +// switchCustomerBindingWithTx 换货完成时清空旧资产的客户绑定并失效旧资产手机号关联,均不迁移到新资产。 func (s *Service) switchCustomerBindingWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { - return s.customerBinding.Migrate(ctx, tx, oldAsset.AssetType, oldAsset.AssetID, newAsset.AssetType, newAsset.AssetID) + if err := s.customerBinding.ClearForExchange(ctx, tx, oldAsset.AssetType, oldAsset.AssetID); err != nil { + return err + } + return s.invalidateOldAssetPhoneAssociationsWithTx(ctx, tx, oldAsset) } // syncNewAssetOwnershipWithTx 将新资产归属同步为旧资产当前归属。 diff --git a/internal/service/phone_asset_association/association_write.go b/internal/service/phone_asset_association/association_write.go index 3741eb41..c8bc6060 100644 --- a/internal/service/phone_asset_association/association_write.go +++ b/internal/service/phone_asset_association/association_write.go @@ -2,10 +2,8 @@ package phone_asset_association import ( "context" - stderrors "errors" "time" - "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" @@ -16,11 +14,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/sanitizer" ) -// associationUniqueConstraint 是「有效关系」部分唯一索引名。 -// 换绑迁移撞上它说明新号已存在同资产有效关系,必须整次失败而不是产生并存关系。 -const associationUniqueConstraint = "uq_phone_asset_association_valid" - -// AssociationWriter 承载手机号—资产关联的写入规则:幂等建联、换绑迁移与十项上限。 +// AssociationWriter 承载手机号—资产关联的写入规则:一资产一号、幂等建联与十项上限。 // 关联只能由 H5 短信验证建立,因此这里不提供任何后台创建入口。 type AssociationWriter struct { store *postgres.PhoneAssetAssociationStore @@ -32,7 +26,7 @@ func NewAssociationWriter(store *postgres.PhoneAssetAssociationStore, auditWrite return &AssociationWriter{store: store, audit: auditWriter} } -// Establish 建立手机号与当前访问资产的有效关联;同一关系已存在时保持幂等。 +// Establish 建立手机号与当前访问资产的有效关联;同一关系已存在时保持幂等,资产已绑定其他号码时拒绝。 // 串行化点:先取手机号事务级 advisory lock,再锁定该号码现有有效关系行, // 使「计数」与「插入」同处临界区;新号没有行可锁时仍由 advisory lock 保证串行。 // 已达十项的手机号对已关联资产重复验证必须返回成功且不产生第二条关系, @@ -50,6 +44,20 @@ func (w *AssociationWriter) Establish( return false, nil } store := w.store.WithTx(tx) + // 一资产一号:先查后写,不加资产锁(两个号码同时为同一资产完成验证属已接受的极小概率,由只读统计发现后后台解除)。 + assetPhones, err := store.ListValidByAsset(ctx, assetType, assetID) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询资产有效手机号关联失败") + } + for _, bound := range assetPhones { + if bound == phone { + return false, nil + } + } + if len(assetPhones) > 0 { + // 提示与响应不含任何号码。 + return false, errors.New(errors.CodeAssetPhoneAlreadyBound) + } if err := store.LockPhoneScopes(ctx, phone); err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败") } @@ -102,64 +110,3 @@ func (w *AssociationWriter) Establish( } return true, nil } - -// Migrate 在同一事务内把旧号全部有效关联原子迁移到新号。 -// 串行化点:先按号码字符串升序取旧、新手机号 advisory lock,再按 id ASC 锁定两侧有效关系行。 -// 迁移前比较「新号现有有效关系数 + 旧号待迁移有效关系数」:超过十项整次失败,旧、新关系均不变。 -// 与「新号已存在的同资产有效关系」冲突时整次失败回滚,不产生并存的有效关系。 -// 返回每次迁移的审计事实;无有效关系时返回空。 -func (w *AssociationWriter) Migrate(ctx context.Context, tx *gorm.DB, oldPhone, newPhone string) ([]accessauditapp.PhoneAssetAssociationChange, error) { - store := w.store.WithTx(tx) - if err := store.LockPhoneScopes(ctx, oldPhone, newPhone); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败") - } - locked, err := store.LockValidByPhones(ctx, oldPhone, newPhone) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号关联资产失败") - } - pending := make([]*model.PhoneAssetAssociation, 0, len(locked)) - newPhoneValidCount := 0 - for _, row := range locked { - if row.Phone == oldPhone { - pending = append(pending, row) - continue - } - newPhoneValidCount++ - } - if newPhoneValidCount+len(pending) > constants.PhoneAssetAssociationMaxValidPerPhone { - return nil, errors.New(errors.CodeInvalidStatus, constants.PhoneAssetAssociationLimitMessage) - } - if len(pending) == 0 { - return nil, nil - } - now := time.Now() - if err := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}). - Where("phone = ? AND status = ?", oldPhone, constants.PhoneAssetAssociationStatusValid). - Updates(map[string]any{"phone": newPhone, "updated_at": now}).Error; err != nil { - if isAssociationUniqueViolation(err) { - return nil, errors.New(errors.CodeInvalidStatus, "新手机号已存在与待迁移资产相同的有效关联,换绑已回滚") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移手机号资产关联失败") - } - oldPhoneMasked, newPhoneMasked := sanitizer.MaskPhone(oldPhone), sanitizer.MaskPhone(newPhone) - changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(pending)) - for _, row := range pending { - changes = append(changes, accessauditapp.PhoneAssetAssociationChange{ - AssociationID: row.ID, PhoneMasked: newPhoneMasked, - AssetType: row.AssetType, AssetID: row.AssetID, - Status: constants.PhoneAssetAssociationStatusValid, Source: row.Source, - BeforeData: map[string]any{"phone_masked": oldPhoneMasked}, - AfterData: map[string]any{"phone_masked": newPhoneMasked}, - }) - } - return changes, nil -} - -// isAssociationUniqueViolation 判断错误是否为有效关系部分唯一索引冲突。 -func isAssociationUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - if !stderrors.As(err, &pgErr) { - return false - } - return pgErr.Code == "23505" && pgErr.ConstraintName == associationUniqueConstraint -} diff --git a/openspec/changes/enforce-single-phone-per-asset/.openspec.yaml b/openspec/changes/enforce-single-phone-per-asset/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/enforce-single-phone-per-asset/design.md b/openspec/changes/enforce-single-phone-per-asset/design.md new file mode 100644 index 00000000..3161ae67 --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/design.md @@ -0,0 +1,47 @@ +## Context + +承接《第1批-8-9月迭代缺陷修复技术方案》(下称“方案”)第 5、6 节。 + +- `AssociationWriter.Establish`(`internal/service/phone_asset_association/association_write.go`)只限制一个手机号最多 10 个资产;部分唯一索引 `uq_phone_asset_association_valid (phone, asset_type, asset_id)`(迁移 `000223`)只防同号同资产重复。 +- `BindPhone` 用同一号码同时写客户手机号与资产关联;`ChangePhone` 经 `migrateAssociations` 迁移关联;`need_bind_phone` 只看当前客户号码与资产是否关联。 +- `ensureNewAssetBindingAvailableWithTx`(`internal/service/exchange/service.go:1071`)查 `tb_personal_customer_device`,有人扫过码即拒绝;`switchCustomerBindingWithTx`(`:1089`)调用 `customerBinding.Migrate` 迁移客户绑定,`Migrate` 只有换货一个调用方。 +- 后台解除接口已存在(`internal/routes/phone_asset_association.go`):单条、按资产批量、CSV 导入、关联列表。 +- 测试库 2026-10-11:有效关联 0 条;强制绑定开关已在测试环境打开(提交 `f56113ba`)。 + +## Goals / Non-Goals + +**Goals:** +- 一资产一号,资产已绑定时不再出现资产侧绑定入口。 +- 换货能被后台解除放行,换货后旧资产与原客户、原号码脱钩。 + +**Non-Goals:** +- 不加数据库唯一约束(已确认用程序控制)。 +- 不做历史关联回填,不调整关联写入与强制绑定开关的耦合关系(方案第 5 节第 7 条,另行决定)。 +- 不改“新号已被其他客户持有返回 1184”。 +- 不新增后台解除接口。 + +## Decisions + +1. **一资产一号只做“先查后写”,不加资产锁。** `Establish` 写入前查询该资产的有效关联再判断;不加 Redis 锁、不锁资产行、不加应用锁。2026-10-11 确认:两个不同号码在同一时刻为同一资产完成短信验证的概率极低,后果只是该资产多一个号,可在后台解除,不值得为此加锁。手机号侧既有的十项名额串行(`LockPhoneScopes`)保持不变。 +2. **不加唯一约束。** 部分唯一索引 `(asset_type, asset_id) WHERE status = 1` 能兜底,但存量可能有多号资产,加约束前要先清理;已确认本批用程序控制,存量只读统计后交业务处理。 +3. **绑定入口按两侧状态拆写。** `bindExistingPrimaryPhone` 不再要求提交号码等于客户手机号;客户侧与资产侧分别判断是否写入。错误码在 `pkg/errors` 新增,提示不带号码。 +4. **`ChangePhone` 去掉 `migrateAssociations` 及其迁移审计。** 客户手机号与资产手机号独立,换号不应牵动资产。 +5. **换货错误号码放 `data`。** 统一错误处理会把错误信息写日志(`pkg/errors/handler.go`),`data` 只随响应返回;用 `errors.NewWithData` 承载 `{"phone": ...}`,错误码沿用现有冲突类。 +6. **换货清空旧资产客户绑定。** 新增 `customer_binding.ClearForExchange(ctx, tx, oldAssetType, oldAssetID)`:定位口径同原 `Migrate`;只处理 `status = 1`,置 0(沿用 `pcdOps.UpdateStatus`、`pciOps.GetByICCID` / `UpdateStatus`),审计角色用 `AuditResourceRolePersonalCustomerOldAssetBinding`。删除 `Migrate`、`migrateFromPCD`、`migrateFromPCI`。 +7. **换货失效旧资产关联复用存储方法。** 用 `LockValidByAsset` + `InvalidateByIDs`(`phone_asset_association_store.go:215、229`),不复用 `Service.unbindAsset`:它自开事务并做后台数据范围校验,放不进换货事务。新增失效方式常量 `exchange_completed`(该列无约束,无迁移)。 + +## Risks / Trade-offs + +- [历史上绑过号但没有关联记录的资产不会被换货拦截] → 取决于回填决定,本 Change 不处理。 +- [新资产已有钱包余额或生效套餐时,迁移会叠加] → 现状如此,不拦截。 +- [前端仍按旧 `need_bind_phone` 语义展示] → 测试环境交付时前端接入两个新布尔字段与 `data.phone`。 +- [同一资产并发绑定两个号码] → 不加锁,接受极小概率;交付前与日常用只读统计“同一资产多条有效关联”发现,后台解除多余的。 +- [原客户手上的旧资产页面在换货后失效] → 属预期,重新扫码即可进入,已在对外行为变化中告知运营与客服。 + +## Migration Plan + +无数据库迁移。测试环境交付前只读统计“同一资产多条有效关联”,有则出清单交业务在后台解除。 + +## Open Questions + +- 历史关联回填及口径、关联写入是否与强制绑定开关解耦:方案定为“发布时定”,不阻塞本 Change 开发。 diff --git a/openspec/changes/enforce-single-phone-per-asset/proposal.md b/openspec/changes/enforce-single-phone-per-asset/proposal.md new file mode 100644 index 00000000..a4d9504f --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/proposal.md @@ -0,0 +1,31 @@ +## Why + +现行规则只限制“一个手机号最多关联 10 个资产”,不限制一个资产能绑几个号;资产已被别人绑定时 H5 仍出现绑定入口;客户换号会把资产关联一并迁走。换货校验又看的是“是否有人扫过码”(`tb_personal_customer_device`),后台解除手机号也解不开,测试库 6 台设备全部被拦。依据为《第1批-8-9月迭代缺陷修复技术方案》第 5、6 节(F1、M12);原在途 Change `limit-phones-per-asset` 与本规则相反,已于 2026-10-11 作废并删除。 + +## What Changes + +- **BREAKING** 一个资产同时只能有一个有效手机号关联;同号重复验证幂等成功;其他号码绑定被拒,提示“该资产已绑定手机号,如需更换请联系管理员”,不带号码。换号须后台先解除再绑定。 +- **BREAKING** `need_bind_phone` 的资产侧判断改为“当前资产是否已有有效关联”;客户自己没有手机号时仍强制绑定客户手机号。登录响应新增两个布尔值:资产是否已绑定手机号、是否为当前客户自己的号码,不返回号码。 +- H5 绑定按“客户有无手机号 × 资产有无手机号”四种场景分别写客户手机号、资产手机号;资产侧号码可与客户手机号不同,两者不联动。 +- **BREAKING** 客户更换手机号不再迁移资产关联。 +- **BREAKING** 换货校验改为:新资产有有效手机号关联就拒绝,提示后台先解除;完整号码放在错误响应 `data.phone`,不进日志。 +- **BREAKING** 换货完成时清空旧资产上的客户绑定(不迁移到新资产),并失效旧资产的手机号关联(失效方式 `exchange_completed`),释放名额。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `phone-asset-association`:一资产一号、资产已绑定不强制、客户与资产手机号独立、换号不迁移关联、换货完成失效旧资产关联。 +- `order-refund-exchange`:换货新资产校验改看手机号关联;换货完成清空旧资产客户绑定、不迁移到新资产。 + +## Impact + +- 代码:`internal/service/phone_asset_association/association_write.go`(`Establish` 增加一资产一号判断,不加锁)、`internal/service/client_auth/service.go`(`need_bind_phone`、绑定入口拆分、`ChangePhone` 去掉 `migrateAssociations`)、`internal/model/dto/client_auth_dto.go`;`internal/service/exchange/service.go`(`ensureNewAssetBindingAvailableWithTx`、`switchCustomerBindingWithTx`)、`customer_binding` 新增 `ClearForExchange` 并删除只有换货使用的 `Migrate`;`phone_asset_association_store.go` 复用 `LockValidByAsset` + `InvalidateByIDs`;`pkg/errors` 新增错误码;`pkg/constants/phone_asset_association.go` 新增失效方式常量。 +- 接口:H5 登录与绑定响应新增字段与错误码;换货被拒响应 `data.phone`,测试环境前端需读取展示;重新生成接口文档。 +- 数据:无迁移(不加数据库唯一约束);交付前只读统计测试库“同一资产多条有效关联”(2026-10-11 为 0 条)。 +- 运营与客服:换货后原客户手上的旧资产页面失效,需重新扫码才能再打开旧资产(谁扫谁能打开,规则不变);资产换号须后台解除。 +- 已删除作废的 `openspec/changes/limit-phones-per-asset`(未实施、未归档)。 diff --git a/openspec/changes/enforce-single-phone-per-asset/specs/order-refund-exchange/spec.md b/openspec/changes/enforce-single-phone-per-asset/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..8bed0e58 --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/specs/order-refund-exchange/spec.md @@ -0,0 +1,52 @@ +## MODIFIED Requirements + +### Requirement: 换货业务数据迁移范围 + +系统 SHALL 仅在选择业务数据迁移的换货完成中迁移旧资产的钱包余额、有效套餐使用记录、累计充值字段和资产标签。资产归属 SHALL 继续作为换货完成固有动作,不受业务数据迁移选项控制。换货完成时系统 MUST 清空旧资产上的全部有效个人客户—资产绑定(设备按虚拟号、为空则 IMEI 定位;卡按虚拟号与 ICCID 两种绑定都清),置为无效而非物理删除,同事务写审计(原因“换货完成”);MUST NOT 把客户绑定迁移到新资产,新资产 MUST NOT 新增任何客户绑定;客户自己的手机号与微信绑定不变。清空后原客户已持有的旧资产 H5 令牌在下一次资产归属校验时失效;任何客户(含原客户)重新扫码旧资产时,按普通扫码登录重新建立绑定,系统 MUST NOT 因曾被换货而拒绝。手机号—资产关联 MUST NOT 随换货或业务数据迁移转移,旧资产关联按手机号—资产关联能力在换货完成时失效,新资产首次访问时按其适用的手机号绑定规则处理。 + +#### Scenario: 选择业务数据迁移完成换货 + +- **WHEN** 换货单选择业务数据迁移并成功确认完成 +- **THEN** 系统迁移钱包余额、有效套餐使用记录、累计充值字段和资产标签,且不迁移客户绑定与手机号—资产关联 + +#### Scenario: 不选择业务数据迁移完成换货 + +- **WHEN** 换货单未选择业务数据迁移并确认完成 +- **THEN** 系统仍完成资产归属的固有换货动作并清空旧资产客户绑定,但不迁移钱包余额、套餐使用记录、累计充值字段或资产标签 + +#### Scenario: 换货后原客户用旧令牌访问旧资产 + +- **WHEN** 换货完成后,原客户不重新扫码、直接用换货前的旧资产 H5 令牌访问旧资产 +- **THEN** 系统按资产不属于该客户拒绝访问 + +#### Scenario: 换货后重新扫码旧资产 + +- **WHEN** 换货完成后,原客户或其他客户重新扫码旧资产登录 +- **THEN** 系统按普通扫码建立新的有效绑定,可正常访问旧资产,且不重复标记资产已售 + +#### Scenario: 卡换货清空两种绑定 + +- **WHEN** 旧卡同时存在按虚拟号与按 ICCID 的有效客户绑定,换货完成 +- **THEN** 两种绑定都变为无效并写审计,新卡不新增客户绑定 + +## ADDED Requirements + +### Requirement: 换货新资产手机号关联校验 + +创建直接换货与确认物流换货完成时,系统 SHALL 检查新资产是否存在有效手机号—资产关联,存在则拒绝,错误信息为“新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请”,错误信息 MUST NOT 包含号码;完整号码 MUST 放在错误响应的 `data.phone` 中,MUST NOT 写入日志。系统 MUST NOT 再以“有人扫过码”(个人客户—资产绑定)作为拒绝依据。直接换货被拒时整体回滚、不留换货单;物流换货被拒时停在已发货待确认,后台解除关联后可重新确认。类型一致、旧资产状态可换、新资产未被其他换货单占用的既有校验不变。 + +#### Scenario: 新资产已绑定手机号 +- **WHEN** 后台为一项已与号码 P 有效关联的新资产创建直接换货 +- **THEN** 系统拒绝且不产生换货单,错误信息不含号码,`data.phone` 为 P + +#### Scenario: 解除后重新提交 +- **WHEN** 后台解除新资产的手机号关联后重新创建换货 +- **THEN** 换货创建成功 + +#### Scenario: 只被扫码过的新资产 +- **WHEN** 新资产没有有效手机号关联,但存在多个客户的扫码绑定 +- **THEN** 系统不因客户绑定拒绝换货 + +#### Scenario: 物流换货确认完成时被拒 +- **WHEN** 物流换货在确认完成时新资产存在有效手机号关联 +- **THEN** 换货单保持已发货待确认,解除关联后再次确认成功 diff --git a/openspec/changes/enforce-single-phone-per-asset/specs/phone-asset-association/spec.md b/openspec/changes/enforce-single-phone-per-asset/specs/phone-asset-association/spec.md new file mode 100644 index 00000000..e1aa1458 --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/specs/phone-asset-association/spec.md @@ -0,0 +1,111 @@ +## MODIFIED Requirements + +### Requirement: H5 验证建立关联与数量上限 + +系统 SHALL 保留既有全局 H5 强制绑定开关。开关开启时,客户首次登录一项尚无任何有效手机号关联的资产,必须完成短信验证码校验后才建立关系;资产已有有效关联时不再要求为该资产验证。开关关闭时登录不要求验证,且不查询、不新增、不删除任何关系,已有关系保留。关联只能由 H5 验证建立,卡(`iot_card`)与设备(`device`)分别计为独立资产。 + +一个资产同时 MUST 至多存在一条有效手机号关联;单手机号最多关联十项当前有效资产;两项上限都只统计当前有效关系,已失效关系不占用额度。建立关联前系统 MUST 查询该资产的有效关联:无有效关联时写入;有效关联的号码与本次相同时幂等成功、不产生第二条关系;有效关联为其他号码时 MUST 拒绝,提示“该资产已绑定手机号,如需更换请联系管理员”,提示与响应 MUST NOT 包含任何号码。所有建立关联的入口 MUST 经同一写入路径,MUST NOT 绕开上述判断。该判断不加资产锁、不加唯一约束:两个不同号码在同一时刻为同一资产完成验证时可能都写入,属已接受的极小概率情况,由只读统计“同一资产多条有效关联”发现后在后台解除。上线不回填历史客户—资产关系。 + +#### Scenario: 第十一项资产验证 + +- **WHEN** 已关联十项有效资产的手机号验证第十一项资产 +- **THEN** 系统拒绝本次新关联,已有十项关系不变 + +#### Scenario: 资产已绑定其他号码 + +- **GIVEN** 资产 A 已与号码 P 存在有效关系 +- **WHEN** 任一客户以号码 Q 通过接口为 A 绑定 +- **THEN** 系统拒绝并提示“该资产已绑定手机号,如需更换请联系管理员”,响应不含 P 或 Q,A 的关系不变 + +#### Scenario: 同号重复验证 + +- **WHEN** 号码 P 对已与 P 有效关联的资产再次完成验证 +- **THEN** 系统返回成功且不产生第二条有效关系 + +#### Scenario: 开关关闭后登录未关联资产 + +- **WHEN** 全局强制绑定开关关闭,客户登录一项未关联其手机号的资产 +- **THEN** 系统不要求验证、不新增关系,且既有关系不被删除 + +#### Scenario: 卡与设备分别计数 + +- **WHEN** 同一手机号关联了 `iot_card` 类型 ID 为 5 的资产与 `device` 类型 ID 为 5 的资产 +- **THEN** 两者计为两项资产,互不覆盖也不合并计数 + +### Requirement: 登录提示判定 + +系统 SHALL 在登录响应中以 `need_bind_phone` 表示当前客户或当前访问资产是否仍需完成手机号绑定,并额外返回两个布尔字段:当前资产是否已绑定手机号、该手机号是否为当前客户自己的号码;响应 MUST NOT 返回号码。开关开启时:客户无主手机号,返回 `true`(客户手机号仍强制绑定);客户有主手机号且当前资产尚无任何有效关联,返回 `true`;当前资产已有有效关联(无论号码属于谁)且客户有主手机号,返回 `false`。开关关闭时 `need_bind_phone` 恒为 `false`。该字段 MUST 只作为前端提示,系统 MUST NOT 因缺少关系拒绝任何资产业务入口。 + +#### Scenario: 已有手机号但资产未关联 + +- **WHEN** 客户已有主手机号,登录一项尚无任何有效关联的资产 +- **THEN** 响应 `need_bind_phone=true`,资产已绑定为 `false` + +#### Scenario: 资产已被他人号码绑定 + +- **WHEN** 客户已有主手机号,登录一项已与其他号码有效关联的资产 +- **THEN** 响应 `need_bind_phone=false`,资产已绑定为 `true`,是否本人号码为 `false`,且不返回号码 + +#### Scenario: 客户无手机号而资产已绑定 + +- **WHEN** 客户没有主手机号,登录一项已有有效关联的资产 +- **THEN** 响应 `need_bind_phone=true`,资产已绑定为 `true`,验证通过后只写客户手机号 + +#### Scenario: 缺关系不拦截业务接口 + +- **WHEN** 客户在 `need_bind_phone=true` 状态下调用其有权访问的资产业务接口 +- **THEN** 系统按既有资产归属与数据范围规则授权,不因缺少该关系返回拒绝 + +### Requirement: H5 绑定与换绑的关联写入 + +客户手机号与资产手机号 SHALL 相互独立,不要求一致、不联动。H5 短信验证成功后,系统 MUST 按客户与当前访问资产两侧状态分别写入: + +| 客户 | 资产 | 写入 | +|---|---|---| +| 无手机号 | 无有效关联 | 一次验证,同时写客户手机号与资产关联 | +| 有手机号 | 无有效关联 | 只建立资产关联,号码可与客户手机号不同,不修改客户手机号 | +| 无手机号 | 已有有效关联 | 只写客户手机号,不动资产关联 | +| 有手机号 | 已有有效关联 | 不需要绑定 | + +请求不含当前访问资产身份时,系统只完成客户手机号绑定,不建立关联。资产关联写入 MUST 遵守“H5 验证建立关联与数量上限”的一资产一号与十项上限。 + +客户更换手机号时 MUST 同时验证旧、新号码,只更换客户手机号,MUST NOT 迁移、复制或失效任何资产关联,也不受资产关联名额影响;新号码已被其他客户持有时的既有拒绝行为保持不变。 + +#### Scenario: 已有主号为新资产绑定不同号码 + +- **WHEN** 客户主手机号为 P,当前访问资产尚无关联,客户提交号码 Q 与有效验证码 +- **THEN** 系统建立 Q 与该资产的有效关系,客户手机号仍为 P + +#### Scenario: 无主号且资产无关联 + +- **WHEN** 客户无主手机号,当前访问资产尚无关联,客户提交号码 P 与有效验证码 +- **THEN** 系统同时写入客户手机号 P 与 P—资产有效关系 + +#### Scenario: 无主号而资产已绑定 + +- **WHEN** 客户无主手机号,当前访问资产已与号码 Q 有效关联,客户提交号码 P 与有效验证码 +- **THEN** 系统只写入客户手机号 P,资产仍只与 Q 关联 + +#### Scenario: 客户换号不迁移关联 + +- **WHEN** 客户把手机号从 P 换成 R +- **THEN** 客户手机号变为 R,P 的全部资产关联保持不变 + +### Requirement: 关联不影响换货与其他写入路径 + +资产导入、后台资产编辑与个人客户主手机号变更 MUST NOT 创建、推断、复制或迁移手机号—资产关系。换货 MUST NOT 把旧资产的关联转移到新资产;换货完成时系统 MUST 在同一事务内将旧资产的全部有效关联置为失效,失效方式为 `exchange_completed`,失效原因“换货完成”,操作人为确认换货完成的账号,写审计(手机号脱敏),释放该号码名额;旧资产无有效关联时跳过。新资产在客户首次 H5 访问时,按当时全局开关与其自身关联状态重新走验证。 + +#### Scenario: 换货不迁移关系并失效旧资产关联 + +- **WHEN** 换货完成并用新资产替换旧资产,旧资产与号码 P 存在有效关系 +- **THEN** 新资产不继承 P,旧资产与 P 的关系变为失效(`exchange_completed`),P 的有效关联数量减一 + +#### Scenario: 换货新资产首次登录 + +- **WHEN** 全局开关开启,客户换货完成后首次登录尚无关联的新资产 +- **THEN** 响应 `need_bind_phone=true` + +#### Scenario: 资产导入与后台资产编辑不产生关系 + +- **WHEN** 导入资产或修改后台资产属性 +- **THEN** 关联记录数不变,不产生任何手机号—资产关系 diff --git a/openspec/changes/enforce-single-phone-per-asset/tasks.md b/openspec/changes/enforce-single-phone-per-asset/tasks.md new file mode 100644 index 00000000..6567ca51 --- /dev/null +++ b/openspec/changes/enforce-single-phone-per-asset/tasks.md @@ -0,0 +1,27 @@ +## 1. F1 一资产一号 + +- [x] 1.1 `pkg/errors` 新增“该资产已绑定手机号,如需更换请联系管理员”错误码,提示不含号码。 +- [x] 1.2 `Establish` 写入前查询该资产有效关联:无则写入、同号幂等、他号拒绝;不加任何资产锁(Redis、行锁、应用锁都不加),手机号侧既有十项名额判断不变。 +- [x] 1.3 核对所有写资产关联的入口(H5 绑定、老客户给新资产绑定)都只经 `Establish`。 + +## 2. F1 H5 登录与绑定 + +- [x] 2.1 `need_bind_phone` 资产侧改为“当前资产是否已有有效关联”,客户无手机号仍为 true;登录响应 DTO 新增资产是否已绑定、是否本人号码两个布尔字段,不返回号码。 +- [x] 2.2 拆分 H5 绑定写入,按 delta spec 四种场景分别写客户手机号与资产关联;`bindExistingPrimaryPhone` 不再要求号码等于客户手机号。 +- [x] 2.3 `ChangePhone` 去掉 `migrateAssociations` 调用及对应迁移审计,其余校验不变。 + +## 3. M12 换货 + +- [x] 3.1 `ensureNewAssetBindingAvailableWithTx` 改查 `tb_phone_asset_association` 有效关联,用 `errors.NewWithData` 返回不含号码的错误信息与 `data.phone`;不再查客户绑定表。 +- [x] 3.2 `customer_binding` 新增 `ClearForExchange`(设备虚拟号或 IMEI、卡虚拟号与 ICCID 两边都清,`status 1 → 0`,同事务审计);`switchCustomerBindingWithTx` 改调用它;删除 `Migrate`、`migrateFromPCD`、`migrateFromPCI`。 +- [x] 3.3 `pkg/constants/phone_asset_association.go` 新增失效方式 `exchange_completed`;换货完成事务内用 `LockValidByAsset` + `InvalidateByIDs` 失效旧资产有效关联,失效原因“换货完成”,操作人为确认完成账号,写审计(手机号脱敏)。 +- [x] 3.4 更新换货接口说明(`data.phone`)与 H5 登录、绑定接口说明。 + +## 4. 文档与测试环境验证 + +- [x] 4.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [x] 4.2 只读统计测试库“同一资产多条有效关联”,有则出清单。 +- [ ] 4.3 F1(按 ENG-TEST-001,关联只经 H5 短信验证建立,不直接改库):资产已有号码 A 时任何客户进入资产侧不要求绑定;号码 B 绑定被拒、错误码与提示正确且不含号码;A 重复验证幂等;四种 H5 场景按 delta spec 写入;客户换号后资产关联不变;后台解除后资产侧重新要求绑定、B 可绑定;`associated_phones` 与审计正确。 +- [ ] 4.4 M12:给一台测试设备经 H5 建立关联后,直接换货被拒、不产生换货单、`data.phone` 正确且日志无完整号码 → 后台解除 → 重新提交成功;旧资产客户绑定全部无效、审计写入、原客户用旧令牌访问旧资产被拒、原客户重新扫码旧资产可正常进入且不重复标记已售;新资产不新增客户绑定;卡换货虚拟号与 ICCID 两种绑定都被清空;客户手机号不变;无关联但多人扫码的资产可直接换货;类型不一致、状态不可换、已被占用仍被拒;物流换货确认完成时被拒、解除后成功;完成后旧资产关联失效(`exchange_completed`)、名额释放。 +- [ ] 4.5 通知测试环境前端接入登录响应两个新布尔字段与换货 `data.phone`。 +- [ ] 4.6 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/pkg/constants/phone_asset_association.go b/pkg/constants/phone_asset_association.go index b1250c2b..eed75540 100644 --- a/pkg/constants/phone_asset_association.go +++ b/pkg/constants/phone_asset_association.go @@ -24,6 +24,10 @@ const ( PhoneAssetAssociationInvalidateMethodBackendBatch = "backend_batch" // PhoneAssetAssociationInvalidateMethodCSVImport 表示 CSV 导入解除。 PhoneAssetAssociationInvalidateMethodCSVImport = "csv_import" + // PhoneAssetAssociationInvalidateMethodExchangeCompleted 表示换货完成时随旧资产一并失效。 + PhoneAssetAssociationInvalidateMethodExchangeCompleted = "exchange_completed" + // PhoneAssetAssociationExchangeCompletedReason 换货完成失效旧资产关联的原因。 + PhoneAssetAssociationExchangeCompletedReason = "换货完成" ) // 手机号—资产关联的业务口径常量。 @@ -136,6 +140,8 @@ func PhoneAssetAssociationInvalidateMethodName(method string) string { return "后台批量解除" case PhoneAssetAssociationInvalidateMethodCSVImport: return "CSV导入解除" + case PhoneAssetAssociationInvalidateMethodExchangeCompleted: + return "换货完成失效" default: return "未知" } diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 264bc19d..d9c2cb39 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -157,6 +157,7 @@ const ( CodeRealnameNotAvailable = 1189 // 请先完成充值或购买套餐后再进行实名认证 CodeOpenAPITimestampInvalid = 1190 // 开放接口时间戳无效 CodeOpenAPINonceReplay = 1191 // 开放接口 nonce 重放 + CodeAssetPhoneAlreadyBound = 1192 // 资产已绑定其他手机号(一资产一号) // 换货相关错误 (1200-1209) CodeExchangeOrderNotFound = 1200 // 换货单不存在 @@ -328,6 +329,7 @@ var allErrorCodes = []int{ CodeRealnameNotAvailable, CodeOpenAPITimestampInvalid, CodeOpenAPINonceReplay, + CodeAssetPhoneAlreadyBound, CodeExchangeOrderNotFound, CodeExchangeInProgress, CodeExchangeStatusInvalid, @@ -477,6 +479,7 @@ var errorMessages = map[int]string{ CodeSmsSendFailed: "短信发送失败", CodeVerificationCodeInvalid: "验证码错误或已过期", CodePhoneAlreadyBound: "手机号已被其他客户绑定", + CodeAssetPhoneAlreadyBound: "该资产已绑定手机号,如需更换请联系管理员", CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定", CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配", CodeNeedRealname: "该套餐需实名认证后购买",