2026-07-31 09:54:17 +08:00
|
|
|
|
// Package common 提供各业务端共用的鉴权、资源、钱包和账户范围能力。
|
2026-07-30 18:04:34 +08:00
|
|
|
|
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"
|
2026-09-13 00:57:32 +08:00
|
|
|
|
redis "github.com/redis/go-redis/v9"
|
2026-07-30 18:04:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
|
|
|
|
|
|
2026-09-13 00:57:32 +08:00
|
|
|
|
var ErrVerificationUnavailable = errcode.NewError(2410, "短信验证服务尚未配置")
|
|
|
|
|
|
|
2026-07-30 18:04:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-09-13 00:57:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-30 18:04:34 +08:00
|
|
|
|
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)
|
2026-09-13 00:57:32 +08:00
|
|
|
|
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})
|
2026-07-30 18:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// VerifyCode 校验并消费验证码。
|
|
|
|
|
|
func VerifyCode(client, phone, purpose, requestIdentity, code string) bool {
|
2026-09-13 00:57:32 +08:00
|
|
|
|
if !config.Spec.Global.MockVerificationEnabled || requestIdentity == "" || code == "" || impl.RedisService == nil || impl.RedisService.Client == nil {
|
2026-07-30 18:04:34 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
key := verificationKey(requestIdentity)
|
2026-09-13 00:57:32 +08:00
|
|
|
|
result, err := consumeVerificationCode.Run(impl.RedisService.Ctx, impl.RedisService.Client,
|
|
|
|
|
|
[]string{key}, client, strings.TrimSpace(phone), purpose, code).Int()
|
|
|
|
|
|
return err == nil && result == 1
|
2026-07-30 18:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 00:57:32 +08:00
|
|
|
|
// 一次性验证码在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
|
|
|
|
|
|
`)
|
|
|
|
|
|
|
2026-07-30 18:04:34 +08:00
|
|
|
|
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())
|
|
|
|
|
|
}
|