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:
175
.claude/commands/migrate-server.md
Normal file
175
.claude/commands/migrate-server.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Migrate Server - 服务器迁移指南
|
||||
|
||||
帮助将 new-api 服务从老服务器迁移到新服务器,包括代码部署、数据库迁移和配置同步。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 收集信息
|
||||
先向用户确认以下信息:
|
||||
- 老服务器 IP / 访问方式
|
||||
- 新服务器 IP / 访问方式
|
||||
- 当前使用的数据库类型(SQLite / MySQL / PostgreSQL)
|
||||
- 当前部署方式(Docker Compose / 直接部署)
|
||||
- 是否使用 Redis
|
||||
- 是否有自定义域名需要迁移
|
||||
|
||||
### 2. 老服务器 - 备份数据
|
||||
|
||||
#### 2.1 数据库备份
|
||||
|
||||
**如果使用 SQLite:**
|
||||
```bash
|
||||
# 找到 SQLite 数据库文件(通常在项目根目录或 data/ 目录下)
|
||||
# 停止服务防止写入
|
||||
docker compose stop # 或 systemctl stop new-api
|
||||
|
||||
# 复制数据库文件
|
||||
cp /path/to/new-api.db /backup/new-api.db
|
||||
|
||||
# 或者使用 sqlite3 导出
|
||||
sqlite3 /path/to/new-api.db .dump > /backup/new-api-dump.sql
|
||||
```
|
||||
|
||||
**如果使用 MySQL:**
|
||||
```bash
|
||||
# 导出完整数据库
|
||||
mysqldump -u root -p --single-transaction --routines --triggers new_api > /backup/new-api-mysql.sql
|
||||
|
||||
# 或者如果 MySQL 在 Docker 中
|
||||
docker exec <mysql_container> mysqldump -u root -p<password> new_api > /backup/new-api-mysql.sql
|
||||
```
|
||||
|
||||
**如果使用 PostgreSQL:**
|
||||
```bash
|
||||
# 导出完整数据库
|
||||
pg_dump -U postgres -d new_api -F c -f /backup/new-api-pg.dump
|
||||
|
||||
# 或者如果在 Docker 中
|
||||
docker exec <pg_container> pg_dump -U postgres -d new_api -F c > /backup/new-api-pg.dump
|
||||
```
|
||||
|
||||
#### 2.2 配置备份
|
||||
```bash
|
||||
# 备份环境变量文件
|
||||
cp .env /backup/.env
|
||||
|
||||
# 备份 Docker Compose 配置
|
||||
cp docker-compose.yml /backup/docker-compose.yml
|
||||
|
||||
# 备份其他配置文件(如果有)
|
||||
cp -r config/ /backup/config/ 2>/dev/null
|
||||
```
|
||||
|
||||
#### 2.3 Redis 数据备份(如果使用)
|
||||
```bash
|
||||
# Redis 数据通常不需要迁移(缓存会自动重建)
|
||||
# 但如果需要:
|
||||
redis-cli BGSAVE
|
||||
cp /var/lib/redis/dump.rdb /backup/redis-dump.rdb
|
||||
```
|
||||
|
||||
### 3. 传输备份到新服务器
|
||||
```bash
|
||||
# 使用 scp 传输
|
||||
scp -r /backup/ user@new-server:/path/to/backup/
|
||||
|
||||
# 或使用 rsync
|
||||
rsync -avz /backup/ user@new-server:/path/to/backup/
|
||||
```
|
||||
|
||||
### 4. 新服务器 - 部署准备
|
||||
|
||||
#### 4.1 安装基础环境
|
||||
```bash
|
||||
# Docker + Docker Compose(推荐)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
apt install docker-compose-plugin # 或 docker compose v2
|
||||
|
||||
# 或者直接部署需要 Go 1.22+ 和 Node.js/Bun
|
||||
```
|
||||
|
||||
#### 4.2 拉取代码
|
||||
```bash
|
||||
git clone https://git.586vip.cn/huangzhenpc/newapi-yx-diy.git
|
||||
cd newapi-yx-diy
|
||||
git checkout dev-v0.11.0-alpha.9 # 或目标分支
|
||||
```
|
||||
|
||||
#### 4.3 恢复配置
|
||||
```bash
|
||||
# 恢复环境变量
|
||||
cp /path/to/backup/.env .env
|
||||
|
||||
# 恢复 Docker Compose 配置(如有定制)
|
||||
cp /path/to/backup/docker-compose.yml docker-compose.yml
|
||||
```
|
||||
|
||||
### 5. 新服务器 - 恢复数据库
|
||||
|
||||
**如果使用 SQLite:**
|
||||
```bash
|
||||
# 直接复制数据库文件到项目目录
|
||||
cp /path/to/backup/new-api.db ./data/new-api.db
|
||||
# GORM AutoMigrate 会在启动时自动添加新字段(如 Phone)
|
||||
```
|
||||
|
||||
**如果使用 MySQL:**
|
||||
```bash
|
||||
# 先启动 MySQL 容器
|
||||
docker compose up -d mysql
|
||||
|
||||
# 等待 MySQL 完全启动
|
||||
sleep 10
|
||||
|
||||
# 导入数据
|
||||
docker exec -i <mysql_container> mysql -u root -p<password> new_api < /path/to/backup/new-api-mysql.sql
|
||||
```
|
||||
|
||||
**如果使用 PostgreSQL:**
|
||||
```bash
|
||||
# 先启动 PostgreSQL 容器
|
||||
docker compose up -d postgres
|
||||
|
||||
# 等待 PostgreSQL 完全启动
|
||||
sleep 5
|
||||
|
||||
# 创建数据库(如果不存在)
|
||||
docker exec <pg_container> createdb -U postgres new_api
|
||||
|
||||
# 导入数据
|
||||
docker exec -i <pg_container> pg_restore -U postgres -d new_api < /path/to/backup/new-api-pg.dump
|
||||
```
|
||||
|
||||
### 6. 新服务器 - 启动服务
|
||||
```bash
|
||||
# Docker Compose 部署
|
||||
docker compose up -d
|
||||
|
||||
# 检查服务状态
|
||||
docker compose ps
|
||||
docker compose logs -f --tail=50
|
||||
```
|
||||
|
||||
### 7. 验证
|
||||
|
||||
向用户确认以下检查项:
|
||||
- [ ] 服务是否正常启动(访问 `/api/status`)
|
||||
- [ ] 管理员账号能否正常登录
|
||||
- [ ] 用户数据是否完整
|
||||
- [ ] 渠道配置是否正常
|
||||
- [ ] API 密钥是否可用
|
||||
- [ ] 如果有自定义域名,DNS 是否已切换
|
||||
|
||||
### 8. 切换流量
|
||||
- 更新 DNS 记录指向新服务器
|
||||
- 或更新反向代理(Nginx/Caddy)配置
|
||||
- 老服务器保留一段时间作为回退方案
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **GORM AutoMigrate**:新版本可能有新的数据库字段(如 `phone`),启动时 GORM 会自动添加,不需要手动执行迁移脚本
|
||||
2. **环境变量**:确保 `.env` 中的数据库连接字符串指向正确的地址
|
||||
3. **数据一致性**:迁移前停止老服务器的写入,避免数据不一致
|
||||
4. **Redis**:Redis 缓存数据不需要迁移,服务启动后会自动重建
|
||||
5. **密钥安全**:传输过程中注意保护敏感信息(数据库密码、API 密钥等)
|
||||
6. **回退方案**:保留老服务器至少 1 周,确认新服务器稳定后再关闭
|
||||
72
.claude/commands/sync-upstream.md
Normal file
72
.claude/commands/sync-upstream.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Sync Upstream - 从上游仓库拉取合并
|
||||
|
||||
从 Calcium-Ion/new-api 上游仓库拉取最新代码并安全合并到当前分支。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### 1. 环境准备
|
||||
- 检查当前工作区是否有未提交的更改,如有则先提示用户处理
|
||||
- 检查是否已配置 `upstream` remote,如果没有则添加:
|
||||
```
|
||||
git remote add upstream https://github.com/Calcium-Ion/new-api.git
|
||||
```
|
||||
|
||||
### 2. 拉取上游代码
|
||||
```
|
||||
git fetch upstream
|
||||
```
|
||||
|
||||
### 3. 分析差异
|
||||
- 运行 `git log HEAD..upstream/main --oneline` 查看上游有哪些新提交
|
||||
- 运行 `git diff HEAD...upstream/main --stat` 查看哪些文件被改动
|
||||
- 将差异分析结果展示给用户
|
||||
|
||||
### 4. 冲突风险评估
|
||||
重点关注以下我们自定义修改过的文件,标记为**高风险**:
|
||||
- `CLAUDE.md` — 我们的项目规范,**必须保留我们的版本**
|
||||
- `.claude/` — 我们的 Claude Code 配置和 skills,**必须保留**
|
||||
- `common/sms.go`, `common/sms_unisms.go` — 短信功能(我们新增)
|
||||
- `middleware/sms-verification-rate-limit.go` — 短信限流(我们新增)
|
||||
- `common/constants.go` — 我们新增了 SMS 相关常量
|
||||
- `model/user.go` — 我们新增了 Phone 字段和相关方法
|
||||
- `model/option.go` — 我们新增了 SMS 相关配置
|
||||
- `controller/misc.go` — 我们新增了 SMS 验证接口
|
||||
- `controller/user.go` — 我们修改了注册逻辑
|
||||
- `router/api-router.go` — 我们新增了 SMS 路由
|
||||
- `i18n/` — 我们新增了 SMS 相关翻译
|
||||
- `web/src/components/auth/RegisterForm.jsx` — 手机号注册 UI
|
||||
- `web/src/components/settings/SystemSetting.jsx` — SMS 配置 UI
|
||||
- `web/src/components/table/users/UsersColumnDefs.jsx` — 用户列表手机号列
|
||||
- `web/src/i18n/locales/` — 前端翻译
|
||||
|
||||
将风险评估结果展示给用户,让用户确认是否继续。
|
||||
|
||||
### 5. 执行合并
|
||||
- 使用 `git merge upstream/main` 进行合并
|
||||
- **绝不**使用 `--force` 或 `--strategy-option theirs`
|
||||
|
||||
### 6. 处理冲突
|
||||
如果出现合并冲突:
|
||||
- 逐个冲突文件分析
|
||||
- 对于我们**新增**的文件(如 `common/sms.go`):保留我们的版本
|
||||
- 对于 `CLAUDE.md` 和 `.claude/`:**始终保留我们的版本**
|
||||
- 对于**双方都修改**的文件(如 `model/user.go`):智能合并,保留双方的改动
|
||||
- 读取冲突内容,理解上游改了什么、我们改了什么
|
||||
- 将两者的改动合并到一起
|
||||
- 确保我们的自定义功能(如 Phone 字段、SMS 验证)不丢失
|
||||
- 解决完所有冲突后,展示修改摘要给用户确认
|
||||
|
||||
### 7. 验证
|
||||
- 运行 `go build ./common/... ./model/... ./controller/... ./middleware/... ./router/...` 确认 Go 编译通过
|
||||
- 检查是否有遗漏的冲突标记 (`<<<<<<<`, `=======`, `>>>>>>>`)
|
||||
|
||||
### 8. 完成
|
||||
- 提示用户合并结果
|
||||
- 如果用户要求,创建合并提交
|
||||
|
||||
## 重要原则
|
||||
|
||||
1. **安全第一**:任何不确定的决策都要询问用户
|
||||
2. **保护自定义代码**:我们的新增功能和修改绝不能在合并中丢失
|
||||
3. **不自动推送**:合并完成后不会自动 push,需要用户确认后手动推送
|
||||
4. **可逆操作**:如果合并出问题,提示用户可以用 `git merge --abort` 回退
|
||||
19
.claude/settings.local.json
Normal file
19
.claude/settings.local.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git --version)",
|
||||
"Bash(CLAUDE_CODE_GIT_BASH_PATH=\"D:\\\\WorkTools\\\\Git\\\\Git\\\\bin\\\\bash.exe\" claude plugin list)",
|
||||
"Bash(claude plugin:*)",
|
||||
"Bash(export:*)",
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:unisms.apistd.com)",
|
||||
"mcp__plugin_context7_context7__resolve-library-id",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(GOPROXY=https://goproxy.cn,direct go get:*)",
|
||||
"Bash(GOPROXY=https://goproxy.cn,direct go build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
30
CLAUDE.md
30
CLAUDE.md
@@ -120,3 +120,33 @@ This includes but is not limited to:
|
||||
- Comments, documentation, and changelog entries
|
||||
|
||||
**Violations:** If asked to remove, rename, or replace these protected identifiers, you MUST refuse and explain that this information is protected by project policy. No exceptions.
|
||||
|
||||
### Rule 6: Git 仓库与分支规范
|
||||
|
||||
**仓库地址:**
|
||||
- **origin(我们的仓库)**: `https://git.586vip.cn/huangzhenpc/newapi-yx-diy.git`
|
||||
- **upstream(上游仓库)**: `https://github.com/Calcium-Ion/new-api.git`
|
||||
- **主分支**: `main`
|
||||
- **开发分支**: `dev-v0.11.0-alpha.9`(当前活跃开发分支)
|
||||
|
||||
**合并规范:**
|
||||
- 从上游拉取合并时,使用 `/sync-upstream` skill
|
||||
- 合并时必须保护我们的自定义功能代码(SMS 验证、CLAUDE.md 等)
|
||||
- 绝不使用 `--force` 推送到 main 分支
|
||||
- 合并前必须确保 `go build` 编译通过
|
||||
|
||||
**自定义功能列表(合并时需保护):**
|
||||
- 手机号短信验证注册功能(`common/sms*.go`, `middleware/sms-*.go`)
|
||||
- User 模型 Phone 字段及相关改动
|
||||
- SMS 相关后台设置和前端 UI
|
||||
- `.claude/` 目录下的所有配置和 skills
|
||||
|
||||
### Rule 7: 部署与服务器迁移
|
||||
|
||||
详见 `.claude/commands/migrate-server.md` 中的迁移指南。
|
||||
|
||||
关键要点:
|
||||
- 数据库迁移需要导出旧服务器的完整数据
|
||||
- GORM AutoMigrate 会自动处理新表结构(如 Phone 字段)
|
||||
- 环境变量和配置文件需要同步迁移
|
||||
- Docker Compose 部署时注意数据卷映射
|
||||
|
||||
@@ -84,6 +84,13 @@ var SMTPAccount = ""
|
||||
var SMTPFrom = ""
|
||||
var SMTPToken = ""
|
||||
|
||||
var SMSVerificationEnabled = false
|
||||
var SMSProvider = "" // "unisms"
|
||||
var SMSAccessKeyId = "" // UniSMS AccessKeyId
|
||||
var SMSAccessKeySecret = "" // UniSMS AccessKeySecret
|
||||
var SMSSignName = "" // SMS signature name
|
||||
var SMSTemplateCode = "" // SMS template code
|
||||
|
||||
var GitHubClientId = ""
|
||||
var GitHubClientSecret = ""
|
||||
var LinuxDOClientId = ""
|
||||
|
||||
37
common/sms.go
Normal file
37
common/sms.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// SmsProvider defines the interface for SMS providers
|
||||
type SmsProvider interface {
|
||||
SendCode(phone string, code string) error
|
||||
}
|
||||
|
||||
var smsProviders = map[string]SmsProvider{}
|
||||
|
||||
// RegisterSmsProvider registers an SMS provider by name
|
||||
func RegisterSmsProvider(name string, provider SmsProvider) {
|
||||
smsProviders[name] = provider
|
||||
}
|
||||
|
||||
// SendSMS sends a verification code via the configured SMS provider
|
||||
func SendSMS(phone string, code string) error {
|
||||
if SMSProvider == "" {
|
||||
return errors.New("SMS provider not configured")
|
||||
}
|
||||
provider, ok := smsProviders[SMSProvider]
|
||||
if !ok {
|
||||
return errors.New("unknown SMS provider: " + SMSProvider)
|
||||
}
|
||||
return provider.SendCode(phone, code)
|
||||
}
|
||||
|
||||
var chinesePhoneRegex = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
|
||||
// IsValidPhone checks if the phone number is a valid Chinese mobile number
|
||||
func IsValidPhone(phone string) bool {
|
||||
return chinesePhoneRegex.MatchString(phone)
|
||||
}
|
||||
22
common/sms_unisms.go
Normal file
22
common/sms_unisms.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
unisms "github.com/apistd/uni-go-sdk/sms"
|
||||
)
|
||||
|
||||
type UniSmsProvider struct{}
|
||||
|
||||
func (p *UniSmsProvider) SendCode(phone string, code string) error {
|
||||
client := unisms.NewClient(SMSAccessKeyId, SMSAccessKeySecret)
|
||||
message := unisms.BuildMessage()
|
||||
message.SetTo(phone)
|
||||
message.SetSignature(SMSSignName)
|
||||
message.SetTemplateId(SMSTemplateCode)
|
||||
message.SetTemplateData(map[string]string{"code": code})
|
||||
_, err := client.Send(message)
|
||||
return err
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterSmsProvider("unisms", &UniSmsProvider{})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type verificationValue struct {
|
||||
const (
|
||||
EmailVerificationPurpose = "v"
|
||||
PasswordResetPurpose = "r"
|
||||
SmsVerificationPurpose = "s"
|
||||
)
|
||||
|
||||
var verificationMutex sync.Mutex
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
@@ -51,6 +52,7 @@ func GetStatus(c *gin.Context) {
|
||||
"version": common.Version,
|
||||
"start_time": common.StartTime,
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"sms_verification": common.SMSVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
|
||||
@@ -299,6 +301,38 @@ func SendEmailVerification(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func SendSmsVerification(c *gin.Context) {
|
||||
phone := c.Query("phone")
|
||||
if phone == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPhoneEmpty)
|
||||
return
|
||||
}
|
||||
if !common.IsValidPhone(phone) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPhoneFormatInvalid)
|
||||
return
|
||||
}
|
||||
if !common.SMSVerificationEnabled {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSmsNotConfigured)
|
||||
return
|
||||
}
|
||||
if model.IsPhoneAlreadyTaken(phone) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPhoneAlreadyTaken)
|
||||
return
|
||||
}
|
||||
code := common.GenerateVerificationCode(6)
|
||||
common.RegisterVerificationCodeWithKey(phone, code, common.SmsVerificationPurpose)
|
||||
err := common.SendSMS(phone, code)
|
||||
if err != nil {
|
||||
common.SysLog("failed to send SMS: " + err.Error())
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSendSmsFailed)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
|
||||
func SendPasswordResetEmail(c *gin.Context) {
|
||||
email := c.Query("email")
|
||||
if err := common.Validate.Var(email, "required,email"); err != nil {
|
||||
|
||||
@@ -239,7 +239,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
|
||||
if oauthUser.Username != "" {
|
||||
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists {
|
||||
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, "", ""); err == nil && !exists {
|
||||
// 防止索引退化
|
||||
if len(oauthUser.Username) <= model.UserNameMaxLength {
|
||||
user.Username = oauthUser.Username
|
||||
|
||||
@@ -157,7 +157,21 @@ func Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email)
|
||||
if common.SMSVerificationEnabled {
|
||||
if user.Phone == "" || user.SmsVerificationCode == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSmsVerificationRequired)
|
||||
return
|
||||
}
|
||||
if !common.IsValidPhone(user.Phone) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserPhoneFormatInvalid)
|
||||
return
|
||||
}
|
||||
if !common.VerifyCodeWithKey(user.Phone, user.SmsVerificationCode, common.SmsVerificationPurpose) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
|
||||
return
|
||||
}
|
||||
}
|
||||
exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email, user.Phone)
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
|
||||
@@ -179,6 +193,9 @@ func Register(c *gin.Context) {
|
||||
if common.EmailVerificationEnabled {
|
||||
cleanUser.Email = user.Email
|
||||
}
|
||||
if common.SMSVerificationEnabled {
|
||||
cleanUser.Phone = user.Phone
|
||||
}
|
||||
if err := cleanUser.Insert(inviterId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -390,6 +407,7 @@ func GetSelf(c *gin.Context) {
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"email": user.Email,
|
||||
"phone": user.Phone,
|
||||
"github_id": user.GitHubId,
|
||||
"discord_id": user.DiscordId,
|
||||
"oidc_id": user.OidcId,
|
||||
|
||||
1
go.mod
1
go.mod
@@ -62,6 +62,7 @@ require (
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
github.com/apistd/uni-go-sdk v0.0.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -10,6 +10,8 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
|
||||
github.com/apistd/uni-go-sdk v0.0.2 h1:7kqETCOz/rz8AQU55XGzxDFGoFeMgeZL5fGwvxKBZrc=
|
||||
github.com/apistd/uni-go-sdk v0.0.2/go.mod h1:eIqYos4IbHgE/rB75r05ypNLahooEMJCrbjXq322b74=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
|
||||
|
||||
@@ -100,6 +100,12 @@ const (
|
||||
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
|
||||
MsgUserTelegramNotBound = "user.telegram_not_bound"
|
||||
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
|
||||
MsgUserSmsVerificationRequired = "user.sms_verification_required"
|
||||
MsgUserPhoneFormatInvalid = "user.phone_format_invalid"
|
||||
MsgUserPhoneAlreadyTaken = "user.phone_already_taken"
|
||||
MsgUserSendSmsFailed = "user.send_sms_failed"
|
||||
MsgUserSmsNotConfigured = "user.sms_not_configured"
|
||||
MsgUserPhoneEmpty = "user.phone_empty"
|
||||
)
|
||||
|
||||
// Quota related messages
|
||||
|
||||
@@ -90,6 +90,12 @@ user.wechat_id_empty: "WeChat ID is empty!"
|
||||
user.telegram_id_empty: "Telegram ID is empty!"
|
||||
user.telegram_not_bound: "This Telegram account is not bound"
|
||||
user.linux_do_id_empty: "Linux DO ID is empty!"
|
||||
user.sms_verification_required: "SMS verification is enabled, please enter your phone number and verification code"
|
||||
user.phone_format_invalid: "Invalid phone number format"
|
||||
user.phone_already_taken: "This phone number is already registered"
|
||||
user.send_sms_failed: "Failed to send SMS, please try again later"
|
||||
user.sms_not_configured: "SMS service is not configured"
|
||||
user.phone_empty: "Phone number cannot be empty"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "Quota cannot be negative!"
|
||||
|
||||
@@ -91,6 +91,12 @@ user.wechat_id_empty: "WeChat id 为空!"
|
||||
user.telegram_id_empty: "Telegram id 为空!"
|
||||
user.telegram_not_bound: "该 Telegram 账户未绑定"
|
||||
user.linux_do_id_empty: "Linux DO id 为空!"
|
||||
user.sms_verification_required: "管理员开启了短信验证,请输入手机号和验证码"
|
||||
user.phone_format_invalid: "手机号格式不正确"
|
||||
user.phone_already_taken: "该手机号已被注册"
|
||||
user.send_sms_failed: "短信发送失败,请稍后重试"
|
||||
user.sms_not_configured: "短信服务未配置"
|
||||
user.phone_empty: "手机号不能为空"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "额度不能为负数!"
|
||||
|
||||
@@ -91,6 +91,12 @@ user.wechat_id_empty: "WeChat id 為空!"
|
||||
user.telegram_id_empty: "Telegram id 為空!"
|
||||
user.telegram_not_bound: "該 Telegram 帳號未綁定"
|
||||
user.linux_do_id_empty: "Linux DO id 為空!"
|
||||
user.sms_verification_required: "管理員開啟了簡訊驗證,請輸入手機號和驗證碼"
|
||||
user.phone_format_invalid: "手機號格式不正確"
|
||||
user.phone_already_taken: "該手機號已被註冊"
|
||||
user.send_sms_failed: "簡訊發送失敗,請稍後重試"
|
||||
user.sms_not_configured: "簡訊服務未配置"
|
||||
user.phone_empty: "手機號不能為空"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "額度不能為負數!"
|
||||
|
||||
77
middleware/sms-verification-rate-limit.go
Normal file
77
middleware/sms-verification-rate-limit.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
SmsVerificationRateLimitMark = "SMS"
|
||||
SmsVerificationMaxRequests = 1 // 60 seconds max 1 request
|
||||
SmsVerificationDuration = 60 // 60 second window
|
||||
)
|
||||
|
||||
func redisSmsVerificationRateLimiter(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
rdb := common.RDB
|
||||
key := "smsVerification:" + SmsVerificationRateLimitMark + ":" + c.ClientIP()
|
||||
|
||||
count, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
memorySmsVerificationRateLimiter(c)
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
_ = rdb.Expire(ctx, key, time.Duration(SmsVerificationDuration)*time.Second).Err()
|
||||
}
|
||||
|
||||
if count <= int64(SmsVerificationMaxRequests) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
ttl, err := rdb.TTL(ctx, key).Result()
|
||||
waitSeconds := int64(SmsVerificationDuration)
|
||||
if err == nil && ttl > 0 {
|
||||
waitSeconds = int64(ttl.Seconds())
|
||||
}
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds),
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func memorySmsVerificationRateLimiter(c *gin.Context) {
|
||||
key := SmsVerificationRateLimitMark + ":" + c.ClientIP()
|
||||
|
||||
if !inMemoryRateLimiter.Request(key, SmsVerificationMaxRequests, SmsVerificationDuration) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"success": false,
|
||||
"message": "发送过于频繁,请稍后再试",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func SmsVerificationRateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if common.RedisEnabled {
|
||||
redisSmsVerificationRateLimiter(c)
|
||||
} else {
|
||||
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
|
||||
memorySmsVerificationRateLimiter(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,12 @@ func InitOptionMap() {
|
||||
common.OptionMap["SMTPAccount"] = ""
|
||||
common.OptionMap["SMTPToken"] = ""
|
||||
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
|
||||
common.OptionMap["SMSVerificationEnabled"] = strconv.FormatBool(common.SMSVerificationEnabled)
|
||||
common.OptionMap["SMSProvider"] = common.SMSProvider
|
||||
common.OptionMap["SMSAccessKeyId"] = common.SMSAccessKeyId
|
||||
common.OptionMap["SMSAccessKeySecret"] = common.SMSAccessKeySecret
|
||||
common.OptionMap["SMSSignName"] = common.SMSSignName
|
||||
common.OptionMap["SMSTemplateCode"] = common.SMSTemplateCode
|
||||
common.OptionMap["Notice"] = ""
|
||||
common.OptionMap["About"] = ""
|
||||
common.OptionMap["HomePageContent"] = ""
|
||||
@@ -292,6 +298,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.StopOnSensitiveEnabled = boolValue
|
||||
case "SMTPSSLEnabled":
|
||||
common.SMTPSSLEnabled = boolValue
|
||||
case "SMSVerificationEnabled":
|
||||
common.SMSVerificationEnabled = boolValue
|
||||
case "WorkerAllowHttpImageRequestEnabled":
|
||||
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
|
||||
case "DefaultUseAutoGroup":
|
||||
@@ -314,6 +322,16 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.SMTPFrom = value
|
||||
case "SMTPToken":
|
||||
common.SMTPToken = value
|
||||
case "SMSProvider":
|
||||
common.SMSProvider = value
|
||||
case "SMSAccessKeyId":
|
||||
common.SMSAccessKeyId = value
|
||||
case "SMSAccessKeySecret":
|
||||
common.SMSAccessKeySecret = value
|
||||
case "SMSSignName":
|
||||
common.SMSSignName = value
|
||||
case "SMSTemplateCode":
|
||||
common.SMSTemplateCode = value
|
||||
case "ServerAddress":
|
||||
system_setting.ServerAddress = value
|
||||
case "WorkerUrl":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/sms_verification", middleware.SmsVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendSmsVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
||||
// OAuth routes - specific routes must come before :provider wildcard
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconPhone,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
@@ -79,6 +80,8 @@ const RegisterForm = () => {
|
||||
password2: '',
|
||||
email: '',
|
||||
verification_code: '',
|
||||
phone: '',
|
||||
sms_verification_code: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
@@ -142,9 +145,14 @@ const RegisterForm = () => {
|
||||
);
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [showSmsVerification, setShowSmsVerification] = useState(false);
|
||||
const [smsCodeLoading, setSmsCodeLoading] = useState(false);
|
||||
const [smsDisableButton, setSmsDisableButton] = useState(false);
|
||||
const [smsCountdown, setSmsCountdown] = useState(60);
|
||||
|
||||
useEffect(() => {
|
||||
setShowEmailVerification(!!status?.email_verification);
|
||||
setShowSmsVerification(!!status?.sms_verification);
|
||||
if (status?.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
@@ -168,6 +176,19 @@ const RegisterForm = () => {
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
let smsInterval = null;
|
||||
if (smsDisableButton && smsCountdown > 0) {
|
||||
smsInterval = setInterval(() => {
|
||||
setSmsCountdown(smsCountdown - 1);
|
||||
}, 1000);
|
||||
} else if (smsCountdown === 0) {
|
||||
setSmsDisableButton(false);
|
||||
setSmsCountdown(60);
|
||||
}
|
||||
return () => clearInterval(smsInterval);
|
||||
}, [smsDisableButton, smsCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
@@ -279,6 +300,31 @@ const RegisterForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendSmsVerificationCode = async () => {
|
||||
if (inputs.phone === '') return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setSmsCodeLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/sms_verification?phone=${encodeURIComponent(inputs.phone)}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('验证码发送成功,请查看手机短信!'));
|
||||
setSmsDisableButton(true);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('发送验证码失败,请重试'));
|
||||
} finally {
|
||||
setSmsCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubClick = () => {
|
||||
if (githubButtonDisabled) {
|
||||
return;
|
||||
@@ -637,6 +683,40 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSmsVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='phone'
|
||||
label={t('手机号')}
|
||||
placeholder={t('输入手机号')}
|
||||
name='phone'
|
||||
onChange={(value) => handleChange('phone', value)}
|
||||
prefix={<IconPhone />}
|
||||
suffix={
|
||||
<Button
|
||||
onClick={sendSmsVerificationCode}
|
||||
loading={smsCodeLoading}
|
||||
disabled={smsDisableButton || smsCodeLoading}
|
||||
>
|
||||
{smsDisableButton
|
||||
? `${t('重新发送')} (${smsCountdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='sms_verification_code'
|
||||
label={t('短信验证码')}
|
||||
placeholder={t('输入短信验证码')}
|
||||
name='sms_verification_code'
|
||||
onChange={(value) =>
|
||||
handleChange('sms_verification_code', value)
|
||||
}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
|
||||
@@ -50,6 +50,12 @@ const SystemSetting = () => {
|
||||
PasswordLoginEnabled: '',
|
||||
PasswordRegisterEnabled: '',
|
||||
EmailVerificationEnabled: '',
|
||||
SMSVerificationEnabled: '',
|
||||
SMSProvider: '',
|
||||
SMSAccessKeyId: '',
|
||||
SMSAccessKeySecret: '',
|
||||
SMSSignName: '',
|
||||
SMSTemplateCode: '',
|
||||
GitHubOAuthEnabled: '',
|
||||
GitHubClientId: '',
|
||||
GitHubClientSecret: '',
|
||||
@@ -174,6 +180,7 @@ const SystemSetting = () => {
|
||||
case 'PasswordLoginEnabled':
|
||||
case 'PasswordRegisterEnabled':
|
||||
case 'EmailVerificationEnabled':
|
||||
case 'SMSVerificationEnabled':
|
||||
case 'GitHubOAuthEnabled':
|
||||
case 'WeChatAuthEnabled':
|
||||
case 'TelegramOAuthEnabled':
|
||||
@@ -347,6 +354,34 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitSMS = async () => {
|
||||
const options = [];
|
||||
if (originInputs['SMSProvider'] !== inputs.SMSProvider) {
|
||||
options.push({ key: 'SMSProvider', value: inputs.SMSProvider });
|
||||
}
|
||||
if (originInputs['SMSAccessKeyId'] !== inputs.SMSAccessKeyId) {
|
||||
options.push({ key: 'SMSAccessKeyId', value: inputs.SMSAccessKeyId });
|
||||
}
|
||||
if (
|
||||
originInputs['SMSAccessKeySecret'] !== inputs.SMSAccessKeySecret &&
|
||||
inputs.SMSAccessKeySecret !== ''
|
||||
) {
|
||||
options.push({
|
||||
key: 'SMSAccessKeySecret',
|
||||
value: inputs.SMSAccessKeySecret,
|
||||
});
|
||||
}
|
||||
if (originInputs['SMSSignName'] !== inputs.SMSSignName) {
|
||||
options.push({ key: 'SMSSignName', value: inputs.SMSSignName });
|
||||
}
|
||||
if (originInputs['SMSTemplateCode'] !== inputs.SMSTemplateCode) {
|
||||
options.push({ key: 'SMSTemplateCode', value: inputs.SMSTemplateCode });
|
||||
}
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
}
|
||||
};
|
||||
|
||||
const submitEmailDomainWhitelist = async () => {
|
||||
if (Array.isArray(emailDomainWhitelist)) {
|
||||
await updateOptions([
|
||||
@@ -681,6 +716,24 @@ const SystemSetting = () => {
|
||||
|
||||
if (optionKey === 'PasswordLoginEnabled' && !value) {
|
||||
setShowPasswordLoginConfirmModal(true);
|
||||
} else if (optionKey === 'SMSVerificationEnabled' && value) {
|
||||
// When enabling SMS verification, disable email verification (mutual exclusion)
|
||||
await updateOptions([
|
||||
{ key: 'SMSVerificationEnabled', value: true },
|
||||
{ key: 'EmailVerificationEnabled', value: false },
|
||||
]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('EmailVerificationEnabled', false);
|
||||
}
|
||||
} else if (optionKey === 'EmailVerificationEnabled' && value) {
|
||||
// When enabling email verification, disable SMS verification (mutual exclusion)
|
||||
await updateOptions([
|
||||
{ key: 'EmailVerificationEnabled', value: true },
|
||||
{ key: 'SMSVerificationEnabled', value: false },
|
||||
]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('SMSVerificationEnabled', false);
|
||||
}
|
||||
} else {
|
||||
await updateOptions([{ key: optionKey, value }]);
|
||||
}
|
||||
@@ -1015,6 +1068,15 @@ const SystemSetting = () => {
|
||||
>
|
||||
{t('通过密码注册时需要进行邮箱验证')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='SMSVerificationEnabled'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('SMSVerificationEnabled', e)
|
||||
}
|
||||
>
|
||||
{t('通过密码注册时需要进行短信验证(与邮箱验证互斥)')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='RegisterEnabled'
|
||||
noLabel
|
||||
@@ -1340,6 +1402,57 @@ const SystemSetting = () => {
|
||||
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text={t('配置短信服务')}>
|
||||
<Text>{t('用以支持短信验证码注册(与邮箱验证互斥)')}</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
field='SMSProvider'
|
||||
label={t('短信服务商')}
|
||||
placeholder={t('选择短信服务商')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value='unisms'>UniSMS (合一短信)</Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSAccessKeyId'
|
||||
label={t('AccessKey ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSAccessKeySecret'
|
||||
label={t('AccessKey Secret')}
|
||||
type='password'
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSSignName'
|
||||
label={t('短信签名')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSTemplateCode'
|
||||
label={t('短信模板 ID')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitSMS} style={{ marginTop: 10 }}>{t('保存短信设置')}</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text={t('配置 OIDC')}>
|
||||
<Text>
|
||||
|
||||
@@ -331,6 +331,26 @@ export const getUsersColumns = ({
|
||||
key: 'quota_usage',
|
||||
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('联系方式'),
|
||||
key: 'contact',
|
||||
render: (text, record) => {
|
||||
const parts = [];
|
||||
if (record.email) parts.push(record.email);
|
||||
if (record.phone) parts.push(record.phone);
|
||||
return parts.length > 0 ? (
|
||||
<Space spacing={2} vertical align='start'>
|
||||
{parts.map((p, i) => (
|
||||
<Tag key={i} color='white' shape='circle' className='!text-xs'>
|
||||
{p}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
|
||||
@@ -2603,6 +2603,21 @@
|
||||
"验证数据库连接状态": "Verify database connection status",
|
||||
"验证码": "Verification Code",
|
||||
"验证码发送成功,请检查邮箱!": "The verification code was sent successfully, please check your email!",
|
||||
"验证码发送成功,请查看手机短信!": "Verification code sent successfully, please check your SMS!",
|
||||
"发送验证码失败,请重试": "Failed to send verification code, please try again",
|
||||
"手机号": "Phone Number",
|
||||
"输入手机号": "Enter phone number",
|
||||
"短信验证码": "SMS Verification Code",
|
||||
"输入短信验证码": "Enter SMS verification code",
|
||||
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "SMS verification required for password registration (mutually exclusive with email verification)",
|
||||
"配置短信服务": "Configure SMS Service",
|
||||
"用以支持短信验证码注册(与邮箱验证互斥)": "To support SMS verification code registration (mutually exclusive with email verification)",
|
||||
"短信服务商": "SMS Provider",
|
||||
"选择短信服务商": "Select SMS provider",
|
||||
"短信签名": "SMS Signature",
|
||||
"短信模板 ID": "SMS Template ID",
|
||||
"保存短信设置": "Save SMS Settings",
|
||||
"联系方式": "Contact",
|
||||
"验证设置": "Verify setup",
|
||||
"验证身份": "Verify identity",
|
||||
"验证配置错误": "Verification configuration error",
|
||||
|
||||
@@ -2622,6 +2622,21 @@
|
||||
"验证数据库连接状态": "验证数据库连接状态",
|
||||
"验证码": "验证码",
|
||||
"验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!",
|
||||
"验证码发送成功,请查看手机短信!": "验证码发送成功,请查看手机短信!",
|
||||
"发送验证码失败,请重试": "发送验证码失败,请重试",
|
||||
"手机号": "手机号",
|
||||
"输入手机号": "输入手机号",
|
||||
"短信验证码": "短信验证码",
|
||||
"输入短信验证码": "输入短信验证码",
|
||||
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "通过密码注册时需要进行短信验证(与邮箱验证互斥)",
|
||||
"配置短信服务": "配置短信服务",
|
||||
"用以支持短信验证码注册(与邮箱验证互斥)": "用以支持短信验证码注册(与邮箱验证互斥)",
|
||||
"短信服务商": "短信服务商",
|
||||
"选择短信服务商": "选择短信服务商",
|
||||
"短信签名": "短信签名",
|
||||
"短信模板 ID": "短信模板 ID",
|
||||
"保存短信设置": "保存短信设置",
|
||||
"联系方式": "联系方式",
|
||||
"验证设置": "验证设置",
|
||||
"验证身份": "验证身份",
|
||||
"验证配置错误": "验证配置错误",
|
||||
|
||||
Reference in New Issue
Block a user