// Package client_auth 提供 C 端认证业务逻辑 // 包含资产验证、微信登录、手机号绑定与退出登录等能力 package client_auth import ( "context" "regexp" "time" "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "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/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/wechat" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" "github.com/spf13/viper" "go.uber.org/zap" "gorm.io/gorm" ) 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 deviceBindStore *postgres.PersonalCustomerDeviceStore phoneStore *postgres.PersonalCustomerPhoneStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore wechatConfigService *wechatConfigSvc.Service verificationService *verification.Service jwtManager *auth.JWTManager redis *redis.Client logger *zap.Logger wechatCache kernel.CacheInterface } // New 创建 C 端认证服务实例 func New( db *gorm.DB, openidStore *postgres.PersonalCustomerOpenIDStore, customerStore *postgres.PersonalCustomerStore, deviceBindStore *postgres.PersonalCustomerDeviceStore, phoneStore *postgres.PersonalCustomerPhoneStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, wechatConfigService *wechatConfigSvc.Service, verificationService *verification.Service, jwtManager *auth.JWTManager, redisClient *redis.Client, logger *zap.Logger, ) *Service { return &Service{ db: db, openidStore: openidStore, customerStore: customerStore, deviceBindStore: deviceBindStore, phoneStore: phoneStore, iotCardStore: iotCardStore, deviceStore: deviceStore, wechatConfigService: wechatConfigService, verificationService: verificationService, jwtManager: jwtManager, redis: redisClient, logger: logger, wechatCache: wechat.NewRedisCache(redisClient), } } 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, err := s.resolveAsset(ctx, req.Identifier) if 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, "签发资产令牌失败") } return &dto.VerifyAssetResponse{ AssetToken: assetToken, ExpiresIn: assetTokenExpireSeconds, }, 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.GetActiveConfig(ctx) if err != nil { return nil, err } if wechatConfig == nil { return nil, errors.New(errors.CodeWechatConfigUnavailable) } 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) 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.GetActiveConfig(ctx) if err != nil { return nil, err } if wechatConfig == nil { return nil, errors.New(errors.CodeWechatConfigUnavailable) } 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) 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 绑定手机号 func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil { return nil, errors.New(errors.CodeAlreadyBoundPhone) } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil { return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err) } if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil { if existed.CustomerID != customerID { return nil, errors.New(errors.CodePhoneAlreadyBound) } return nil, errors.New(errors.CodeAlreadyBoundPhone) } else if err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败") } record := &model.PersonalCustomerPhone{ CustomerID: customerID, Phone: req.Phone, IsPrimary: true, Status: 1, } if err := s.phoneStore.Create(ctx, record); err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败") } return &dto.BindPhoneResponse{ Phone: req.Phone, BoundAt: record.VerifiedAt.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) } primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID) if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeOldPhoneMismatch) } if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") } if primary.Phone != req.OldPhone { return nil, errors.New(errors.CodeOldPhoneMismatch) } if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil { return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err) } if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil { return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err) } if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID { return nil, errors.New(errors.CodePhoneAlreadyBound) } else if err != nil && err != gorm.ErrRecordNotFound { return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败") } now := time.Now() if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}). Where("id = ? AND customer_id = ?", primary.ID, customerID). Updates(map[string]any{ "phone": req.NewPhone, "verified_at": now, "updated_at": now, }).Error; err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败") } return &dto.ChangePhoneResponse{ Phone: req.NewPhone, ChangedAt: now.Format("2006-01-02 15:04:05"), }, nil } // 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, error) { var card model.IotCard if err := s.db.WithContext(ctx). Where("iccid = ?", identifier). First(&card).Error; err == nil { return assetTypeIotCard, card.ID, nil } else if err != gorm.ErrRecordNotFound { return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") } var device model.Device if err := s.db.WithContext(ctx). Where("virtual_no = ? OR imei = ?", identifier, identifier). First(&device).Error; err == nil { return assetTypeDevice, device.ID, nil } else if err != gorm.ErrRecordNotFound { return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") } return "", 0, errors.New(errors.CodeAssetNotFound) } 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) return token.SignedString([]byte(viper.GetString("jwt.secret_key") + ":asset")) } func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) { if assetToken == "" { return nil, errors.New(errors.CodeInvalidParam) } parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New(errors.CodeInvalidToken) } return []byte(viper.GetString("jwt.secret_key") + ":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) { var ( customerID uint isNewUser bool ) err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType) if findErr != nil { return findErr } if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil { return bindErr } customerID = cid isNewUser = created return nil }) if err != nil { 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, 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, errors.New(errors.CodeCustomerNotFound) } return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败") } if customer.Status == 0 { return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用") } if nickname != "" && customer.Nickname != nickname { customer.Nickname = nickname } if avatar != "" && customer.AvatarURL != avatar { customer.AvatarURL = avatar } if saveErr := customerStore.Update(ctx, customer); saveErr != nil { return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败") } return customer.ID, false, nil } else if err != gorm.ErrRecordNotFound { return 0, false, 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, errors.New(errors.CodeCustomerNotFound) } return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败") } if customer.Status == 0 { return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用") } record := &model.PersonalCustomerOpenID{ CustomerID: customer.ID, AppID: appID, OpenID: openID, UnionID: unionID, AppType: appType, } if createErr := openidStore.Create(ctx, record); createErr != nil { return 0, false, 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 { return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败") } return customer.ID, false, nil } else if err != gorm.ErrRecordNotFound { return 0, false, 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, errors.Wrap(errors.CodeInternalError, err, "创建客户失败") } record := &model.PersonalCustomerOpenID{ CustomerID: newCustomer.ID, AppID: appID, OpenID: openID, UnionID: unionID, AppType: appType, } if err := openidStore.Create(ctx, record); err != nil { return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败") } return newCustomer.ID, true, nil } // bindAsset 绑定客户与资产关系 func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error { assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID) if err != nil { return err } var bindCount int64 if err := tx.WithContext(ctx). Model(&model.PersonalCustomerDevice{}). Where("virtual_no = ?", assetKey). Count(&bindCount).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败") } firstEverBind := bindCount == 0 bindStore := postgres.NewPersonalCustomerDeviceStore(tx) exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey) if err != nil { return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败") } if !exists { record := &model.PersonalCustomerDevice{ CustomerID: customerID, VirtualNo: assetKey, Status: 1, } if err := bindStore.Create(ctx, record); err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败") } } if firstEverBind { if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil { return err } } return nil } func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) { if assetType == assetTypeIotCard { var card model.IotCard if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil { if err == gorm.ErrRecordNotFound { return "", errors.New(errors.CodeAssetNotFound) } return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") } return card.ICCID, nil } if assetType == assetTypeDevice { var device model.Device if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil { if err == gorm.ErrRecordNotFound { return "", errors.New(errors.CodeAssetNotFound) } return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") } if device.VirtualNo != "" { return device.VirtualNo, nil } return device.IMEI, nil } return "", errors.New(errors.CodeInvalidParam) } func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error { if assetType == assetTypeIotCard { if err := tx.WithContext(ctx). Model(&model.IotCard{}). Where("id = ? AND asset_status = ?", assetID, 1). Update("asset_status", 2).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败") } return nil } if assetType == assetTypeDevice { if err := tx.WithContext(ctx). Model(&model.Device{}). Where("id = ? AND asset_status = ?", assetID, 1). Update("asset_status", 2).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败") } return nil } return errors.New(errors.CodeInvalidParam) } func (s *Service) issueLoginToken(ctx context.Context, customerID uint) (string, bool, error) { token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, "") 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, "保存登录状态失败") } needBindPhone := false if viper.GetBool("client.require_phone_binding") { if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == gorm.ErrRecordNotFound { needBindPhone = true } else if err != nil { return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败") } } return token, needBindPhone, 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 }