// Package client_auth 提供 C 端认证业务逻辑 // 包含资产验证、微信登录、手机号绑定与退出登录等能力 package client_auth import ( "context" stderrors "errors" "regexp" "time" "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association" "github.com/break/junhong_cmp_fiber/internal/service/verification" wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/auth" "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/sanitizer" "github.com/break/junhong_cmp_fiber/pkg/wechat" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( assetTypeIotCard = "iot_card" assetTypeDevice = "device" appTypeOfficialAccount = "official_account" appTypeMiniapp = "miniapp" assetTokenExpireSeconds = 300 ) var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9_-]{1,50}$`) // Service C 端认证服务 type Service struct { db *gorm.DB openidStore *postgres.PersonalCustomerOpenIDStore customerStore *postgres.PersonalCustomerStore phoneStore *postgres.PersonalCustomerPhoneStore associationStore *postgres.PhoneAssetAssociationStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore wechatConfigService *wechatConfigSvc.Service verificationService *verification.Service jwtManager *auth.JWTManager redis *redis.Client logger *zap.Logger wechatCache kernel.CacheInterface customerBinding *customerBinding.Service accessAudit accessauditapp.Writer associationWriter *associationSvc.AssociationWriter } // New 创建 C 端认证服务实例 func New( db *gorm.DB, openidStore *postgres.PersonalCustomerOpenIDStore, customerStore *postgres.PersonalCustomerStore, phoneStore *postgres.PersonalCustomerPhoneStore, associationStore *postgres.PhoneAssetAssociationStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, wechatConfigService *wechatConfigSvc.Service, verificationService *verification.Service, jwtManager *auth.JWTManager, redisClient *redis.Client, logger *zap.Logger, binding *customerBinding.Service, accessAudit accessauditapp.Writer, ) *Service { return &Service{ db: db, openidStore: openidStore, customerStore: customerStore, phoneStore: phoneStore, associationStore: associationStore, iotCardStore: iotCardStore, deviceStore: deviceStore, wechatConfigService: wechatConfigService, verificationService: verificationService, jwtManager: jwtManager, redis: redisClient, logger: logger, wechatCache: wechat.NewRedisCache(redisClient), customerBinding: binding, accessAudit: accessAudit, associationWriter: associationSvc.NewAssociationWriter(associationStore, accessAudit), } } type assetTokenClaims struct { AssetType string `json:"asset_type"` AssetID uint `json:"asset_id"` jwt.RegisteredClaims } // VerifyAsset A1 验证资产并签发短期资产令牌 func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) { if req == nil || !identifierRegex.MatchString(req.Identifier) { return nil, errors.New(errors.CodeInvalidParam) } if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil { return nil, err } assetType, assetID, shopID, err := s.resolveAsset(ctx, req.Identifier) if err != nil { return nil, err } // 如果是卡类型,检查是否绑定了设备 // TODO: resolveAsset 可返回完整卡对象以避免二次查询,后续优化 if assetType == assetTypeIotCard { if bound, checkErr := s.checkCardBoundToDevice(ctx, assetID); checkErr != nil { return nil, checkErr } else if bound { return nil, errors.New(errors.CodeForbidden, "该卡绑定了设备,请使用设备的虚拟号/设备号/IMEI/SN登录") } } if err := s.ensureShopClientLoginAllowed(ctx, shopID); err != nil { return nil, err } assetToken, err := s.signAssetToken(assetType, assetID) if err != nil { s.logger.Error("签发资产令牌失败", zap.Error(err)) return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败") } resp := &dto.VerifyAssetResponse{ AssetToken: assetToken, ExpiresIn: assetTokenExpireSeconds, } wechatAuthorization, err := s.wechatConfigService.GetAuthorizationConfig(ctx) if err != nil { return nil, err } resp.OaAppID = wechatAuthorization.OaAppID resp.MiniappAppID = wechatAuthorization.MiniappAppID return resp, nil } // WechatLogin A2 公众号登录 func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } assetClaims, err := s.verifyAssetToken(req.AssetToken) if err != nil { return nil, err } wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx) if err != nil { return nil, err } oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger) if err != nil { s.logger.Error("创建公众号实例失败", zap.Error(err)) return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用") } oaService := wechat.NewOfficialAccountService(oaApp, s.logger) userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code) if err != nil { return nil, err } customerID, isNewUser, err := s.loginByOpenID( ctx, assetClaims.AssetType, assetClaims.AssetID, wechatConfig.OaAppID, userInfo.OpenID, userInfo.UnionID, userInfo.Nickname, userInfo.Avatar, appTypeOfficialAccount, ) if err != nil { return nil, err } token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) if err != nil { return nil, err } s.logger.Info("公众号登录成功", zap.Uint("customer_id", customerID), zap.String("client_ip", clientIP), ) return &dto.WechatLoginResponse{ Token: token, NeedBindPhone: needBindPhone, IsNewUser: isNewUser, }, nil } // MiniappLogin A3 小程序登录 func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } assetClaims, err := s.verifyAssetToken(req.AssetToken) if err != nil { return nil, err } wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx) if err != nil { return nil, err } miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger) if err != nil { s.logger.Error("创建小程序服务失败", zap.Error(err)) return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用") } openID, unionID, _, err := miniService.Code2Session(ctx, req.Code) if err != nil { return nil, err } customerID, isNewUser, err := s.loginByOpenID( ctx, assetClaims.AssetType, assetClaims.AssetID, wechatConfig.MiniappAppID, openID, unionID, req.Nickname, req.AvatarURL, appTypeMiniapp, ) if err != nil { return nil, err } token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID) if err != nil { return nil, err } s.logger.Info("小程序登录成功", zap.Uint("customer_id", customerID), zap.String("client_ip", clientIP), ) return &dto.WechatLoginResponse{ Token: token, NeedBindPhone: needBindPhone, IsNewUser: isNewUser, }, nil } // SendCode A4 发送验证码 func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) { if req == nil || req.Phone == "" { return nil, errors.New(errors.CodeInvalidParam) } if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil { return nil, err } if err := s.verificationService.SendCode(ctx, req.Phone); err != nil { s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err)) return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败") } cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone) if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil { s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err)) return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败") } return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil } // 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 { return nil, errors.New(errors.CodeInvalidParam) } if s.db == nil || s.accessAudit == nil || s.associationStore == nil { return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置") } customer, err := s.customerStore.GetByID(ctx, customerID) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") } primary, primaryErr := s.phoneStore.GetPrimaryPhone(ctx, customerID) if primaryErr == nil { return s.bindExistingPrimaryPhone(ctx, customer, primary, assetType, assetID, req) } if primaryErr != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败") } if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil { appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr) return nil, appErr } if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil { appErr := errors.New(errors.CodeAlreadyBoundPhone) if existed.CustomerID != customerID { appErr = errors.New(errors.CodePhoneAlreadyBound) } s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr) return nil, appErr } else if err != gorm.ErrRecordNotFound { appErr := errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败") s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, appErr) return nil, appErr } now := time.Now() record := &model.PersonalCustomerPhone{ CustomerID: customerID, Phone: req.Phone, IsPrimary: true, VerifiedAt: &now, Status: 1, } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 固定加锁次序:先取手机号串行化点(advisory),再按 id ASC 锁行。 if err := s.lockPhoneScopesInOrder(ctx, tx, req.Phone); err != nil { return err } if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") } var count int64 if err := tx.Model(&model.PersonalCustomerPhone{}). Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).Count(&count).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } if count > 0 { return errors.New(errors.CodeAlreadyBoundPhone) } var existed model.PersonalCustomerPhone if err := tx.Where("phone = ? AND status = ?", req.Phone, 1).First(&existed).Error; err == nil { if existed.CustomerID != customerID { return errors.New(errors.CodePhoneAlreadyBound) } return errors.New(errors.CodeAlreadyBoundPhone) } else if err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败") } if err := tx.Create(record).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败") } if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil, map[string]any{"phone": sanitizer.MaskPhone(record.Phone)}, "手机号已绑定", constants.AuditResultSuccess, )); err != nil { return err } // 建联与账号手机号同事务:超限或写入失败时账号手机号一并回滚。 if _, err := s.establishAssociation(ctx, tx, customer, record.Phone, assetType, assetID); err != nil { return err } return nil }) if err != nil { s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err) return nil, err } return &dto.BindPhoneResponse{ Phone: req.Phone, BoundAt: now.Format("2006-01-02 15:04:05"), }, nil } // bindExistingPrimaryPhone 处理已有主手机号的绑定请求。 // 提交号码等于主号且验证码有效时幂等建立关联且不改账号手机号;号码不一致仍拒绝换号。 func (s *Service) bindExistingPrimaryPhone( ctx context.Context, customer *model.PersonalCustomer, primary *model.PersonalCustomerPhone, assetType string, assetID uint, req *dto.BindPhoneRequest, ) (*dto.BindPhoneResponse, error) { if primary.Phone != req.Phone { appErr := errors.New(errors.CodeAlreadyBoundPhone) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr) return nil, appErr } if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil { appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr) return nil, appErr } 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 { return err } if err := s.lockPhoneRowsInIDOrder(ctx, tx, []uint{primary.ID}); err != nil { return err } var locked model.PersonalCustomerPhone if err := tx.WithContext(ctx). Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customer.ID, true, 1). First(&locked).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeAlreadyBoundPhone) } return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } if locked.Phone != req.Phone { // 并发换绑已把主号改成其他号码:提交号码不再等于账号手机号,按既有语义拒绝。 return errors.New(errors.CodeAlreadyBoundPhone) } _, err := s.establishAssociation(ctx, tx, customer, locked.Phone, assetType, assetID) return err }) if err != nil { s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, primary, err) return nil, err } return &dto.BindPhoneResponse{ Phone: req.Phone, BoundAt: now.Format("2006-01-02 15:04:05"), }, nil } // ChangePhone A6 换绑手机号 func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } if s.db == nil || s.accessAudit == nil { return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置") } customer, err := s.customerStore.GetByID(ctx, customerID) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") } primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID) if err == gorm.ErrRecordNotFound { appErr := errors.New(errors.CodeOldPhoneMismatch) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, nil, appErr) return nil, appErr } if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, nil, appErr) return nil, appErr } if primary.Phone != req.OldPhone { appErr := errors.New(errors.CodeOldPhoneMismatch) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr) return nil, appErr } if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil { appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr) return nil, appErr } if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil { appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr) return nil, appErr } 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 锁定两侧有效关系行。 // 换绑两行的加锁顺序与 bind-phone 的单行加锁一致,两条路径不会形成 A→B / B→A 死锁环。 if idErr := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone, req.NewPhone); idErr != nil { return idErr } phoneIDs, idErr := s.phoneRowIDsByPhone(ctx, tx, primary.Phone, req.NewPhone) if idErr != nil { return idErr } if idErr := s.lockPhoneRowsInIDOrder(ctx, tx, phoneIDs); idErr != nil { return idErr } // 手机号行已在本事务内锁定,此处只复核主号归属与号码,不重复加锁。 if err := tx.WithContext(ctx). Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1). First(primary).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeOldPhoneMismatch) } return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } if primary.Phone != req.OldPhone { return errors.New(errors.CodeOldPhoneMismatch) } current := *primary failurePhone = ¤t beforeData = map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)} var existed model.PersonalCustomerPhone if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID { return errors.New(errors.CodePhoneAlreadyBound) } 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, "updated_at": now, }).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "更新手机号失败") } primary.Phone = req.NewPhone primary.VerifiedAt = &now if err := 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 { failurePhone = primary } s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, failurePhone, err) return nil, err } return &dto.ChangePhoneResponse{ Phone: req.NewPhone, ChangedAt: now.Format("2006-01-02 15:04:05"), }, nil } func personalPhoneAudit( actionCode, summary string, customer *model.PersonalCustomer, phone *model.PersonalCustomerPhone, beforeData, afterData map[string]any, subjectSummary, result string, ) accessauditapp.ChangeAudit { change := accessauditapp.ChangeAudit{ ActionCode: actionCode, Summary: summary, Result: result, OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, PersonalCustomer: customer, SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: subjectSummary, SubjectData: afterData, } if phone != nil && phone.ID != 0 { change.PersonalPhones = []accessauditapp.PersonalCustomerPhoneChange{{ Phone: phone, BeforeData: beforeData, AfterData: afterData, }} } return change } func (s *Service) recordPersonalFailure( ctx context.Context, actionCode, summary string, customer *model.PersonalCustomer, phone *model.PersonalCustomerPhone, originalErr error, ) { if customer == nil || customer.ID == 0 { return } subjectSummary := "个人身份资料操作失败" change := personalPhoneAudit(actionCode, summary, customer, phone, nil, nil, subjectSummary, personalAuditFailureResult(originalErr)) accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, change, originalErr) } func personalAuditFailureResult(err error) string { var appErr *errors.AppError if stderrors.As(err, &appErr) { switch appErr.Code { case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound, errors.CodeAlreadyBoundPhone, errors.CodePhoneAlreadyBound, errors.CodeOldPhoneMismatch, errors.CodeVerificationCodeInvalid: return constants.AuditResultDenied } } return constants.AuditResultFailed } // Logout A7 退出登录 func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) { redisKey := constants.RedisPersonalCustomerTokenKey(customerID) if err := s.redis.Del(ctx, redisKey).Err(); err != nil { return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败") } return &dto.LogoutResponse{Success: true}, nil } func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error { if clientIP == "" { return nil } key := constants.RedisClientAuthRateLimitIPKey(clientIP) count, err := s.redis.Incr(ctx, key).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败") } if count == 1 { if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil { return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败") } } if count > 30 { return errors.New(errors.CodeTooManyRequests) } return nil } func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, *uint, error) { var card model.IotCard if err := s.db.WithContext(ctx). Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier). First(&card).Error; err == nil { return assetTypeIotCard, card.ID, card.ShopID, nil } else if err != gorm.ErrRecordNotFound { return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") } var device model.Device if err := s.db.WithContext(ctx). Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier). First(&device).Error; err == nil { return assetTypeDevice, device.ID, device.ShopID, nil } else if err != gorm.ErrRecordNotFound { return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") } return "", 0, nil, errors.New(errors.CodeAssetNotFound) } // ensureShopClientLoginAllowed 在签发资产令牌前检查店铺的新登录限制。 func (s *Service) ensureShopClientLoginAllowed(ctx context.Context, shopID *uint) error { if shopID == nil { return nil } var shop model.Shop err := s.db.WithContext(ctx).Select("client_login_disabled").First(&shop, *shopID).Error if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeForbidden, "资产所属店铺不可用") } if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺C端登录限制失败") } if shop.ClientLoginDisabled { return errors.New(errors.CodeForbidden, "该店铺暂不允许C端登录") } return nil } func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) { now := time.Now() claims := &assetTokenClaims{ AssetType: assetType, AssetID: assetID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) cfg := config.Get() if cfg == nil || cfg.JWT.SecretKey == "" { return "", errors.New(errors.CodeInternalError, "JWT 密钥未配置") } return token.SignedString([]byte(cfg.JWT.SecretKey + ":asset")) } func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) { if assetToken == "" { return nil, errors.New(errors.CodeInvalidParam) } cfg := config.Get() if cfg == nil || cfg.JWT.SecretKey == "" { return nil, errors.New(errors.CodeInternalError, "JWT 密钥未配置") } parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New(errors.CodeInvalidToken) } return []byte(cfg.JWT.SecretKey + ":asset"), nil }) if err != nil { return nil, errors.New(errors.CodeInvalidToken) } claims, ok := parsed.Claims.(*assetTokenClaims) if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" { return nil, errors.New(errors.CodeInvalidToken) } return claims, nil } func (s *Service) loginByOpenID( ctx context.Context, assetType string, assetID uint, appID string, openID string, unionID string, nickname string, avatar string, appType string, ) (uint, bool, error) { if s.db == nil || s.accessAudit == nil { return 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置") } var ( customerID uint isNewUser bool identityAudit *accessauditapp.ChangeAudit ) err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { cid, created, change, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType) customerID = cid identityAudit = change isNewUser = created if findErr != nil { return findErr } if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil { return bindErr } if identityAudit != nil { return s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit) } return nil }) if err != nil { if identityAudit != nil && customerID != 0 && !isNewUser { if identityAudit.ActionCode == constants.AuditActionPersonalCustomerProfileUpdated { identityAudit.Summary = "同步个人资料失败" identityAudit.SubjectSummary = "个人资料同步失败" } else { identityAudit.Summary = "同步个人微信主体失败" identityAudit.SubjectSummary = "微信登录身份同步失败" } identityAudit.Result = personalAuditFailureResult(err) identityAudit.SubjectData = nil identityAudit.PersonalOpenIDs = nil restorePersonalCustomerSnapshot(identityAudit) accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, *identityAudit, err) } return 0, false, err } return customerID, isNewUser, nil } // findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户 func (s *Service) findOrCreateCustomer( ctx context.Context, tx *gorm.DB, appID string, openID string, unionID string, nickname string, avatar string, appType string, ) (uint, bool, *accessauditapp.ChangeAudit, error) { openidStore := postgres.NewPersonalCustomerOpenIDStore(tx) customerStore := postgres.NewPersonalCustomerStore(tx, s.redis) if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil { customer, getErr := customerStore.GetByID(ctx, existed.CustomerID) if getErr != nil { if getErr == gorm.ErrRecordNotFound { return 0, false, nil, errors.New(errors.CodeCustomerNotFound) } return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败") } if customer.Status == 0 { change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied) return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用") } beforeData := personalCustomerProfileData(customer) changed := false if nickname != "" && customer.Nickname != nickname { customer.Nickname = nickname changed = true } if avatar != "" && customer.AvatarURL != avatar { customer.AvatarURL = avatar changed = true } var change *accessauditapp.ChangeAudit if changed { pending := personalProfileSyncAudit(customer, beforeData) change = &pending } if saveErr := customerStore.Update(ctx, customer); saveErr != nil { return customer.ID, false, change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败") } return customer.ID, false, change, nil } else if err != gorm.ErrRecordNotFound { return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败") } if unionID != "" { if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil { customer, getErr := customerStore.GetByID(ctx, existed.CustomerID) if getErr != nil { if getErr == gorm.ErrRecordNotFound { return 0, false, nil, errors.New(errors.CodeCustomerNotFound) } return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败") } if customer.Status == 0 { change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied) return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用") } beforeData := personalCustomerProfileData(customer) record := &model.PersonalCustomerOpenID{ CustomerID: customer.ID, AppID: appID, OpenID: openID, UnionID: unionID, AppType: appType, } change := personalWechatAudit(customer, record, beforeData, nil, appID, appType, constants.AuditResultSuccess) if createErr := openidStore.Create(ctx, record); createErr != nil { return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败") } if nickname != "" && customer.Nickname != nickname { customer.Nickname = nickname } if avatar != "" && customer.AvatarURL != avatar { customer.AvatarURL = avatar } if saveErr := customerStore.Update(ctx, customer); saveErr != nil { change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess) return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败") } change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess) return customer.ID, false, &change, nil } else if err != gorm.ErrRecordNotFound { return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败") } } newCustomer := &model.PersonalCustomer{ WxOpenID: openID, WxUnionID: unionID, Nickname: nickname, AvatarURL: avatar, Status: 1, } if err := customerStore.Create(ctx, newCustomer); err != nil { return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "创建客户失败") } record := &model.PersonalCustomerOpenID{ CustomerID: newCustomer.ID, AppID: appID, OpenID: openID, UnionID: unionID, AppType: appType, } change := personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess) if err := openidStore.Create(ctx, record); err != nil { return newCustomer.ID, true, &change, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败") } change = personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess) return newCustomer.ID, true, &change, nil } func personalProfileSyncAudit(customer *model.PersonalCustomer, beforeData map[string]any) accessauditapp.ChangeAudit { return accessauditapp.ChangeAudit{ ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "同步个人资料", Result: constants.AuditResultSuccess, OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalCustomerProfileData(customer), SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已同步", SubjectData: personalCustomerProfileData(customer), } } func personalWechatAudit( customer *model.PersonalCustomer, openID *model.PersonalCustomerOpenID, beforeData, afterData map[string]any, appID, appType, result string, ) accessauditapp.ChangeAudit { change := accessauditapp.ChangeAudit{ ActionCode: constants.AuditActionPersonalCustomerWechatIdentityUpdated, Summary: "同步个人微信主体", Result: result, OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, PersonalCustomer: customer, BeforeData: beforeData, AfterData: afterData, SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "微信登录身份已同步", SubjectData: map[string]any{"app_id": appID, "app_type": appType}, } if openID != nil && openID.ID != 0 { change.PersonalOpenIDs = []accessauditapp.PersonalCustomerOpenIDChange{{ OpenID: openID, AfterData: map[string]any{"app_id": openID.AppID, "app_type": openID.AppType}, }} } return change } func personalCustomerProfileData(customer *model.PersonalCustomer) map[string]any { return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL} } func restorePersonalCustomerSnapshot(change *accessauditapp.ChangeAudit) { if change.PersonalCustomer == nil || change.BeforeData == nil { return } customer := *change.PersonalCustomer if nickname, ok := change.BeforeData["nickname"].(string); ok { customer.Nickname = nickname } if avatarURL, ok := change.BeforeData["avatar_url"].(string); ok { customer.AvatarURL = avatarURL } change.PersonalCustomer = &customer } // checkCardBoundToDevice 检查卡是否绑定了设备 // 返回 true 表示已绑定设备,false 表示未绑定 func (s *Service) checkCardBoundToDevice(ctx context.Context, cardID uint) (bool, error) { var card model.IotCard if err := s.db.WithContext(ctx).Select("is_standalone", "device_virtual_no").First(&card, cardID).Error; err != nil { if err == gorm.ErrRecordNotFound { return false, errors.New(errors.CodeAssetNotFound) } return false, errors.Wrap(errors.CodeInternalError, err, "查询卡绑定状态失败") } // is_standalone = false 表示已绑定设备,或者 device_virtual_no 不为空也表示已绑定 return !card.IsStandalone || card.DeviceVirtualNo != "", nil } // bindAsset 委托 CustomerBinding 模块处理客户与资产的绑定关系 func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error { return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID) } // issueLoginToken 签发登录令牌并判定是否需要手机号验证。 // 三支判定:无主手机号 → true;有主手机号但未与当前访问资产存在有效关系 → true; // 已存在有效关系 → 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) { // 查询用户已绑定的主手机号,写入 JWT,供后续接口直接从 context 取用 var boundPhone string needBindPhone := false cfg := config.Get() requirePhoneBinding := true if cfg != nil { requirePhoneBinding = cfg.Client.RequirePhoneBinding } primaryPhone, phoneErr := s.phoneStore.GetPrimaryPhone(ctx, customerID) if phoneErr == nil { boundPhone = primaryPhone.Phone } else if phoneErr != gorm.ErrRecordNotFound { return "", false, 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 } } token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID) if err != nil { return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败") } claims, err := s.jwtManager.VerifyPersonalCustomerToken(token) if err != nil { return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败") } ttl := time.Until(claims.ExpiresAt.Time) if ttl <= 0 { ttl = 24 * time.Hour } redisKey := constants.RedisPersonalCustomerTokenKey(customerID) if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil { return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败") } return token, needBindPhone, nil } // DevLogin 开发环境测试登录 // 根据资产标识符查找或创建测试客户并直接签发 JWT,无需微信 OAuth // ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用 func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) { if s.db == nil || s.accessAudit == nil { return "", 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置") } assetType, assetID, _, err := s.resolveAsset(ctx, identifier) if err != nil { return "", 0, false, err } var ( customerID uint isNewUser bool ) // 在事务中查找或创建测试客户并绑定资产 // 使用固定的开发测试 OpenID(dev_test_+identifier),避免重复创建 err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { devOpenID := "dev_test_" + identifier devAppID := "dev_test_app" cid, created, identityAudit, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev") if findErr != nil { return findErr } if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil { return bindErr } if identityAudit != nil { if auditErr := s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit); auditErr != nil { return auditErr } } customerID = cid isNewUser = created return nil }) if err != nil { return "", 0, false, err } token, _, err := s.issueLoginToken(ctx, customerID, assetType, assetID) if err != nil { return "", 0, false, err } s.logger.Info("开发环境测试登录成功", zap.Uint("customer_id", customerID), zap.String("identifier", identifier), ) return token, customerID, isNewUser, nil } func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error { phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone) exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败") } if exists > 0 { return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试") } ipKey := constants.RedisClientSendCodeIPHourKey(clientIP) ipCount, err := s.redis.Incr(ctx, ipKey).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败") } if ipCount == 1 { if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil { return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败") } } if ipCount > 20 { return errors.New(errors.CodeTooManyRequests) } phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone) phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败") } if phoneDayCount == 1 { nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour) ttl := time.Until(nextDay) if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil { return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败") } } if phoneDayCount > 10 { return errors.New(errors.CodeTooManyRequests) } return nil }