实现面向个人客户的 7 个认证接口(A1-A7),覆盖资产验证、 微信公众号/小程序登录、手机号绑定/换绑、退出登录完整流程。 主要变更: - 新增 PersonalCustomerOpenID 模型,支持多 AppID 多 OpenID 管理 - 实现有状态 JWT(JWT + Redis 双重校验),支持服务端主动失效 - 扩展微信 SDK:小程序 Code2Session + 3 个 DB 动态工厂函数 - 实现 A1 资产验证 IP 限流(30/min)和 A4 三层验证码限流 - 新增 7 个错误码(1180-1186)和 6 个 Redis Key 函数 - 注册 /api/c/v1/auth/* 下 7 个端点并更新 OpenAPI 文档 - 数据库迁移 000083:新建 tb_personal_customer_openid 表
762 lines
23 KiB
Go
762 lines
23 KiB
Go
// 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
|
|
}
|