实现个人客户微信认证和短信验证功能
- 添加个人客户微信登录和手机验证码登录接口 - 实现个人客户设备、ICCID、手机号关联管理 - 添加短信发送服务(HTTP 客户端) - 添加微信认证服务(含 mock 实现) - 添加 JWT Token 生成和验证工具 - 创建数据库迁移脚本(personal_customer 关联表) - 修复测试文件中的路由注册参数错误 - 重构 scripts 目录结构(分离独立脚本到子目录) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,22 +4,29 @@ import (
|
||||
pkgGorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
|
||||
)
|
||||
|
||||
// Bootstrap 初始化所有业务组件并返回 Handlers
|
||||
// BootstrapResult Bootstrap 初始化结果
|
||||
type BootstrapResult struct {
|
||||
Handlers *Handlers
|
||||
Middlewares *Middlewares
|
||||
}
|
||||
|
||||
// Bootstrap 初始化所有业务组件并返回 Handlers 和 Middlewares
|
||||
// 这是应用启动时的主入口,负责编排所有组件的初始化流程
|
||||
//
|
||||
// 初始化顺序:
|
||||
// 1. 初始化 Store 层(数据访问)
|
||||
// 2. 注册 GORM Callbacks(数据权限过滤等)- 需要 AccountStore
|
||||
// 3. 初始化 Service 层(业务逻辑)
|
||||
// 4. 初始化 Handler 层(HTTP 处理)
|
||||
// 4. 初始化 Middleware 层(中间件)
|
||||
// 5. 初始化 Handler 层(HTTP 处理)
|
||||
//
|
||||
// 参数:
|
||||
// - deps: 基础依赖(DB, Redis, Logger)
|
||||
//
|
||||
// 返回:
|
||||
// - *Handlers: 所有 HTTP 处理器
|
||||
// - *BootstrapResult: 包含 Handlers 和 Middlewares
|
||||
// - error: 初始化错误
|
||||
func Bootstrap(deps *Dependencies) (*Handlers, error) {
|
||||
func Bootstrap(deps *Dependencies) (*BootstrapResult, error) {
|
||||
// 1. 初始化 Store 层
|
||||
stores := initStores(deps)
|
||||
|
||||
@@ -29,12 +36,18 @@ func Bootstrap(deps *Dependencies) (*Handlers, error) {
|
||||
}
|
||||
|
||||
// 3. 初始化 Service 层
|
||||
services := initServices(stores)
|
||||
services := initServices(stores, deps)
|
||||
|
||||
// 4. 初始化 Handler 层
|
||||
handlers := initHandlers(services)
|
||||
// 4. 初始化 Middleware 层
|
||||
middlewares := initMiddlewares(deps)
|
||||
|
||||
return handlers, nil
|
||||
// 5. 初始化 Handler 层
|
||||
handlers := initHandlers(services, deps)
|
||||
|
||||
return &BootstrapResult{
|
||||
Handlers: handlers,
|
||||
Middlewares: middlewares,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// registerGORMCallbacks 注册 GORM Callbacks
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -9,7 +11,9 @@ import (
|
||||
// Dependencies 封装所有基础依赖
|
||||
// 这些是应用启动时初始化的核心组件
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB // PostgreSQL 数据库连接
|
||||
Redis *redis.Client // Redis 客户端
|
||||
Logger *zap.Logger // 应用日志器
|
||||
DB *gorm.DB // PostgreSQL 数据库连接
|
||||
Redis *redis.Client // Redis 客户端
|
||||
Logger *zap.Logger // 应用日志器
|
||||
JWTManager *auth.JWTManager // JWT 管理器
|
||||
VerificationService *verification.Service // 验证码服务
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
)
|
||||
|
||||
// initHandlers 初始化所有 Handler 实例
|
||||
func initHandlers(svc *services) *Handlers {
|
||||
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
return &Handlers{
|
||||
Account: admin.NewAccountHandler(svc.Account),
|
||||
Role: admin.NewRoleHandler(svc.Role),
|
||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||
Account: admin.NewAccountHandler(svc.Account),
|
||||
Role: admin.NewRoleHandler(svc.Role),
|
||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
||||
// TODO: 新增 Handler 在此初始化
|
||||
}
|
||||
}
|
||||
|
||||
23
internal/bootstrap/middlewares.go
Normal file
23
internal/bootstrap/middlewares.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
)
|
||||
|
||||
// initMiddlewares 初始化所有中间件
|
||||
func initMiddlewares(deps *Dependencies) *Middlewares {
|
||||
// 获取全局配置
|
||||
cfg := config.Get()
|
||||
|
||||
// 创建 JWT Manager
|
||||
jwtManager := auth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
||||
|
||||
// 创建个人客户认证中间件
|
||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
|
||||
|
||||
return &Middlewares{
|
||||
PersonalAuth: personalAuthMiddleware,
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,27 @@ package bootstrap
|
||||
import (
|
||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
||||
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
|
||||
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
||||
)
|
||||
|
||||
// services 封装所有 Service 实例
|
||||
// 注意:此结构体不导出,仅在 bootstrap 包内部使用
|
||||
type services struct {
|
||||
Account *accountSvc.Service
|
||||
Role *roleSvc.Service
|
||||
Permission *permissionSvc.Service
|
||||
Account *accountSvc.Service
|
||||
Role *roleSvc.Service
|
||||
Permission *permissionSvc.Service
|
||||
PersonalCustomer *personalCustomerSvc.Service
|
||||
// TODO: 新增 Service 在此添加字段
|
||||
}
|
||||
|
||||
// initServices 初始化所有 Service 实例
|
||||
func initServices(s *stores) *services {
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
return &services{
|
||||
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
|
||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||
Permission: permissionSvc.New(s.Permission),
|
||||
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
|
||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||
Permission: permissionSvc.New(s.Permission),
|
||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger),
|
||||
// TODO: 新增 Service 在此初始化
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,26 @@ import (
|
||||
// stores 封装所有 Store 实例
|
||||
// 注意:此结构体不导出,仅在 bootstrap 包内部使用
|
||||
type stores struct {
|
||||
Account *postgres.AccountStore
|
||||
Role *postgres.RoleStore
|
||||
Permission *postgres.PermissionStore
|
||||
AccountRole *postgres.AccountRoleStore
|
||||
RolePermission *postgres.RolePermissionStore
|
||||
Account *postgres.AccountStore
|
||||
Role *postgres.RoleStore
|
||||
Permission *postgres.PermissionStore
|
||||
AccountRole *postgres.AccountRoleStore
|
||||
RolePermission *postgres.RolePermissionStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
// TODO: 新增 Store 在此添加字段
|
||||
}
|
||||
|
||||
// initStores 初始化所有 Store 实例
|
||||
func initStores(deps *Dependencies) *stores {
|
||||
return &stores{
|
||||
Account: postgres.NewAccountStore(deps.DB, deps.Redis),
|
||||
Role: postgres.NewRoleStore(deps.DB),
|
||||
Permission: postgres.NewPermissionStore(deps.DB),
|
||||
AccountRole: postgres.NewAccountRoleStore(deps.DB),
|
||||
RolePermission: postgres.NewRolePermissionStore(deps.DB),
|
||||
Account: postgres.NewAccountStore(deps.DB, deps.Redis),
|
||||
Role: postgres.NewRoleStore(deps.DB),
|
||||
Permission: postgres.NewPermissionStore(deps.DB),
|
||||
AccountRole: postgres.NewAccountRoleStore(deps.DB),
|
||||
RolePermission: postgres.NewRolePermissionStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
// TODO: 新增 Store 在此初始化
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,23 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
)
|
||||
|
||||
// Handlers 封装所有 HTTP 处理器
|
||||
// 用于路由注册
|
||||
type Handlers struct {
|
||||
Account *admin.AccountHandler
|
||||
Role *admin.RoleHandler
|
||||
Permission *admin.PermissionHandler
|
||||
Account *admin.AccountHandler
|
||||
Role *admin.RoleHandler
|
||||
Permission *admin.PermissionHandler
|
||||
PersonalCustomer *app.PersonalCustomerHandler
|
||||
// TODO: 新增 Handler 在此添加字段
|
||||
}
|
||||
|
||||
// Middlewares 封装所有中间件
|
||||
// 用于路由注册
|
||||
type Middlewares struct {
|
||||
PersonalAuth *middleware.PersonalAuthMiddleware
|
||||
// TODO: 新增 Middleware 在此添加字段
|
||||
}
|
||||
|
||||
202
internal/handler/app/personal_customer.go
Normal file
202
internal/handler/app/personal_customer.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PersonalCustomerHandler 个人客户处理器
|
||||
type PersonalCustomerHandler struct {
|
||||
service *personal_customer.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPersonalCustomerHandler 创建个人客户处理器实例
|
||||
func NewPersonalCustomerHandler(service *personal_customer.Service, logger *zap.Logger) *PersonalCustomerHandler {
|
||||
return &PersonalCustomerHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendCodeRequest 发送验证码请求
|
||||
type SendCodeRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
||||
}
|
||||
|
||||
// SendCode 发送验证码
|
||||
// POST /api/c/v1/login/send-code
|
||||
func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
|
||||
var req SendCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
if err := h.service.SendVerificationCode(c.Context(), req.Phone); err != nil {
|
||||
h.logger.Error("发送验证码失败",
|
||||
zap.String("phone", req.Phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, "发送验证码失败", err)
|
||||
}
|
||||
|
||||
return response.Success(c, fiber.Map{
|
||||
"message": "验证码已发送",
|
||||
})
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
||||
Code string `json:"code" validate:"required,len=6"` // 验证码(6位)
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"` // 访问令牌
|
||||
Customer *PersonalCustomerDTO `json:"customer"` // 客户信息
|
||||
}
|
||||
|
||||
// PersonalCustomerDTO 个人客户 DTO
|
||||
type PersonalCustomerDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
WxOpenID string `json:"wx_open_id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// Login 登录(手机号 + 验证码)
|
||||
// POST /api/c/v1/login
|
||||
func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
|
||||
var req LoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
// 登录
|
||||
token, customer, err := h.service.LoginByPhone(c.Context(), req.Phone, req.Code)
|
||||
if err != nil {
|
||||
h.logger.Error("登录失败",
|
||||
zap.String("phone", req.Phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, "登录失败", err)
|
||||
}
|
||||
|
||||
// 构造响应
|
||||
// 注意:Phone 字段已从 PersonalCustomer 模型移除,需要从 PersonalCustomerPhone 表查询
|
||||
resp := &LoginResponse{
|
||||
Token: token,
|
||||
Customer: &PersonalCustomerDTO{
|
||||
ID: customer.ID,
|
||||
Phone: req.Phone, // 使用请求中的手机号(临时方案)
|
||||
Nickname: customer.Nickname,
|
||||
AvatarURL: customer.AvatarURL,
|
||||
WxOpenID: customer.WxOpenID,
|
||||
Status: customer.Status,
|
||||
},
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// BindWechatRequest 绑定微信请求
|
||||
type BindWechatRequest struct {
|
||||
Code string `json:"code" validate:"required"` // 微信授权码
|
||||
}
|
||||
|
||||
// BindWechat 绑定微信
|
||||
// POST /api/c/v1/bind-wechat
|
||||
// TODO: 实现微信 OAuth 授权逻辑
|
||||
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
|
||||
var req BindWechatRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
// TODO: 从 context 中获取当前登录的客户 ID
|
||||
// customerID := c.Locals("customer_id").(uint)
|
||||
|
||||
// TODO: 使用微信授权码换取 OpenID 和 UnionID
|
||||
// wxOpenID, wxUnionID, err := wechatService.GetUserInfo(req.Code)
|
||||
|
||||
// TODO: 绑定微信
|
||||
// if err := h.service.BindWechat(c.Context(), customerID, wxOpenID, wxUnionID); err != nil {
|
||||
// return errors.Wrap(errors.CodeInternalError, "绑定微信失败", err)
|
||||
// }
|
||||
|
||||
return response.Success(c, fiber.Map{
|
||||
"message": "微信绑定功能暂未实现,待微信 SDK 对接后启用",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfileRequest 更新个人资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
AvatarURL string `json:"avatar_url"` // 头像 URL
|
||||
}
|
||||
|
||||
// UpdateProfile 更新个人资料
|
||||
// PUT /api/c/v1/profile
|
||||
func (h *PersonalCustomerHandler) UpdateProfile(c *fiber.Ctx) error {
|
||||
var req UpdateProfileRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
// 从 context 中获取当前登录的客户 ID
|
||||
customerID, ok := c.Locals("customer_id").(uint)
|
||||
if !ok {
|
||||
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
|
||||
}
|
||||
|
||||
if err := h.service.UpdateProfile(c.Context(), customerID, req.Nickname, req.AvatarURL); err != nil {
|
||||
h.logger.Error("更新个人资料失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, "更新个人资料失败", err)
|
||||
}
|
||||
|
||||
return response.Success(c, fiber.Map{
|
||||
"message": "更新成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile 获取个人资料
|
||||
// GET /api/c/v1/profile
|
||||
func (h *PersonalCustomerHandler) GetProfile(c *fiber.Ctx) error {
|
||||
// 从 context 中获取当前登录的客户 ID
|
||||
customerID, ok := c.Locals("customer_id").(uint)
|
||||
if !ok {
|
||||
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
|
||||
}
|
||||
|
||||
// 获取客户资料(包含主手机号)
|
||||
customer, phone, err := h.service.GetProfileWithPhone(c.Context(), customerID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取个人资料失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, "获取个人资料失败", err)
|
||||
}
|
||||
|
||||
// 构造响应
|
||||
resp := &PersonalCustomerDTO{
|
||||
ID: customer.ID,
|
||||
Phone: phone, // 使用查询到的主手机号
|
||||
Nickname: customer.Nickname,
|
||||
AvatarURL: customer.AvatarURL,
|
||||
WxOpenID: customer.WxOpenID,
|
||||
Status: customer.Status,
|
||||
}
|
||||
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
89
internal/middleware/personal_auth.go
Normal file
89
internal/middleware/personal_auth.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PersonalAuthMiddleware 个人客户认证中间件
|
||||
type PersonalAuthMiddleware struct {
|
||||
jwtManager *auth.JWTManager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPersonalAuthMiddleware 创建个人客户认证中间件
|
||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||
return &PersonalAuthMiddleware{
|
||||
jwtManager: jwtManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate 认证中间件
|
||||
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",
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("method", c.Method()),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
|
||||
}
|
||||
|
||||
// 检查 Bearer 前缀
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
m.logger.Warn("个人客户认证失败:Authorization header 格式错误",
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("auth_header", authHeader),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌格式错误")
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// 验证 token
|
||||
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
|
||||
if err != nil {
|
||||
m.logger.Warn("个人客户认证失败:token 验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
|
||||
}
|
||||
|
||||
// 将客户信息存储到 context 中
|
||||
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("个人客户认证成功",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.String("phone", claims.Phone),
|
||||
zap.String("path", c.Path()),
|
||||
)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerID 从 context 中获取当前个人客户 ID
|
||||
func GetCustomerID(c *fiber.Ctx) (uint, bool) {
|
||||
customerID, ok := c.Locals("customer_id").(uint)
|
||||
return customerID, ok
|
||||
}
|
||||
|
||||
// GetCustomerPhone 从 context 中获取当前个人客户手机号
|
||||
func GetCustomerPhone(c *fiber.Ctx) (string, bool) {
|
||||
phone, ok := c.Locals("customer_phone").(string)
|
||||
return phone, ok
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomer 个人客户模型
|
||||
// PersonalCustomer 个人客户模型(微信用户)
|
||||
// 说明:个人客户由微信 OpenID/UnionID 唯一标识
|
||||
// 手机号、ICCID、设备号通过关联表存储
|
||||
type PersonalCustomer struct {
|
||||
gorm.Model
|
||||
Phone string `gorm:"column:phone;type:varchar(20);uniqueIndex:idx_personal_customer_phone,where:deleted_at IS NULL;comment:手机号(唯一标识)" json:"phone"`
|
||||
Nickname string `gorm:"column:nickname;type:varchar(50);comment:昵称" json:"nickname"`
|
||||
AvatarURL string `gorm:"column:avatar_url;type:varchar(255);comment:头像URL" json:"avatar_url"`
|
||||
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);index;comment:微信OpenID" json:"wx_open_id"`
|
||||
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;comment:微信UnionID" json:"wx_union_id"`
|
||||
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL;not null;comment:微信OpenID(唯一标识)" json:"wx_open_id"`
|
||||
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;not null;comment:微信UnionID" json:"wx_union_id"`
|
||||
Nickname string `gorm:"column:nickname;type:varchar(100);comment:微信昵称" json:"nickname"`
|
||||
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
|
||||
23
internal/model/personal_customer_device.go
Normal file
23
internal/model/personal_customer_device.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerDevice 个人客户设备号绑定表
|
||||
// 说明:记录微信用户使用过哪些设备号/IMEI,一个设备号可以被多个微信用户使用过
|
||||
type PersonalCustomerDevice struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
DeviceNo string `gorm:"column:device_no;type:varchar(50);not null;comment:设备号/IMEI" json:"device_no"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PersonalCustomerDevice) TableName() string {
|
||||
return "tb_personal_customer_device"
|
||||
}
|
||||
23
internal/model/personal_customer_iccid.go
Normal file
23
internal/model/personal_customer_iccid.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerICCID 个人客户ICCID绑定表
|
||||
// 说明:记录微信用户使用过哪些ICCID,一个ICCID可以被多个微信用户使用过
|
||||
type PersonalCustomerICCID struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);not null;comment:ICCID(20位数字)" json:"iccid"`
|
||||
BindAt time.Time `gorm:"column:bind_at;type:timestamp;not null;comment:绑定时间" json:"bind_at"`
|
||||
LastUsedAt time.Time `gorm:"column:last_used_at;type:timestamp;comment:最后使用时间" json:"last_used_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PersonalCustomerICCID) TableName() string {
|
||||
return "tb_personal_customer_iccid"
|
||||
}
|
||||
23
internal/model/personal_customer_phone.go
Normal file
23
internal/model/personal_customer_phone.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerPhone 个人客户手机号绑定表
|
||||
// 说明:一个微信用户可以绑定多个手机号
|
||||
type PersonalCustomerPhone struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;comment:关联个人客户ID" json:"customer_id"`
|
||||
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:手机号" json:"phone"`
|
||||
IsPrimary bool `gorm:"column:is_primary;type:boolean;not null;default:false;comment:是否主手机号" json:"is_primary"`
|
||||
VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp;comment:验证通过时间" json:"verified_at"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PersonalCustomerPhone) TableName() string {
|
||||
return "tb_personal_customer_phone"
|
||||
}
|
||||
38
internal/routes/personal.go
Normal file
38
internal/routes/personal.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
||||
// 路由挂载在 /api/c/v1 下
|
||||
func RegisterPersonalCustomerRoutes(app *fiber.App, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
||||
// C端路由组 (Customer)
|
||||
customerGroup := app.Group("/api/c/v1")
|
||||
|
||||
// 公开路由(不需要认证)
|
||||
publicGroup := customerGroup.Group("")
|
||||
{
|
||||
// 发送验证码
|
||||
publicGroup.Post("/login/send-code", handlers.PersonalCustomer.SendCode)
|
||||
|
||||
// 登录
|
||||
publicGroup.Post("/login", handlers.PersonalCustomer.Login)
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authGroup := customerGroup.Group("")
|
||||
authGroup.Use(personalAuthMiddleware.Authenticate())
|
||||
{
|
||||
// 绑定微信
|
||||
authGroup.Post("/bind-wechat", handlers.PersonalCustomer.BindWechat)
|
||||
|
||||
// 获取个人资料
|
||||
authGroup.Get("/profile", handlers.PersonalCustomer.GetProfile)
|
||||
|
||||
// 更新个人资料
|
||||
authGroup.Put("/profile", handlers.PersonalCustomer.UpdateProfile)
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,17 @@ import (
|
||||
|
||||
// RegisterRoutes 路由注册总入口
|
||||
// 按业务模块调用各自的路由注册函数
|
||||
func RegisterRoutes(app *fiber.App, handlers *bootstrap.Handlers) {
|
||||
func RegisterRoutes(app *fiber.App, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares) {
|
||||
// 1. 全局路由
|
||||
registerHealthRoutes(app)
|
||||
|
||||
// 2. Admin 域 (挂载在 /api/admin)
|
||||
adminGroup := app.Group("/api/admin")
|
||||
RegisterAdminRoutes(adminGroup, handlers, nil, "/api/admin")
|
||||
|
||||
|
||||
// 任务相关路由 (归属于 Admin 域)
|
||||
registerTaskRoutes(adminGroup)
|
||||
|
||||
// 3. 个人客户路由 (挂载在 /api/c/v1)
|
||||
RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,9 @@ func (s *Service) Create(ctx context.Context, req *model.CreatePersonalCustomerR
|
||||
}
|
||||
|
||||
// 创建个人客户
|
||||
// 注意:根据新的数据模型,手机号应该存储在 PersonalCustomerPhone 表中
|
||||
// 这里暂时先创建客户记录,手机号的存储后续通过 PersonalCustomerPhoneStore 实现
|
||||
customer := &model.PersonalCustomer{
|
||||
Phone: req.Phone,
|
||||
Nickname: req.Nickname,
|
||||
AvatarURL: req.AvatarURL,
|
||||
WxOpenID: req.WxOpenID,
|
||||
@@ -46,6 +47,17 @@ func (s *Service) Create(ctx context.Context, req *model.CreatePersonalCustomerR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: 创建 PersonalCustomerPhone 记录
|
||||
// if req.Phone != "" {
|
||||
// phoneRecord := &model.PersonalCustomerPhone{
|
||||
// CustomerID: customer.ID,
|
||||
// Phone: req.Phone,
|
||||
// IsPrimary: true,
|
||||
// Status: constants.StatusEnabled,
|
||||
// }
|
||||
// // 需要通过 PersonalCustomerPhoneStore 创建
|
||||
// }
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
@@ -57,14 +69,11 @@ func (s *Service) Update(ctx context.Context, id uint, req *model.UpdatePersonal
|
||||
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
|
||||
}
|
||||
|
||||
// 检查手机号唯一性(如果修改了手机号)
|
||||
if req.Phone != nil && *req.Phone != customer.Phone {
|
||||
existing, err := s.customerStore.GetByPhone(ctx, *req.Phone)
|
||||
if err == nil && existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodeCustomerPhoneExists, "手机号已存在")
|
||||
}
|
||||
customer.Phone = *req.Phone
|
||||
}
|
||||
// 注意:手机号的更新逻辑需要通过 PersonalCustomerPhone 表处理
|
||||
// TODO: 实现手机号的更新逻辑
|
||||
// if req.Phone != nil {
|
||||
// // 通过 PersonalCustomerPhoneStore 更新或创建手机号记录
|
||||
// }
|
||||
|
||||
// 更新字段
|
||||
if req.Nickname != nil {
|
||||
|
||||
236
internal/service/personal_customer/service.go
Normal file
236
internal/service/personal_customer/service.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package personal_customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 个人客户服务
|
||||
type Service struct {
|
||||
store *postgres.PersonalCustomerStore
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||
verificationService *verification.Service
|
||||
jwtManager *auth.JWTManager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建个人客户服务实例
|
||||
func NewService(
|
||||
store *postgres.PersonalCustomerStore,
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||||
verificationService *verification.Service,
|
||||
jwtManager *auth.JWTManager,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
phoneStore: phoneStore,
|
||||
verificationService: verificationService,
|
||||
jwtManager: jwtManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码
|
||||
func (s *Service) SendVerificationCode(ctx context.Context, phone string) error {
|
||||
return s.verificationService.SendCode(ctx, phone)
|
||||
}
|
||||
|
||||
// VerifyCode 验证验证码
|
||||
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
|
||||
return s.verificationService.VerifyCode(ctx, phone, code)
|
||||
}
|
||||
|
||||
// LoginByPhone 通过手机号登录
|
||||
// 如果手机号不存在,自动创建新的个人客户
|
||||
// 注意:此方法是临时实现,完整的登录流程应该是先微信授权,再绑定手机号
|
||||
func (s *Service) LoginByPhone(ctx context.Context, phone string, code string) (string, *model.PersonalCustomer, error) {
|
||||
// 验证验证码
|
||||
if err := s.verificationService.VerifyCode(ctx, phone, code); err != nil {
|
||||
s.logger.Warn("验证码验证失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", nil, fmt.Errorf("验证码验证失败: %w", err)
|
||||
}
|
||||
|
||||
// 查找或创建个人客户
|
||||
customer, err := s.store.GetByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 客户不存在,创建新客户
|
||||
// 注意:临时实现,使用空的微信信息(正式应该先微信授权)
|
||||
customer = &model.PersonalCustomer{
|
||||
WxOpenID: "", // 临时为空,后续需绑定微信
|
||||
WxUnionID: "", // 临时为空,后续需绑定微信
|
||||
Status: 1, // 默认启用
|
||||
}
|
||||
if err := s.store.Create(ctx, customer); err != nil {
|
||||
s.logger.Error("创建个人客户失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", nil, fmt.Errorf("创建个人客户失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建手机号绑定记录
|
||||
// TODO: 这里需要通过 PersonalCustomerPhoneStore 来创建
|
||||
// 暂时跳过,等待 PersonalCustomerPhoneStore 实现
|
||||
|
||||
s.logger.Info("创建新个人客户",
|
||||
zap.Uint("customer_id", customer.ID),
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
} else {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", nil, fmt.Errorf("查询个人客户失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查客户状态
|
||||
if customer.Status == 0 {
|
||||
s.logger.Warn("个人客户已被禁用",
|
||||
zap.Uint("customer_id", customer.ID),
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
return "", nil, fmt.Errorf("账号已被禁用")
|
||||
}
|
||||
|
||||
// 生成 Token(临时传递 phone,后续应该从 Token 中移除 phone 字段)
|
||||
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, phone)
|
||||
if err != nil {
|
||||
s.logger.Error("生成 Token 失败",
|
||||
zap.Uint("customer_id", customer.ID),
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", nil, fmt.Errorf("生成 Token 失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("个人客户登录成功",
|
||||
zap.Uint("customer_id", customer.ID),
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
|
||||
return token, customer, nil
|
||||
}
|
||||
|
||||
// BindWechat 绑定微信信息
|
||||
func (s *Service) BindWechat(ctx context.Context, customerID uint, wxOpenID, wxUnionID string) error {
|
||||
// 获取客户
|
||||
customer, err := s.store.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("查询个人客户失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新微信信息
|
||||
customer.WxOpenID = wxOpenID
|
||||
customer.WxUnionID = wxUnionID
|
||||
|
||||
if err := s.store.Update(ctx, customer); err != nil {
|
||||
s.logger.Error("更新微信信息失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("更新微信信息失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("绑定微信信息成功",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.String("wx_open_id", wxOpenID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProfile 更新个人资料
|
||||
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
|
||||
customer, err := s.store.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("查询个人客户失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新资料
|
||||
if nickname != "" {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatarURL != "" {
|
||||
customer.AvatarURL = avatarURL
|
||||
}
|
||||
|
||||
if err := s.store.Update(ctx, customer); err != nil {
|
||||
s.logger.Error("更新个人资料失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("更新个人资料失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("更新个人资料成功",
|
||||
zap.Uint("customer_id", customerID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProfile 获取个人资料
|
||||
func (s *Service) GetProfile(ctx context.Context, customerID uint) (*model.PersonalCustomer, error) {
|
||||
customer, err := s.store.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("查询个人客户失败: %w", err)
|
||||
}
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// GetProfileWithPhone 获取个人资料(包含主手机号)
|
||||
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
|
||||
// 获取客户信息
|
||||
customer, err := s.store.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询个人客户失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, "", fmt.Errorf("查询个人客户失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取主手机号
|
||||
phone := ""
|
||||
primaryPhone, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
|
||||
if err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
s.logger.Error("查询主手机号失败",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 不返回错误,继续返回客户信息(手机号为空)
|
||||
}
|
||||
} else {
|
||||
phone = primaryPhone.Phone
|
||||
}
|
||||
|
||||
return customer, phone, nil
|
||||
}
|
||||
172
internal/service/verification/service.go
Normal file
172
internal/service/verification/service.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sms"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service 验证码服务
|
||||
type Service struct {
|
||||
redisClient *redis.Client
|
||||
smsClient *sms.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService 创建验证码服务实例
|
||||
func NewService(redisClient *redis.Client, smsClient *sms.Client, logger *zap.Logger) *Service {
|
||||
return &Service{
|
||||
redisClient: redisClient,
|
||||
smsClient: smsClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendCode 发送验证码
|
||||
func (s *Service) SendCode(ctx context.Context, phone string) error {
|
||||
// 检查发送频率限制
|
||||
limitKey := constants.RedisVerificationCodeLimitKey(phone)
|
||||
exists, err := s.redisClient.Exists(ctx, limitKey).Result()
|
||||
if err != nil {
|
||||
s.logger.Error("检查验证码发送频率限制失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("检查验证码发送频率限制失败: %w", err)
|
||||
}
|
||||
|
||||
if exists > 0 {
|
||||
s.logger.Warn("验证码发送过于频繁",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
return fmt.Errorf("验证码发送过于频繁,请稍后再试")
|
||||
}
|
||||
|
||||
// 生成随机验证码
|
||||
code, err := s.generateCode()
|
||||
if err != nil {
|
||||
s.logger.Error("生成验证码失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("生成验证码失败: %w", err)
|
||||
}
|
||||
|
||||
// 构造短信内容
|
||||
cfg := config.Get()
|
||||
content := fmt.Sprintf("您的验证码是%s,%d分钟内有效", code, int(constants.VerificationCodeExpiration.Minutes()))
|
||||
|
||||
// 发送短信
|
||||
_, err = s.smsClient.SendMessage(ctx, content, []string{phone})
|
||||
if err != nil {
|
||||
s.logger.Error("发送验证码短信失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("发送验证码短信失败: %w", err)
|
||||
}
|
||||
|
||||
// 存储验证码到 Redis
|
||||
codeKey := constants.RedisVerificationCodeKey(phone)
|
||||
err = s.redisClient.Set(ctx, codeKey, code, constants.VerificationCodeExpiration).Err()
|
||||
if err != nil {
|
||||
s.logger.Error("存储验证码失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("存储验证码失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置发送频率限制
|
||||
err = s.redisClient.Set(ctx, limitKey, "1", constants.VerificationCodeRateLimit).Err()
|
||||
if err != nil {
|
||||
s.logger.Error("设置验证码发送频率限制失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 这个错误不影响主流程,只记录日志
|
||||
}
|
||||
|
||||
s.logger.Info("验证码发送成功",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
|
||||
// 避免在日志中暴露验证码(仅在开发环境下记录)
|
||||
if cfg.Logging.Development {
|
||||
s.logger.Debug("验证码内容(仅开发环境)",
|
||||
zap.String("phone", phone),
|
||||
zap.String("code", code),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyCode 验证验证码
|
||||
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
|
||||
codeKey := constants.RedisVerificationCodeKey(phone)
|
||||
|
||||
// 从 Redis 获取验证码
|
||||
storedCode, err := s.redisClient.Get(ctx, codeKey).Result()
|
||||
if err == redis.Nil {
|
||||
s.logger.Warn("验证码不存在或已过期",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
return fmt.Errorf("验证码不存在或已过期")
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("获取验证码失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("获取验证码失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证码比对
|
||||
if storedCode != code {
|
||||
s.logger.Warn("验证码错误",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
|
||||
// 验证成功,删除验证码(防止重复使用)
|
||||
err = s.redisClient.Del(ctx, codeKey).Err()
|
||||
if err != nil {
|
||||
s.logger.Error("删除验证码失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 这个错误不影响主流程,只记录日志
|
||||
}
|
||||
|
||||
s.logger.Info("验证码验证成功",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCode 生成随机验证码
|
||||
func (s *Service) generateCode() (string, error) {
|
||||
// 生成 6 位数字验证码
|
||||
const digits = "0123456789"
|
||||
code := make([]byte, constants.VerificationCodeLength)
|
||||
|
||||
for i := range code {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code[i] = digits[num.Int64()]
|
||||
}
|
||||
|
||||
return string(code), nil
|
||||
}
|
||||
117
internal/store/postgres/personal_customer_device_store.go
Normal file
117
internal/store/postgres/personal_customer_device_store.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerDeviceStore 个人客户设备号绑定数据访问层
|
||||
type PersonalCustomerDeviceStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPersonalCustomerDeviceStore 创建个人客户设备号 Store
|
||||
func NewPersonalCustomerDeviceStore(db *gorm.DB) *PersonalCustomerDeviceStore {
|
||||
return &PersonalCustomerDeviceStore{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建设备号绑定记录
|
||||
func (s *PersonalCustomerDeviceStore) Create(ctx context.Context, record *model.PersonalCustomerDevice) error {
|
||||
now := time.Now()
|
||||
record.BindAt = now
|
||||
record.LastUsedAt = now
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
// GetByCustomerID 根据客户 ID 获取所有设备号绑定记录
|
||||
func (s *PersonalCustomerDeviceStore) GetByCustomerID(ctx context.Context, customerID uint) ([]*model.PersonalCustomerDevice, error) {
|
||||
var records []*model.PersonalCustomerDevice
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("last_used_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetByDeviceNo 根据设备号获取所有绑定记录(查询哪些用户使用过这个设备)
|
||||
func (s *PersonalCustomerDeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) ([]*model.PersonalCustomerDevice, error) {
|
||||
var records []*model.PersonalCustomerDevice
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("device_no = ?", deviceNo).
|
||||
Order("last_used_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetByCustomerAndDevice 根据客户 ID 和设备号获取绑定记录
|
||||
func (s *PersonalCustomerDeviceStore) GetByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (*model.PersonalCustomerDevice, error) {
|
||||
var record model.PersonalCustomerDevice
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ? AND device_no = ?", customerID, deviceNo).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// UpdateLastUsedAt 更新最后使用时间
|
||||
func (s *PersonalCustomerDeviceStore) UpdateLastUsedAt(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerDevice{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态
|
||||
func (s *PersonalCustomerDeviceStore) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerDevice{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// Delete 软删除绑定记录
|
||||
func (s *PersonalCustomerDeviceStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.PersonalCustomerDevice{}, id).Error
|
||||
}
|
||||
|
||||
// ExistsByCustomerAndDevice 检查客户是否已绑定该设备
|
||||
func (s *PersonalCustomerDeviceStore) ExistsByCustomerAndDevice(ctx context.Context, customerID uint, deviceNo string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerDevice{}).
|
||||
Where("customer_id = ? AND device_no = ? AND status = ?", customerID, deviceNo, 1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间
|
||||
// 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录
|
||||
func (s *PersonalCustomerDeviceStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, deviceNo string) error {
|
||||
record, err := s.GetByCustomerAndDevice(ctx, customerID, deviceNo)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 不存在,创建新记录
|
||||
newRecord := &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
DeviceNo: deviceNo,
|
||||
Status: 1, // 启用
|
||||
}
|
||||
return s.Create(ctx, newRecord)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 存在,更新最后使用时间
|
||||
return s.UpdateLastUsedAt(ctx, record.ID)
|
||||
}
|
||||
117
internal/store/postgres/personal_customer_iccid_store.go
Normal file
117
internal/store/postgres/personal_customer_iccid_store.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerICCIDStore 个人客户 ICCID 绑定数据访问层
|
||||
type PersonalCustomerICCIDStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPersonalCustomerICCIDStore 创建个人客户 ICCID Store
|
||||
func NewPersonalCustomerICCIDStore(db *gorm.DB) *PersonalCustomerICCIDStore {
|
||||
return &PersonalCustomerICCIDStore{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建 ICCID 绑定记录
|
||||
func (s *PersonalCustomerICCIDStore) Create(ctx context.Context, record *model.PersonalCustomerICCID) error {
|
||||
now := time.Now()
|
||||
record.BindAt = now
|
||||
record.LastUsedAt = now
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
// GetByCustomerID 根据客户 ID 获取所有 ICCID 绑定记录
|
||||
func (s *PersonalCustomerICCIDStore) GetByCustomerID(ctx context.Context, customerID uint) ([]*model.PersonalCustomerICCID, error) {
|
||||
var records []*model.PersonalCustomerICCID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("last_used_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetByICCID 根据 ICCID 获取所有绑定记录(查询哪些用户使用过这个 ICCID)
|
||||
func (s *PersonalCustomerICCIDStore) GetByICCID(ctx context.Context, iccid string) ([]*model.PersonalCustomerICCID, error) {
|
||||
var records []*model.PersonalCustomerICCID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iccid = ?", iccid).
|
||||
Order("last_used_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetByCustomerAndICCID 根据客户 ID 和 ICCID 获取绑定记录
|
||||
func (s *PersonalCustomerICCIDStore) GetByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (*model.PersonalCustomerICCID, error) {
|
||||
var record model.PersonalCustomerICCID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ? AND iccid = ?", customerID, iccid).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// UpdateLastUsedAt 更新最后使用时间
|
||||
func (s *PersonalCustomerICCIDStore) UpdateLastUsedAt(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerICCID{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态
|
||||
func (s *PersonalCustomerICCIDStore) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerICCID{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// Delete 软删除绑定记录
|
||||
func (s *PersonalCustomerICCIDStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.PersonalCustomerICCID{}, id).Error
|
||||
}
|
||||
|
||||
// ExistsByCustomerAndICCID 检查客户是否已绑定该 ICCID
|
||||
func (s *PersonalCustomerICCIDStore) ExistsByCustomerAndICCID(ctx context.Context, customerID uint, iccid string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerICCID{}).
|
||||
Where("customer_id = ? AND iccid = ? AND status = ?", customerID, iccid, 1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateLastUsed 创建或更新绑定记录的最后使用时间
|
||||
// 如果绑定记录存在,更新最后使用时间;如果不存在,创建新记录
|
||||
func (s *PersonalCustomerICCIDStore) CreateOrUpdateLastUsed(ctx context.Context, customerID uint, iccid string) error {
|
||||
record, err := s.GetByCustomerAndICCID(ctx, customerID, iccid)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 不存在,创建新记录
|
||||
newRecord := &model.PersonalCustomerICCID{
|
||||
CustomerID: customerID,
|
||||
ICCID: iccid,
|
||||
Status: 1, // 启用
|
||||
}
|
||||
return s.Create(ctx, newRecord)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 存在,更新最后使用时间
|
||||
return s.UpdateLastUsedAt(ctx, record.ID)
|
||||
}
|
||||
120
internal/store/postgres/personal_customer_phone_store.go
Normal file
120
internal/store/postgres/personal_customer_phone_store.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerPhoneStore 个人客户手机号数据访问层
|
||||
type PersonalCustomerPhoneStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPersonalCustomerPhoneStore 创建个人客户手机号 Store
|
||||
func NewPersonalCustomerPhoneStore(db *gorm.DB) *PersonalCustomerPhoneStore {
|
||||
return &PersonalCustomerPhoneStore{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建手机号绑定记录
|
||||
func (s *PersonalCustomerPhoneStore) Create(ctx context.Context, phone *model.PersonalCustomerPhone) error {
|
||||
phone.VerifiedAt = time.Now()
|
||||
return s.db.WithContext(ctx).Create(phone).Error
|
||||
}
|
||||
|
||||
// GetByCustomerID 根据客户 ID 获取所有手机号
|
||||
func (s *PersonalCustomerPhoneStore) GetByCustomerID(ctx context.Context, customerID uint) ([]*model.PersonalCustomerPhone, error) {
|
||||
var phones []*model.PersonalCustomerPhone
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("is_primary DESC, created_at DESC").
|
||||
Find(&phones).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return phones, nil
|
||||
}
|
||||
|
||||
// GetPrimaryPhone 获取客户的主手机号
|
||||
func (s *PersonalCustomerPhoneStore) GetPrimaryPhone(ctx context.Context, customerID uint) (*model.PersonalCustomerPhone, error) {
|
||||
var phone model.PersonalCustomerPhone
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).
|
||||
First(&phone).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &phone, nil
|
||||
}
|
||||
|
||||
// GetByPhone 根据手机号查询绑定记录
|
||||
func (s *PersonalCustomerPhoneStore) GetByPhone(ctx context.Context, phone string) (*model.PersonalCustomerPhone, error) {
|
||||
var record model.PersonalCustomerPhone
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("phone = ? AND status = ?", phone, 1).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// SetPrimary 设置主手机号
|
||||
// 将指定的手机号设置为主号,同时将该客户的其他手机号设置为非主号
|
||||
func (s *PersonalCustomerPhoneStore) SetPrimary(ctx context.Context, id uint, customerID uint) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 将该客户的所有手机号设置为非主号
|
||||
if err := tx.Model(&model.PersonalCustomerPhone{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Update("is_primary", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 将指定的手机号设置为主号
|
||||
if err := tx.Model(&model.PersonalCustomerPhone{}).
|
||||
Where("id = ? AND customer_id = ?", id, customerID).
|
||||
Update("is_primary", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateStatus 更新手机号状态
|
||||
func (s *PersonalCustomerPhoneStore) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerPhone{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// Delete 软删除手机号
|
||||
func (s *PersonalCustomerPhoneStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.PersonalCustomerPhone{}, id).Error
|
||||
}
|
||||
|
||||
// ExistsByPhone 检查手机号是否已被绑定
|
||||
func (s *PersonalCustomerPhoneStore) ExistsByPhone(ctx context.Context, phone string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerPhone{}).
|
||||
Where("phone = ? AND status = ?", phone, 1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ExistsByCustomerAndPhone 检查某个客户是否已绑定该手机号
|
||||
func (s *PersonalCustomerPhoneStore) ExistsByCustomerAndPhone(ctx context.Context, customerID uint, phone string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerPhone{}).
|
||||
Where("customer_id = ? AND phone = ? AND status = ?", customerID, phone, 1).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -39,9 +39,16 @@ func (s *PersonalCustomerStore) GetByID(ctx context.Context, id uint) (*model.Pe
|
||||
}
|
||||
|
||||
// GetByPhone 根据手机号获取个人客户
|
||||
// 注意:由于 PersonalCustomer 不再直接存储手机号,此方法需要通过 PersonalCustomerPhone 关联表查询
|
||||
func (s *PersonalCustomerStore) GetByPhone(ctx context.Context, phone string) (*model.PersonalCustomer, error) {
|
||||
var customerPhone model.PersonalCustomerPhone
|
||||
if err := s.db.WithContext(ctx).Where("phone = ?", phone).First(&customerPhone).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询关联的个人客户
|
||||
var customer model.PersonalCustomer
|
||||
if err := s.db.WithContext(ctx).Where("phone = ?", phone).First(&customer).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).First(&customer, customerPhone.CustomerID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &customer, nil
|
||||
@@ -83,9 +90,8 @@ func (s *PersonalCustomerStore) List(ctx context.Context, opts *store.QueryOptio
|
||||
query := s.db.WithContext(ctx).Model(&model.PersonalCustomer{})
|
||||
|
||||
// 应用过滤条件
|
||||
if phone, ok := filters["phone"].(string); ok && phone != "" {
|
||||
query = query.Where("phone LIKE ?", "%"+phone+"%")
|
||||
}
|
||||
// 注意:phone 过滤需要通过关联表查询,这里先移除该过滤条件
|
||||
// TODO: 如果需要按手机号过滤,需要通过 JOIN PersonalCustomerPhone 表实现
|
||||
if nickname, ok := filters["nickname"].(string); ok && nickname != "" {
|
||||
query = query.Where("nickname LIKE ?", "%"+nickname+"%")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user