实现个人客户微信认证和短信验证功能

- 添加个人客户微信登录和手机验证码登录接口
- 实现个人客户设备、ICCID、手机号关联管理
- 添加短信发送服务(HTTP 客户端)
- 添加微信认证服务(含 mock 实现)
- 添加 JWT Token 生成和验证工具
- 创建数据库迁移脚本(personal_customer 关联表)
- 修复测试文件中的路由注册参数错误
- 重构 scripts 目录结构(分离独立脚本到子目录)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 11:42:38 +08:00
parent 1b9080e3ab
commit 9c6d4a3bd4
53 changed files with 4258 additions and 97 deletions

View File

@@ -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

View File

@@ -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 // 验证码服务
}

View File

@@ -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 在此初始化
}
}

View 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,
}
}

View File

@@ -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 在此初始化
}
}

View File

@@ -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 在此初始化
}
}

View File

@@ -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 在此添加字段
}

View 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)
}

View 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
}

View File

@@ -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"`
}

View 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"
}

View 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:ICCID20位数字" 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"
}

View 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"
}

View 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)
}
}

View File

@@ -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)
}

View File

@@ -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 {

View 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
}

View 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
}

View 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)
}

View 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)
}

View 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
}

View File

@@ -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+"%")
}