Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付 (C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件 经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空 历史支付继续按 payment_config_id 双读。凭证版本化加载与 ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部 引用。
1031 lines
37 KiB
Go
1031 lines
37 KiB
Go
// 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"
|
||
"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/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
|
||
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
|
||
}
|
||
|
||
// New 创建 C 端认证服务实例
|
||
func New(
|
||
db *gorm.DB,
|
||
openidStore *postgres.PersonalCustomerOpenIDStore,
|
||
customerStore *postgres.PersonalCustomerStore,
|
||
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||
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,
|
||
iotCardStore: iotCardStore,
|
||
deviceStore: deviceStore,
|
||
wechatConfigService: wechatConfigService,
|
||
verificationService: verificationService,
|
||
jwtManager: jwtManager,
|
||
redis: redisClient,
|
||
logger: logger,
|
||
wechatCache: wechat.NewRedisCache(redisClient),
|
||
customerBinding: binding,
|
||
accessAudit: 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 绑定手机号
|
||
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 s.db == nil || s.accessAudit == nil {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
|
||
}
|
||
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
|
||
appErr := errors.New(errors.CodeAlreadyBoundPhone)
|
||
if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil {
|
||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||
}
|
||
return nil, appErr
|
||
} else if err != gorm.ErrRecordNotFound {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||
}
|
||
customer, err := s.customerStore.GetByID(ctx, customerID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
|
||
}
|
||
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 {
|
||
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, "创建手机号绑定记录失败")
|
||
}
|
||
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
|
||
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
|
||
map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess,
|
||
))
|
||
})
|
||
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
|
||
}
|
||
|
||
// 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
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
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": 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, "查询新手机号绑定关系失败")
|
||
}
|
||
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
|
||
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
|
||
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
|
||
map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess,
|
||
))
|
||
})
|
||
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)
|
||
}
|
||
|
||
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, "查询手机号绑定关系失败")
|
||
} else if requirePhoneBinding {
|
||
needBindPhone = true
|
||
}
|
||
|
||
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
|
||
}
|