feat: add SMS verification registration with UniSMS provider

- Add phone field to user model with index and helper methods
- Implement SMS provider interface with UniSMS (合一短信) implementation
- Add SMS verification code sending endpoint with rate limiting (1/60s)
- Support SMS registration in Register() (mutually exclusive with email)
- Add SMS configuration to admin settings (provider, keys, signature, template)
- Display phone number in admin user list contact column
- Add i18n translations for all SMS-related messages (zh-CN, en, zh-TW)
- Add Claude Code skills: sync-upstream, migrate-server
- Update CLAUDE.md with git conventions and deployment guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nosqli
2026-03-06 23:57:15 +08:00
parent 4a4cf0a0df
commit 835cd8e74b
26 changed files with 815 additions and 17 deletions

View File

@@ -29,6 +29,8 @@ type User struct {
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Email string `json:"email" gorm:"index" validate:"max=50"`
Phone string `json:"phone" gorm:"index" validate:"max=20"`
SmsVerificationCode string `json:"sms_verification_code" gorm:"-:all"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
@@ -159,26 +161,28 @@ func generateDefaultSidebarConfigForRole(userRole int) string {
}
// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil
func CheckUserExistOrDeleted(username string, email string) (bool, error) {
func CheckUserExistOrDeleted(username string, email string, phone string) (bool, error) {
var user User
// err := DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error
// check email if empty
var err error
if email == "" {
err = DB.Unscoped().First(&user, "username = ?", username).Error
} else {
err = DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error
conditions := []string{"username = ?"}
args := []interface{}{username}
if email != "" {
conditions = append(conditions, "email = ?")
args = append(args, email)
}
if phone != "" {
conditions = append(conditions, "phone = ?")
args = append(args, phone)
}
query := strings.Join(conditions, " or ")
err = DB.Unscoped().Where(query, args...).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// not exist, return false, nil
return false, nil
}
// other error, return false, err
return false, err
}
// exist, return true, nil
return true, nil
}
@@ -242,7 +246,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
query := tx.Unscoped().Model(&User{})
// 构建搜索条件
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ? OR phone LIKE ?"
// 尝试将关键字转换为整数ID
keywordInt, err := strconv.Atoi(keyword)
@@ -251,19 +255,19 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
likeCondition = "id = ? OR " + likeCondition
if group != "" {
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
}
} else {
// 非数字关键字,只搜索字符串字段
if group != "" {
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
}
}
@@ -546,6 +550,7 @@ func (user *User) ClearBinding(bindingType string) error {
bindingColumnMap := map[string]string{
"email": "email",
"phone": "phone",
"github": "github_id",
"discord": "discord_id",
"oidc": "oidc_id",
@@ -680,6 +685,18 @@ func IsEmailAlreadyTaken(email string) bool {
return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1
}
func IsPhoneAlreadyTaken(phone string) bool {
return DB.Unscoped().Where("phone = ?", phone).Find(&User{}).RowsAffected == 1
}
func (user *User) FillUserByPhone() error {
if user.Phone == "" {
return errors.New("phone 为空!")
}
DB.Where(User{Phone: user.Phone}).First(user)
return nil
}
func IsWeChatIdAlreadyTaken(wechatId string) bool {
return DB.Unscoped().Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1
}