feat: 实现 C 端完整认证系统(client-auth-system)
实现面向个人客户的 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 表
This commit is contained in:
@@ -17,6 +17,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Role: admin.NewRoleHandler(svc.Role, validate),
|
||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
||||
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
|
||||
Shop: admin.NewShopHandler(svc.Shop),
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
|
||||
@@ -22,7 +22,7 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
||||
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
||||
|
||||
// 创建个人客户认证中间件
|
||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
|
||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Redis, deps.Logger)
|
||||
|
||||
// 创建 Token Manager(用于后台和H5认证)
|
||||
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
@@ -47,6 +48,7 @@ type services struct {
|
||||
Role *roleSvc.Service
|
||||
Permission *permissionSvc.Service
|
||||
PersonalCustomer *personalCustomerSvc.Service
|
||||
ClientAuth *clientAuthSvc.Service
|
||||
Shop *shopSvc.Service
|
||||
Auth *authSvc.Service
|
||||
ShopCommission *shopCommissionSvc.Service
|
||||
@@ -102,11 +104,25 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger)
|
||||
|
||||
return &services{
|
||||
Account: account,
|
||||
AccountAudit: accountAudit,
|
||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
|
||||
Account: account,
|
||||
AccountAudit: accountAudit,
|
||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
|
||||
ClientAuth: clientAuthSvc.New(
|
||||
deps.DB,
|
||||
s.PersonalCustomerOpenID,
|
||||
s.PersonalCustomer,
|
||||
s.PersonalCustomerDevice,
|
||||
s.PersonalCustomerPhone,
|
||||
s.IotCard,
|
||||
s.Device,
|
||||
wechatConfig,
|
||||
deps.VerificationService,
|
||||
deps.JWTManager,
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
),
|
||||
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
|
||||
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
||||
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
||||
|
||||
@@ -14,6 +14,8 @@ type stores struct {
|
||||
ShopRole *postgres.ShopRoleStore
|
||||
RolePermission *postgres.RolePermissionStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
@@ -68,6 +70,8 @@ func initStores(deps *Dependencies) *stores {
|
||||
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
||||
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
|
||||
@@ -15,6 +15,7 @@ type Handlers struct {
|
||||
Role *admin.RoleHandler
|
||||
Permission *admin.PermissionHandler
|
||||
PersonalCustomer *app.PersonalCustomerHandler
|
||||
ClientAuth *app.ClientAuthHandler
|
||||
Shop *admin.ShopHandler
|
||||
ShopRole *admin.ShopRoleHandler
|
||||
AdminAuth *admin.AuthHandler
|
||||
|
||||
165
internal/handler/app/client_auth.go
Normal file
165
internal/handler/app/client_auth.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var clientAuthValidator = validator.New()
|
||||
|
||||
// ClientAuthHandler C 端认证处理器
|
||||
type ClientAuthHandler struct {
|
||||
service *clientAuthSvc.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewClientAuthHandler 创建 C 端认证处理器
|
||||
func NewClientAuthHandler(service *clientAuthSvc.Service, logger *zap.Logger) *ClientAuthHandler {
|
||||
return &ClientAuthHandler{service: service, logger: logger}
|
||||
}
|
||||
|
||||
// VerifyAsset A1 资产验证
|
||||
// POST /api/c/v1/auth/verify-asset
|
||||
func (h *ClientAuthHandler) VerifyAsset(c *fiber.Ctx) error {
|
||||
var req dto.VerifyAssetRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("资产验证参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.VerifyAsset(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// WechatLogin A2 公众号登录
|
||||
// POST /api/c/v1/auth/wechat-login
|
||||
func (h *ClientAuthHandler) WechatLogin(c *fiber.Ctx) error {
|
||||
var req dto.WechatLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("公众号登录参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.WechatLogin(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// MiniappLogin A3 小程序登录
|
||||
// POST /api/c/v1/auth/miniapp-login
|
||||
func (h *ClientAuthHandler) MiniappLogin(c *fiber.Ctx) error {
|
||||
var req dto.MiniappLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("小程序登录参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.MiniappLogin(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// SendCode A4 发送验证码
|
||||
// POST /api/c/v1/auth/send-code
|
||||
func (h *ClientAuthHandler) SendCode(c *fiber.Ctx) error {
|
||||
var req dto.ClientSendCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("发送验证码参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.SendCode(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// BindPhone A5 绑定手机号
|
||||
// POST /api/c/v1/auth/bind-phone
|
||||
func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
var req dto.BindPhoneRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("绑定手机号参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// ChangePhone A6 更换手机号
|
||||
// POST /api/c/v1/auth/change-phone
|
||||
func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
var req dto.ChangePhoneRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("更换手机号参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.ChangePhone(c.UserContext(), customerID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
// POST /api/c/v1/auth/logout
|
||||
func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
resp, err := h.service.Logout(c.UserContext(), customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"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/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PersonalAuthMiddleware 个人客户认证中间件
|
||||
type PersonalAuthMiddleware struct {
|
||||
jwtManager *auth.JWTManager
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPersonalAuthMiddleware 创建个人客户认证中间件
|
||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, rdb *redis.Client, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||
return &PersonalAuthMiddleware{
|
||||
jwtManager: jwtManager,
|
||||
redis: rdb,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate 认证中间件
|
||||
// JWT + Redis 双重校验:先验证 JWT 签名和有效期,再检查 Redis 中 token 是否存在
|
||||
func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// 从 Authorization header 获取 token
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
m.logger.Warn("个人客户认证失败:缺少 Authorization header",
|
||||
@@ -36,7 +41,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
|
||||
}
|
||||
|
||||
// 检查 Bearer 前缀
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
m.logger.Warn("个人客户认证失败:Authorization header 格式错误",
|
||||
@@ -48,7 +52,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// 验证 token
|
||||
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
|
||||
if err != nil {
|
||||
m.logger.Warn("个人客户认证失败:token 验证失败",
|
||||
@@ -58,12 +61,35 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
|
||||
}
|
||||
|
||||
// 将客户信息存储到 context 中
|
||||
// Redis 有效性检查:token 必须在 Redis 中存在才视为有效
|
||||
// 支持服务端主动失效(封禁/强制下线/退出登录)
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(claims.CustomerID)
|
||||
storedToken, redisErr := m.redis.Get(context.Background(), redisKey).Result()
|
||||
if redisErr == redis.Nil {
|
||||
m.logger.Warn("个人客户认证失败:token 已被服务端失效",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.String("path", c.Path()),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||
}
|
||||
if redisErr != nil {
|
||||
m.logger.Error("个人客户认证:Redis 查询异常",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.Error(redisErr),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证服务异常,请稍后重试")
|
||||
}
|
||||
// 比对 Redis 中存储的 token 与当前请求 token 是否一致
|
||||
if storedToken != token {
|
||||
m.logger.Warn("个人客户认证失败:token 不匹配(可能已在其他设备登录)",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.String("path", c.Path()),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||
}
|
||||
|
||||
c.Locals("customer_id", claims.CustomerID)
|
||||
c.Locals("customer_phone", claims.Phone)
|
||||
|
||||
// 设置 SkipOwnerFilter 标记,跳过 B 端数据权限过滤
|
||||
// 个人客户不参与 RBAC 权限体系,不需要 Owner 过滤
|
||||
c.Locals("skip_owner_filter", true)
|
||||
|
||||
m.logger.Debug("个人客户认证成功",
|
||||
|
||||
103
internal/model/dto/client_auth_dto.go
Normal file
103
internal/model/dto/client_auth_dto.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package dto
|
||||
|
||||
// ========================================
|
||||
// A1 资产验证
|
||||
// ========================================
|
||||
|
||||
// VerifyAssetRequest A1 资产验证请求
|
||||
type VerifyAssetRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||
}
|
||||
|
||||
// VerifyAssetResponse A1 资产验证响应
|
||||
type VerifyAssetResponse struct {
|
||||
AssetToken string `json:"asset_token" description:"资产令牌(5分钟有效)"`
|
||||
ExpiresIn int `json:"expires_in" description:"过期时间(秒)"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A2 公众号登录
|
||||
// ========================================
|
||||
|
||||
// WechatLoginRequest A2 公众号登录请求
|
||||
type WechatLoginRequest struct {
|
||||
Code string `json:"code" validate:"required" required:"true" description:"微信OAuth授权码"`
|
||||
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||
}
|
||||
|
||||
// WechatLoginResponse A2/A3 登录统一响应
|
||||
type WechatLoginResponse struct {
|
||||
Token string `json:"token" description:"登录JWT令牌"`
|
||||
NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号"`
|
||||
IsNewUser bool `json:"is_new_user" description:"是否新创建用户"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A3 小程序登录
|
||||
// ========================================
|
||||
|
||||
// MiniappLoginRequest A3 小程序登录请求
|
||||
type MiniappLoginRequest struct {
|
||||
Code string `json:"code" validate:"required" required:"true" description:"小程序登录凭证"`
|
||||
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||
Nickname string `json:"nickname" description:"用户昵称(前端授权后传入)"`
|
||||
AvatarURL string `json:"avatar_url" description:"用户头像URL(前端授权后传入)"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A4 发送验证码
|
||||
// ========================================
|
||||
|
||||
// ClientSendCodeRequest A4 发送验证码请求
|
||||
type ClientSendCodeRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||
Scene string `json:"scene" validate:"required,oneof=bind_phone change_phone_old change_phone_new" required:"true" description:"业务场景 (bind_phone:绑定手机号, change_phone_old:换绑旧手机, change_phone_new:换绑新手机)"`
|
||||
}
|
||||
|
||||
// ClientSendCodeResponse A4 发送验证码响应
|
||||
type ClientSendCodeResponse struct {
|
||||
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A5 绑定手机号
|
||||
// ========================================
|
||||
|
||||
// BindPhoneRequest A5 绑定手机号请求
|
||||
type BindPhoneRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"验证码"`
|
||||
}
|
||||
|
||||
// BindPhoneResponse A5 绑定手机号响应
|
||||
type BindPhoneResponse struct {
|
||||
Phone string `json:"phone" description:"已绑定手机号"`
|
||||
BoundAt string `json:"bound_at" description:"绑定时间"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A6 换绑手机号
|
||||
// ========================================
|
||||
|
||||
// ChangePhoneRequest A6 换绑手机号请求
|
||||
type ChangePhoneRequest struct {
|
||||
OldPhone string `json:"old_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"旧手机号"`
|
||||
OldCode string `json:"old_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"旧手机号验证码"`
|
||||
NewPhone string `json:"new_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"新手机号"`
|
||||
NewCode string `json:"new_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"新手机号验证码"`
|
||||
}
|
||||
|
||||
// ChangePhoneResponse A6 换绑手机号响应
|
||||
type ChangePhoneResponse struct {
|
||||
Phone string `json:"phone" description:"换绑后手机号"`
|
||||
ChangedAt string `json:"changed_at" description:"换绑时间"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A7 退出登录
|
||||
// ========================================
|
||||
|
||||
// LogoutResponse A7 退出登录响应
|
||||
type LogoutResponse struct {
|
||||
Success bool `json:"success" description:"是否成功"`
|
||||
}
|
||||
23
internal/model/personal_customer_openid.go
Normal file
23
internal/model/personal_customer_openid.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerOpenID 个人客户 OpenID 关联模型
|
||||
// 保存客户在不同微信应用(公众号/小程序)下的 OpenID 记录
|
||||
// 同一客户可在多个 AppID 下拥有不同的 OpenID
|
||||
// 唯一约束:UNIQUE(app_id, open_id) WHERE deleted_at IS NULL
|
||||
type PersonalCustomerOpenID struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;index:idx_pco_customer_id;comment:关联个人客户ID" json:"customer_id"`
|
||||
AppID string `gorm:"column:app_id;type:varchar(100);not null;comment:微信应用标识(公众号或小程序AppID)" json:"app_id"`
|
||||
OpenID string `gorm:"column:open_id;type:varchar(100);not null;comment:当前应用下的OpenID" json:"open_id"`
|
||||
UnionID string `gorm:"column:union_id;type:varchar(100);not null;default:'';comment:微信开放平台统一标识(可选)" json:"union_id"`
|
||||
AppType string `gorm:"column:app_type;type:varchar(20);not null;default:'';comment:应用类型(official_account/miniapp)" json:"app_type"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PersonalCustomerOpenID) TableName() string {
|
||||
return "tb_personal_customer_openid"
|
||||
}
|
||||
@@ -6,12 +6,74 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
||||
// 路由挂载在 /api/c/v1 下
|
||||
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
||||
authBasePath := "/auth"
|
||||
authPublicGroup := router.Group(authBasePath)
|
||||
authProtectedGroup := router.Group(authBasePath)
|
||||
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{
|
||||
Summary: "资产验证",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.VerifyAssetRequest{},
|
||||
Output: &dto.VerifyAssetResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{
|
||||
Summary: "公众号登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.WechatLoginRequest{},
|
||||
Output: &dto.WechatLoginResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{
|
||||
Summary: "小程序登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.MiniappLoginRequest{},
|
||||
Output: &dto.WechatLoginResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/send-code", handlers.ClientAuth.SendCode, RouteSpec{
|
||||
Summary: "发送验证码",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.ClientSendCodeRequest{},
|
||||
Output: &dto.ClientSendCodeResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{
|
||||
Summary: "绑定手机号",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: &dto.BindPhoneRequest{},
|
||||
Output: &dto.BindPhoneResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/change-phone", handlers.ClientAuth.ChangePhone, RouteSpec{
|
||||
Summary: "更换手机号",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: &dto.ChangePhoneRequest{},
|
||||
Output: &dto.ChangePhoneResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/logout", handlers.ClientAuth.Logout, RouteSpec{
|
||||
Summary: "退出登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: nil,
|
||||
Output: &dto.LogoutResponse{},
|
||||
})
|
||||
|
||||
// 需要认证的路由
|
||||
authGroup := router.Group("")
|
||||
authGroup.Use(personalAuthMiddleware.Authenticate())
|
||||
|
||||
761
internal/service/client_auth/service.go
Normal file
761
internal/service/client_auth/service.go
Normal file
@@ -0,0 +1,761 @@
|
||||
// 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
|
||||
}
|
||||
58
internal/store/postgres/personal_customer_openid_store.go
Normal file
58
internal/store/postgres/personal_customer_openid_store.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerOpenIDStore 个人客户 OpenID 关联数据访问层
|
||||
type PersonalCustomerOpenIDStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPersonalCustomerOpenIDStore 创建个人客户 OpenID Store
|
||||
func NewPersonalCustomerOpenIDStore(db *gorm.DB) *PersonalCustomerOpenIDStore {
|
||||
return &PersonalCustomerOpenIDStore{db: db}
|
||||
}
|
||||
|
||||
// FindByAppIDAndOpenID 根据 AppID 和 OpenID 查询关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) FindByAppIDAndOpenID(ctx context.Context, appID, openID string) (*model.PersonalCustomerOpenID, error) {
|
||||
var record model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("app_id = ? AND open_id = ?", appID, openID).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// FindByUnionID 根据 UnionID 查询首条关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) FindByUnionID(ctx context.Context, unionID string) (*model.PersonalCustomerOpenID, error) {
|
||||
var record model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("union_id = ?", unionID).
|
||||
Order("id ASC").
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// Create 创建 OpenID 关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) Create(ctx context.Context, record *model.PersonalCustomerOpenID) error {
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
// ListByCustomerID 根据客户 ID 查询所有 OpenID 关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) ListByCustomerID(ctx context.Context, customerID uint) ([]*model.PersonalCustomerOpenID, error) {
|
||||
var records []*model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("id ASC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
Reference in New Issue
Block a user