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

- 添加个人客户微信登录和手机验证码登录接口
- 实现个人客户设备、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

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