// Package common 提供各业务端共用的鉴权、资源、钱包和账户范围能力。 package common import ( "fmt" "regexp" "strings" "time" "git.apinb.com/bsm-sdk/core/crypto/token" "git.apinb.com/bsm-sdk/core/env" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" redis "github.com/redis/go-redis/v9" ) var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) var ErrVerificationUnavailable = errcode.NewError(2410, "短信验证服务尚未配置") var verificationPurposes = map[string]struct{}{ "login": {}, "register": {}, "reset_login_password": {}, "set_payment_password": {}, "reset_payment_password": {}, "bind_bank": {}, "unbind_bank": {}, } type verificationValue struct { Code string `json:"code"` Phone string `json:"phone"` Purpose string `json:"purpose"` Client string `json:"client"` } // ValidPhone 判断手机号是否符合中国大陆手机号格式。 func ValidPhone(phone string) bool { return phonePattern.MatchString(strings.TrimSpace(phone)) } // SendVerificationCode 创建一次性验证码。Mock 模式的验证码只保存在 Redis,不返回给客户端。 func SendVerificationCode(client string) gin.HandlerFunc { return func(ctx *gin.Context) { var request struct { Phone string `json:"phone" binding:"required"` Purpose string `json:"purpose" binding:"required"` } if ctx.ShouldBindJSON(&request) != nil || !ValidPhone(request.Phone) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } if _, ok := verificationPurposes[request.Purpose]; !ok { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } if !config.Spec.Global.MockVerificationEnabled { infra.Response.Error(ctx, ErrVerificationUnavailable) return } if impl.RedisService == nil || impl.RedisService.Client == nil { infra.Response.Error(ctx, errcode.ErrRedis) return } phone := strings.TrimSpace(request.Phone) throttleKey := impl.RedisService.BuildKey("client-verification-throttle", client, phone) var sent bool if impl.RedisService.Get(throttleKey, &sent) == nil && sent { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } requestIdentity := models.NewIdentity() value := verificationValue{Code: config.Spec.Global.MockVerificationCode, Phone: phone, Purpose: request.Purpose, Client: client} ttl := time.Duration(config.Spec.Global.VerificationTTLSeconds) * time.Second if err := impl.RedisService.Set(verificationKey(requestIdentity), value, ttl); err != nil { infra.Response.Error(ctx, err) return } _ = impl.RedisService.Set(throttleKey, true, time.Duration(config.Spec.Global.VerificationSendIntervalSeconds)*time.Second) infra.Response.Success(ctx, gin.H{"request_identity": requestIdentity, "expires_in": config.Spec.Global.VerificationTTLSeconds, "delivery_mode": "mock", "delivery_status": "not_sent", "retry_after": config.Spec.Global.VerificationSendIntervalSeconds}) } } // VerifyCode 校验并消费验证码。 func VerifyCode(client, phone, purpose, requestIdentity, code string) bool { if !config.Spec.Global.MockVerificationEnabled || requestIdentity == "" || code == "" || impl.RedisService == nil || impl.RedisService.Client == nil { return false } key := verificationKey(requestIdentity) result, err := consumeVerificationCode.Run(impl.RedisService.Ctx, impl.RedisService.Client, []string{key}, client, strings.TrimSpace(phone), purpose, code).Int() return err == nil && result == 1 } // 一次性验证码在Redis内完成校验和消费,两个并发请求只能有一个成功。 var consumeVerificationCode = redis.NewScript(` local raw = redis.call('GET', KEYS[1]) if not raw then return 0 end local ok, value = pcall(cjson.decode, raw) if not ok or type(value) ~= 'table' then return 0 end if value.client ~= ARGV[1] or value.phone ~= ARGV[2] or value.purpose ~= ARGV[3] or value.code ~= ARGV[4] then return 0 end redis.call('DEL', KEYS[1]) return 1 `) func verificationKey(identity string) string { return impl.RedisService.BuildKey("client-verification", identity) } // IssueToken 签发严格区分 user_app 和 service_app 的 JWT。 func IssueToken(identity, client, role string, extend map[string]string) (string, error) { return token.New(env.Runtime.JwtSecretKey).GenerateJwt(0, identity, client, role, nil, extend) } // RequireClient 验证客户端种类,阻止后台令牌跨端调用。 func RequireClient(client string) gin.HandlerFunc { return func(ctx *gin.Context) { claims, err := sdkmiddleware.ParseAuth(ctx) if err != nil || claims.Client != client { infra.Response.Error(ctx, errcode.ErrPermissionDenied) ctx.Abort() return } ctx.Next() } } // UserAccount 返回当前启用的用户账户。 func UserAccount(ctx *gin.Context) (models.UserAccount, bool) { claims, err := sdkmiddleware.ParseAuth(ctx) if err != nil { infra.Response.Error(ctx, err) return models.UserAccount{}, false } var account models.UserAccount if impl.DBService.Where("identity = ? AND status = ?", claims.Identity, 1).First(&account).Error != nil { infra.Response.Error(ctx, errcode.ErrPermissionDenied) return models.UserAccount{}, false } return account, true } // StaffAccount 返回当前启用的工作人员账户。 func StaffAccount(ctx *gin.Context) (models.StaffAccount, bool) { claims, err := sdkmiddleware.ParseAuth(ctx) if err != nil { infra.Response.Error(ctx, err) return models.StaffAccount{}, false } var account models.StaffAccount if impl.DBService.Where("identity = ? AND status = ?", claims.Identity, 1).First(&account).Error != nil { infra.Response.Error(ctx, errcode.ErrPermissionDenied) return models.StaffAccount{}, false } return account, true } // RecordNo 生成便于检索的业务流水号。 func RecordNo(prefix string) string { return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) }