From dd60174344f8e9f80807c581ca188086a9d6c11b Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 3 Apr 2026 10:49:14 +0800 Subject: [PATCH 1/6] docs: add CHANGELOG_FORK.md for fork-specific changes Record SMS verification code login feature added in this fork: - Support Aliyun, Aliyun PNVS, Tencent Cloud SMS providers - Rate limiting: 1/60s per phone, 5/hour per IP - Auto-register new users with phone number - Support 2FA and Turnstile verification Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG_FORK.md | 195 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 CHANGELOG_FORK.md diff --git a/CHANGELOG_FORK.md b/CHANGELOG_FORK.md new file mode 100644 index 000000000000..8dcd798b6112 --- /dev/null +++ b/CHANGELOG_FORK.md @@ -0,0 +1,195 @@ +# Changelog for bulaya/new-api + +本文档记录了 [bulaya/new-api](https://github.com/bulaya/new-api) fork 相对于上游 [Calcium-Ion/new-api](https://github.com/Calcium-Ion/new-api) 的改动。 + +> **注意**: 本 CHANGELOG 仅记录 fork 的个性化改动,便于后续与上游同步合并。 + +--- + +## 新增功能:手机验证码登录 + +### 功能概述 + +支持用户通过手机号 + 短信验证码方式登录系统,适用于个人开发者场景(无需企业资质即可使用阿里云号码认证服务)。 + +### 支持的短信服务商 + +| 服务商 | 说明 | +|--------|------| +| 阿里云短信 | 标准短信服务,需审核签名和模板 | +| 阿里云 PNVS | 号码认证服务,个人开发者可用,系统赠送签名和模板 | +| 腾讯云短信 | 标准短信服务 | + +--- + +## 新增文件 + +### 后端 + +| 文件 | 说明 | +|------|------| +| `controller/sms_login.go` | 短信登录控制器,包含发送验证码和登录接口 | +| `middleware/sms_rate_limit.go` | 短信发送频率限制中间件 | +| `common/sms/sms.go` | SMS 发送接口定义和工厂方法 | +| `common/sms/aliyun.go` | 阿里云短信发送实现 | +| `common/sms/aliyun_pnvs.go` | 阿里云 PNVS(号码认证服务)短信发送实现 | +| `setting/system_setting/sms.go` | SMS 配置结构定义 | + +### 前端 + +| 文件 | 说明 | +|------|------| +| `web/src/components/auth/SmsLoginForm.jsx` | 短信验证码登录表单组件 | + +--- + +## 修改文件 + +### 后端 + +| 文件 | 改动说明 | +|------|----------| +| `router/api-router.go` | 新增路由:`POST /api/sms/send`、`POST /api/user/login/sms` | +| `model/user.go` | 新增 `Phone` 字段、`FillUserByPhone()`、`IsPhoneAlreadyTaken()` 方法 | +| `i18n/keys.go` | 新增短信登录相关国际化键(8 条) | +| `i18n/locales/zh-CN.yaml` | 新增中文翻译 | +| `i18n/locales/en.yaml` | 新增英文翻译 | +| `i18n/locales/zh-TW.yaml` | 新增繁体中文翻译 | + +### 前端 + +| 文件 | 改动说明 | +|------|----------| +| `web/src/components/auth/LoginForm.jsx` | 添加短信登录入口 | +| `web/src/components/settings/SystemSetting.jsx` | 添加短信服务配置界面 | +| `web/src/i18n/locales/zh-CN.json` | 新增中文翻译 | +| `web/src/i18n/locales/en.json` | 新增英文翻译 | +| 其他语言文件 | 新增对应翻译 | + +--- + +## 接口说明 + +### 1. 发送短信验证码 + +``` +POST /api/sms/send?turnstile={token} +Content-Type: application/json + +{ + "phone": "+8613800138000" +} +``` + +**响应:** +```json +{ + "success": true, + "message": "验证码发送成功" +} +``` + +### 2. 短信验证码登录 + +``` +POST /api/user/login/sms?turnstile={token} +Content-Type: application/json + +{ + "phone": "+8613800138000", + "code": "123456" +} +``` + +**响应:** +```json +{ + "success": true, + "message": "登录成功", + "data": { + "id": 1, + "username": "sms_1", + "display_name": "138****8000", + "token": "sk-xxx" + } +} +``` + +--- + +## 频率限制 + +| 限制类型 | 限制值 | 时间窗口 | +|----------|--------|----------| +| 单手机号 | 1 次 | 60 秒 | +| 单 IP | 5 次 | 1 小时 | + +--- + +## 配置说明 + +系统设置中新增 SMS 配置项: + +```json +{ + "sms": { + "enabled": true, + "provider": "aliyun_pnvs", + "access_key_id": "您的 AccessKey ID", + "access_key_secret": "您的 AccessKey Secret", + "sign_name": "系统赠送签名", + "template_code": "系统赠送模板CODE", + "app_id": "腾讯云 AppId(腾讯云专用)", + "scheme_code": "方案Code(阿里云PNVS可选)" + } +} +``` + +--- + +## 用户模型变更 + +`users` 表新增字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `phone` | varchar(20) | 手机号,带索引 | + +--- + +## 自动注册逻辑 + +当用户使用未注册的手机号登录时: + +1. 检查系统是否允许注册(`RegisterEnabled`) +2. 自动创建用户,用户名格式:`sms_{userId}` +3. 显示名称:手机号脱敏(如 `138****8000`) +4. 生成随机密码 +5. 支持邀请码(aff 参数) +6. 可配置是否生成默认 Token + +--- + +## 与上游合并注意事项 + +当与上游 [Calcium-Ion/new-api](https://github.com/Calcium-Ion/new-api) 同步时,需特别注意: + +1. **`model/user.go`** - 用户模型新增 `Phone` 字段,合并时保留 +2. **`router/api-router.go`** - 新增短信登录路由,合并时保留 +3. **数据库迁移** - 确保上游迁移不会删除 `phone` 字段 + +--- + +## 更新记录 + +| 日期 | 上游 Commit | 同步状态 | +|------|-------------|----------| +| 2026-04-02 | d22f889e | 已同步 | + +--- + +## 参考链接 + +- 上游仓库: https://github.com/Calcium-Ion/new-api +- Fork 仓库: https://github.com/bulaya/new-api +- 阿里云号码认证服务: https://dypns.aliyun.com/ From f4d5605799462d3e1d2bc164a25cdfe9fe7ab37c Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 3 Apr 2026 11:29:26 +0800 Subject: [PATCH 2/6] feat: add SMS verification code login - Support Aliyun, Aliyun PNVS, Tencent Cloud SMS providers - Add rate limiting: 1/60s per phone, 5/hour per IP - Auto-register new users with phone number - Support 2FA and Turnstile verification - Add Phone field to User model Co-Authored-By: Claude Opus 4.6 (1M context) --- common/sms/aliyun.go | 101 ++++++ common/sms/aliyun_pnvs.go | 118 +++++++ common/sms/sms.go | 44 +++ common/sms/tencent.go | 139 ++++++++ common/verification.go | 1 + controller/misc.go | 1 + controller/option.go | 8 + controller/sms_login.go | 221 ++++++++++++ controller/user.go | 1 + i18n/keys.go | 12 + i18n/locales/en.yaml | 10 + i18n/locales/zh-CN.yaml | 10 + i18n/locales/zh-TW.yaml | 10 + middleware/cors.go | 16 +- middleware/sms_rate_limit.go | 98 ++++++ model/user.go | 14 + router/api-router.go | 2 + setting/system_setting/sms.go | 24 ++ web/bun.lock | 9 +- web/src/components/auth/LoginForm.jsx | 150 ++++++-- web/src/components/auth/SmsLoginForm.jsx | 324 ++++++++++++++++++ web/src/components/common/logo/PhoneIcon.jsx | 16 + web/src/components/settings/SystemSetting.jsx | 129 +++++++ web/src/helpers/auth.jsx | 6 + web/src/i18n/locales/en.json | 40 ++- web/src/i18n/locales/zh-CN.json | 40 ++- 26 files changed, 1516 insertions(+), 28 deletions(-) create mode 100644 common/sms/aliyun.go create mode 100644 common/sms/aliyun_pnvs.go create mode 100644 common/sms/sms.go create mode 100644 common/sms/tencent.go create mode 100644 controller/sms_login.go create mode 100644 middleware/sms_rate_limit.go create mode 100644 setting/system_setting/sms.go create mode 100644 web/src/components/auth/SmsLoginForm.jsx create mode 100644 web/src/components/common/logo/PhoneIcon.jsx diff --git a/common/sms/aliyun.go b/common/sms/aliyun.go new file mode 100644 index 000000000000..db2f9d2a3dba --- /dev/null +++ b/common/sms/aliyun.go @@ -0,0 +1,101 @@ +package sms + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +// AliyunSender implements SmsSender using Alibaba Cloud SMS API. +type AliyunSender struct { + AccessKeyId string + AccessKeySecret string + SignName string + TemplateCode string +} + +func (s *AliyunSender) SendCode(phone string, code string) error { + params := map[string]string{ + "AccessKeyId": s.AccessKeyId, + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phone, + "RegionId": "cn-hangzhou", + "SignName": s.SignName, + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": uuid.New().String(), + "SignatureVersion": "1.0", + "TemplateCode": s.TemplateCode, + "TemplateParam": fmt.Sprintf(`{"code":"%s"}`, code), + "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "Version": "2017-05-25", + } + + // Sort keys + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build canonical query string + var queryParts []string + for _, k := range keys { + queryParts = append(queryParts, specialURLEncode(k)+"="+specialURLEncode(params[k])) + } + canonicalQueryString := strings.Join(queryParts, "&") + + // Build string to sign + stringToSign := "GET&" + specialURLEncode("/") + "&" + specialURLEncode(canonicalQueryString) + + // Calculate signature + mac := hmac.New(sha1.New, []byte(s.AccessKeySecret+"&")) + mac.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + // Build request URL + reqURL := "https://dysmsapi.aliyuncs.com/?" + canonicalQueryString + "&Signature=" + url.QueryEscape(signature) + + resp, err := http.Get(reqURL) + if err != nil { + return fmt.Errorf("aliyun SMS request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("aliyun SMS read response failed: %w", err) + } + + var result struct { + Code string `json:"Code"` + Message string `json:"Message"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("aliyun SMS parse response failed: %w", err) + } + + if result.Code != "OK" { + return fmt.Errorf("aliyun SMS error: %s - %s", result.Code, result.Message) + } + + return nil +} + +func specialURLEncode(s string) string { + encoded := url.QueryEscape(s) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "*", "%2A") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} diff --git a/common/sms/aliyun_pnvs.go b/common/sms/aliyun_pnvs.go new file mode 100644 index 000000000000..bab61466757b --- /dev/null +++ b/common/sms/aliyun_pnvs.go @@ -0,0 +1,118 @@ +package sms + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/google/uuid" +) + +// AliyunPnvsSender implements SmsSender using Alibaba Cloud PNVS (号码认证服务) SMS Auth API. +// This is designed for individual developers — no enterprise qualification, signature or template approval needed. +// Uses the system-provided (赠送) signatures and templates from the PNVS console. +type AliyunPnvsSender struct { + AccessKeyId string + AccessKeySecret string + SignName string // 系统赠送签名,从 PNVS 控制台获取 + TemplateCode string // 系统赠送模板,从 PNVS 控制台获取 + SchemeCode string // 方案Code(可选,融合认证方式不需要) +} + +func (s *AliyunPnvsSender) SendCode(phone string, code string) error { + // TemplateParam: 模板变量,赠送模板通常包含 code 和 min(有效期) 两个变量 + templateParam := fmt.Sprintf(`{"code":"%s","min":"10"}`, code) + + params := map[string]string{ + "AccessKeyId": s.AccessKeyId, + "Action": "SendSmsVerifyCode", + "Format": "JSON", + "PhoneNumber": phone, + "RegionId": "cn-hangzhou", + "SignName": s.SignName, + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": uuid.New().String(), + "SignatureVersion": "1.0", + "TemplateCode": s.TemplateCode, + "TemplateParam": templateParam, + "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "Version": "2017-05-25", + "CodeLength": fmt.Sprintf("%d", len(code)), + "CodeType": "1", // 1=纯数字 + "Code": code, + } + + if s.SchemeCode != "" { + params["SchemeCode"] = s.SchemeCode + } + + // Sort keys + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build canonical query string + var queryParts []string + for _, k := range keys { + queryParts = append(queryParts, pnvsSpecialURLEncode(k)+"="+pnvsSpecialURLEncode(params[k])) + } + canonicalQueryString := strings.Join(queryParts, "&") + + // Build string to sign + stringToSign := "GET&" + pnvsSpecialURLEncode("/") + "&" + pnvsSpecialURLEncode(canonicalQueryString) + + // Calculate signature + mac := hmac.New(sha1.New, []byte(s.AccessKeySecret+"&")) + mac.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + // Build request URL — note: PNVS uses dypnsapi.aliyuncs.com, NOT dysmsapi.aliyuncs.com + reqURL := "https://dypnsapi.aliyuncs.com/?" + canonicalQueryString + "&Signature=" + url.QueryEscape(signature) + + resp, err := http.Get(reqURL) + if err != nil { + return fmt.Errorf("aliyun PNVS SMS request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("aliyun PNVS SMS read response failed: %w", err) + } + + var result struct { + Code string `json:"Code"` + Message string `json:"Message"` + Model *struct { + BizId string `json:"BizId"` + VerifyCode string `json:"VerifyCode"` + } `json:"Model"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("aliyun PNVS SMS parse response failed: %w", err) + } + + if result.Code != "OK" { + return fmt.Errorf("aliyun PNVS SMS error: %s - %s", result.Code, result.Message) + } + + return nil +} + +func pnvsSpecialURLEncode(s string) string { + encoded := url.QueryEscape(s) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "*", "%2A") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} diff --git a/common/sms/sms.go b/common/sms/sms.go new file mode 100644 index 000000000000..0ed8c60be538 --- /dev/null +++ b/common/sms/sms.go @@ -0,0 +1,44 @@ +package sms + +import ( + "fmt" + + "github.com/QuantumNous/new-api/setting/system_setting" +) + +// SmsSender defines the interface for sending SMS verification codes. +type SmsSender interface { + SendCode(phone string, code string) error +} + +// NewSender creates an SmsSender based on the current SMS provider configuration. +func NewSender() (SmsSender, error) { + settings := system_setting.GetSmsSettings() + switch settings.Provider { + case "aliyun": + return &AliyunSender{ + AccessKeyId: settings.AccessKeyId, + AccessKeySecret: settings.AccessKeySecret, + SignName: settings.SignName, + TemplateCode: settings.TemplateCode, + }, nil + case "aliyun_pnvs": + return &AliyunPnvsSender{ + AccessKeyId: settings.AccessKeyId, + AccessKeySecret: settings.AccessKeySecret, + SignName: settings.SignName, + TemplateCode: settings.TemplateCode, + SchemeCode: settings.SchemeCode, + }, nil + case "tencent": + return &TencentSender{ + SecretId: settings.AccessKeyId, + SecretKey: settings.AccessKeySecret, + AppId: settings.AppId, + SignName: settings.SignName, + TemplateCode: settings.TemplateCode, + }, nil + default: + return nil, fmt.Errorf("unsupported SMS provider: %s", settings.Provider) + } +} diff --git a/common/sms/tencent.go b/common/sms/tencent.go new file mode 100644 index 000000000000..f972b0d594f2 --- /dev/null +++ b/common/sms/tencent.go @@ -0,0 +1,139 @@ +package sms + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// TencentSender implements SmsSender using Tencent Cloud SMS API. +type TencentSender struct { + SecretId string + SecretKey string + AppId string + SignName string + TemplateCode string +} + +func (s *TencentSender) SendCode(phone string, code string) error { + host := "sms.tencentcloudapi.com" + service := "sms" + action := "SendSms" + version := "2021-01-11" + timestamp := time.Now().Unix() + dateStr := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + + // Build request payload + payload := map[string]interface{}{ + "SmsSdkAppId": s.AppId, + "SignName": s.SignName, + "TemplateId": s.TemplateCode, + "PhoneNumberSet": []string{ + phone, + }, + "TemplateParamSet": []string{ + code, + }, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("tencent SMS marshal payload failed: %w", err) + } + payloadStr := string(payloadBytes) + + // Step 1: Build canonical request + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + canonicalHeaders := "content-type:application/json; charset=utf-8\nhost:" + host + "\nx-tc-action:" + strings.ToLower(action) + "\n" + signedHeaders := "content-type;host;x-tc-action" + hashedPayload := sha256hex(payloadStr) + canonicalRequest := httpRequestMethod + "\n" + canonicalURI + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload + + // Step 2: Build string to sign + algorithm := "TC3-HMAC-SHA256" + credentialScope := dateStr + "/" + service + "/tc3_request" + stringToSign := algorithm + "\n" + fmt.Sprintf("%d", timestamp) + "\n" + credentialScope + "\n" + sha256hex(canonicalRequest) + + // Step 3: Calculate signature + secretDate := hmacSha256([]byte("TC3"+s.SecretKey), dateStr) + secretService := hmacSha256(secretDate, service) + secretSigning := hmacSha256(secretService, "tc3_request") + signature := hex.EncodeToString(hmacSha256(secretSigning, stringToSign)) + + // Step 4: Build authorization header + authorization := algorithm + " " + + "Credential=" + s.SecretId + "/" + credentialScope + ", " + + "SignedHeaders=" + signedHeaders + ", " + + "Signature=" + signature + + // Send request + req, err := http.NewRequest("POST", "https://"+host, strings.NewReader(payloadStr)) + if err != nil { + return fmt.Errorf("tencent SMS create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", authorization) + req.Header.Set("Host", host) + req.Header.Set("X-TC-Action", action) + req.Header.Set("X-TC-Version", version) + req.Header.Set("X-TC-Timestamp", fmt.Sprintf("%d", timestamp)) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("tencent SMS request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("tencent SMS read response failed: %w", err) + } + + var result struct { + Response struct { + SendStatusSet []struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"SendStatusSet"` + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + } `json:"Response"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("tencent SMS parse response failed: %w", err) + } + + if result.Response.Error != nil { + return fmt.Errorf("tencent SMS error: %s - %s", result.Response.Error.Code, result.Response.Error.Message) + } + + if len(result.Response.SendStatusSet) > 0 && result.Response.SendStatusSet[0].Code != "Ok" { + return fmt.Errorf("tencent SMS send error: %s - %s", result.Response.SendStatusSet[0].Code, result.Response.SendStatusSet[0].Message) + } + + return nil +} + +func sha256hex(s string) string { + h := sha256.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} + +func hmacSha256(key []byte, data string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + return mac.Sum(nil) +} diff --git a/common/verification.go b/common/verification.go index 41fd3c943e7e..a5bc797add63 100644 --- a/common/verification.go +++ b/common/verification.go @@ -16,6 +16,7 @@ type verificationValue struct { const ( EmailVerificationPurpose = "v" PasswordResetPurpose = "r" + SmsVerificationPurpose = "s" ) var verificationMutex sync.Mutex diff --git a/controller/misc.go b/controller/misc.go index 519caed57b81..fff5a7864dc1 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -117,6 +117,7 @@ func GetStatus(c *gin.Context) { "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, + "sms_login": system_setting.GetSmsSettings().Enabled, } // 根据启用状态注入可选内容 diff --git a/controller/option.go b/controller/option.go index ecb1e25e8677..b60378b9ccaf 100644 --- a/controller/option.go +++ b/controller/option.go @@ -188,6 +188,14 @@ func UpdateOption(c *gin.Context) { }) return } + case "sms.enabled": + if option.Value == "true" && system_setting.GetSmsSettings().Provider == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用短信登录,请先配置短信服务商!", + }) + return + } case "GroupRatio": err = ratio_setting.CheckGroupRatio(option.Value.(string)) if err != nil { diff --git a/controller/sms_login.go b/controller/sms_login.go new file mode 100644 index 000000000000..22bc5a1e23d2 --- /dev/null +++ b/controller/sms_login.go @@ -0,0 +1,221 @@ +package controller + +import ( + "fmt" + "net/http" + "regexp" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/common/sms" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +var phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{6,14}$`) + +type SmsRequest struct { + Phone string `json:"phone"` +} + +type SmsLoginRequest struct { + Phone string `json:"phone"` + Code string `json:"code"` +} + +func SendSmsVerification(c *gin.Context) { + smsSettings := system_setting.GetSmsSettings() + if !smsSettings.Enabled { + common.ApiErrorI18n(c, i18n.MsgSmsLoginDisabled) + return + } + + var req SmsRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + + if req.Phone == "" { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneRequired) + return + } + + if !phoneRegex.MatchString(req.Phone) { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneInvalid) + return + } + + code := common.GenerateVerificationCode(6) + common.RegisterVerificationCodeWithKey(req.Phone, code, common.SmsVerificationPurpose) + + sender, err := sms.NewSender() + if err != nil { + common.ApiErrorI18n(c, i18n.MsgSmsProviderNotConfig) + return + } + + if err := sender.SendCode(req.Phone, code); err != nil { + common.SysLog("SMS send failed: " + err.Error()) + common.ApiErrorI18n(c, i18n.MsgSmsSendFailed) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": i18n.T(c, i18n.MsgSmsSendSuccess), + }) +} + +func SmsLogin(c *gin.Context) { + smsSettings := system_setting.GetSmsSettings() + if !smsSettings.Enabled { + common.ApiErrorI18n(c, i18n.MsgSmsLoginDisabled) + return + } + + var req SmsLoginRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + + if req.Phone == "" { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneRequired) + return + } + + if req.Code == "" { + common.ApiErrorI18n(c, i18n.MsgSmsCodeRequired) + return + } + + if !phoneRegex.MatchString(req.Phone) { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneInvalid) + return + } + + if !common.VerifyCodeWithKey(req.Phone, req.Code, common.SmsVerificationPurpose) { + common.ApiErrorI18n(c, i18n.MsgSmsVerificationCodeErr) + return + } + + common.DeleteKey(req.Phone, common.SmsVerificationPurpose) + + user := model.User{Phone: req.Phone} + err := user.FillUserByPhone() + if err != nil || user.Id == 0 { + // User not found - auto register + if !common.RegisterEnabled { + common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled) + return + } + + nextId := model.GetMaxUserId() + 1 + username := fmt.Sprintf("sms_%d", nextId) + displayName := maskPhone(req.Phone) + + randPassword, err := common.GenerateKey() + if err != nil { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + + newUser := model.User{ + Username: username, + Password: randPassword, + DisplayName: displayName, + Phone: req.Phone, + Role: common.RoleCommonUser, + } + + inviterId := 0 + affCode := c.Query("aff") + if affCode != "" { + inviterId, _ = model.GetUserIdByAffCode(affCode) + } + + if err := newUser.Insert(inviterId); err != nil { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + + // Generate default token + if constant.GenerateDefaultToken { + var insertedUser model.User + if err := model.DB.Where("username = ?", newUser.Username).First(&insertedUser).Error; err == nil { + key, err := common.GenerateKey() + if err == nil { + token := model.Token{ + UserId: insertedUser.Id, + Name: insertedUser.Username + "的初始令牌", + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, + RemainQuota: 500000, + UnlimitedQuota: true, + ModelLimitsEnabled: false, + } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } + _ = token.Insert() + } + } + } + + // Fetch the newly created user for login + user = model.User{Phone: req.Phone} + if err := user.FillUserByPhone(); err != nil || user.Id == 0 { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + } + + if user.Status != common.UserStatusEnabled { + common.ApiErrorI18n(c, i18n.MsgUserDisabled) + return + } + + // Check 2FA + if model.IsTwoFAEnabled(user.Id) { + session := sessions.Default(c) + session.Set("pending_username", user.Username) + session.Set("pending_user_id", user.Id) + if err := session.Save(); err != nil { + common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": i18n.T(c, i18n.MsgUserRequire2FA), + "success": true, + "data": map[string]interface{}{ + "require_2fa": true, + }, + }) + return + } + + setupLogin(&user, c) +} + +func maskPhone(phone string) string { + runes := []rune(phone) + length := len(runes) + if length <= 4 { + return phone + } + // Show first 3 and last 4, mask the middle + if length >= 11 { + return string(runes[:3]) + "****" + string(runes[length-4:]) + } + // For shorter numbers, show first 2 and last 2 + return string(runes[:2]) + "****" + string(runes[length-2:]) +} diff --git a/controller/user.go b/controller/user.go index 8229d0d2c2bc..16a09b3cc415 100644 --- a/controller/user.go +++ b/controller/user.go @@ -395,6 +395,7 @@ func GetSelf(c *gin.Context) { "oidc_id": user.OidcId, "wechat_id": user.WeChatId, "telegram_id": user.TelegramId, + "phone": user.Phone, "group": user.Group, "quota": user.Quota, "used_quota": user.UsedQuota, diff --git a/i18n/keys.go b/i18n/keys.go index 4d98540a77ce..3b2dd08b0a99 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -314,3 +314,15 @@ const ( MsgCustomOAuthBindingNotFound = "custom_oauth.binding_not_found" MsgCustomOAuthProviderIdInvalid = "custom_oauth.provider_id_field_invalid" ) + +// SMS related messages +const ( + MsgSmsLoginDisabled = "sms.login_disabled" + MsgSmsSendSuccess = "sms.send_success" + MsgSmsSendFailed = "sms.send_failed" + MsgSmsCodeRequired = "sms.code_required" + MsgSmsPhoneRequired = "sms.phone_required" + MsgSmsPhoneInvalid = "sms.phone_invalid" + MsgSmsProviderNotConfig = "sms.provider_not_configured" + MsgSmsVerificationCodeErr = "sms.verification_code_error" +) diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 54dbf9181b8b..ad93bf780d72 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -263,3 +263,13 @@ custom_oauth.name_empty: "Provider name cannot be empty" custom_oauth.has_bindings: "Cannot delete provider with existing user bindings" custom_oauth.binding_not_found: "OAuth binding not found" custom_oauth.provider_id_field_invalid: "Could not extract user ID from provider response" + +# SMS messages +sms.login_disabled: "SMS login is not enabled" +sms.send_success: "Verification code sent successfully" +sms.send_failed: "Failed to send verification code" +sms.code_required: "Verification code is required" +sms.phone_required: "Phone number is required" +sms.phone_invalid: "Invalid phone number format" +sms.provider_not_configured: "SMS provider is not configured" +sms.verification_code_error: "Incorrect or expired verification code" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 4e0b5cd15d3a..c0bcac0484f2 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -264,3 +264,13 @@ custom_oauth.name_empty: "提供商名称不能为空" custom_oauth.has_bindings: "无法删除已有用户绑定的提供商" custom_oauth.binding_not_found: "OAuth 绑定不存在" custom_oauth.provider_id_field_invalid: "无法从提供商响应中提取用户 ID" + +# SMS messages +sms.login_disabled: "短信登录未启用" +sms.send_success: "验证码发送成功" +sms.send_failed: "验证码发送失败" +sms.code_required: "请输入验证码" +sms.phone_required: "请输入手机号" +sms.phone_invalid: "手机号格式无效" +sms.provider_not_configured: "短信服务未配置" +sms.verification_code_error: "验证码错误或已过期" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index dcdd331b39a3..6f0e606fcea9 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -264,3 +264,13 @@ custom_oauth.name_empty: "供應者名稱不能為空" custom_oauth.has_bindings: "無法刪除已有使用者綁定的供應者" custom_oauth.binding_not_found: "OAuth 綁定不存在" custom_oauth.provider_id_field_invalid: "無法從供應者響應中提取使用者 ID" + +# SMS messages +sms.login_disabled: "簡訊登入未啟用" +sms.send_success: "驗證碼發送成功" +sms.send_failed: "驗證碼發送失敗" +sms.code_required: "請輸入驗證碼" +sms.phone_required: "請輸入手機號" +sms.phone_invalid: "手機號格式無效" +sms.provider_not_configured: "簡訊服務未配置" +sms.verification_code_error: "驗證碼錯誤或已過期" diff --git a/middleware/cors.go b/middleware/cors.go index 6aaa15d739ce..e402de6c011f 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -1,6 +1,8 @@ package middleware import ( + "strings" + "github.com/QuantumNous/new-api/common" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -8,10 +10,22 @@ import ( func CORS() gin.HandlerFunc { config := cors.DefaultConfig() - config.AllowAllOrigins = true config.AllowCredentials = true config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} config.AllowHeaders = []string{"*"} + // AllowAllOrigins + AllowCredentials is invalid per CORS spec. + // Use AllowOriginFunc to echo back the request origin instead. + config.AllowOriginFunc = func(origin string) bool { + // Allow localhost for development + if strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") { + return true + } + // Allow all HTTPS origins (production) + if strings.HasPrefix(origin, "https://") { + return true + } + return false + } return cors.New(config) } diff --git a/middleware/sms_rate_limit.go b/middleware/sms_rate_limit.go new file mode 100644 index 000000000000..f9c51715ef33 --- /dev/null +++ b/middleware/sms_rate_limit.go @@ -0,0 +1,98 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +const ( + SmsRateLimitMark = "SMS" + SmsMaxRequestsPerPhone = 1 // 1 request per 60 seconds per phone + SmsPhoneDuration = 60 // 60 seconds + SmsMaxRequestsPerIP = 5 // 5 requests per hour per IP + SmsIPDuration = 3600 // 1 hour +) + +func redisSmsRateLimiter(c *gin.Context) { + ctx := context.Background() + rdb := common.RDB + + // Rate limit by phone number + phone := c.PostForm("phone") + if phone == "" { + // Try to read from JSON body - will be handled in controller + // For rate limiting, we still limit by IP + } else { + phoneKey := "sms:" + SmsRateLimitMark + ":phone:" + phone + count, err := rdb.Incr(ctx, phoneKey).Result() + if err == nil { + if count == 1 { + _ = rdb.Expire(ctx, phoneKey, time.Duration(SmsPhoneDuration)*time.Second).Err() + } + if count > int64(SmsMaxRequestsPerPhone) { + ttl, err := rdb.TTL(ctx, phoneKey).Result() + waitSeconds := int64(SmsPhoneDuration) + if err == nil && ttl > 0 { + waitSeconds = int64(ttl.Seconds()) + } + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds), + }) + c.Abort() + return + } + } + } + + // Rate limit by IP + ipKey := "sms:" + SmsRateLimitMark + ":ip:" + c.ClientIP() + count, err := rdb.Incr(ctx, ipKey).Result() + if err == nil { + if count == 1 { + _ = rdb.Expire(ctx, ipKey, time.Duration(SmsIPDuration)*time.Second).Err() + } + if count > int64(SmsMaxRequestsPerIP) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "请求过于频繁,请稍后再试", + }) + c.Abort() + return + } + } + + c.Next() +} + +func memorySmsRateLimiter(c *gin.Context) { + // Rate limit by IP (in-memory fallback) + ipKey := SmsRateLimitMark + ":ip:" + c.ClientIP() + if !inMemoryRateLimiter.Request(ipKey, SmsMaxRequestsPerIP, SmsIPDuration) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "请求过于频繁,请稍后再试", + }) + c.Abort() + return + } + + c.Next() +} + +func SmsRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + if common.RedisEnabled { + redisSmsRateLimiter(c) + } else { + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + memorySmsRateLimiter(c) + } + } +} diff --git a/model/user.go b/model/user.go index 1210b5435d04..672151ac2f37 100644 --- a/model/user.go +++ b/model/user.go @@ -34,6 +34,7 @@ type User struct { OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + Phone string `json:"phone" gorm:"type:varchar(20);column:phone;index" validate:"max=20"` VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management Quota int `json:"quota" gorm:"type:int;default:0"` @@ -552,6 +553,7 @@ func (user *User) ClearBinding(bindingType string) error { "wechat": "wechat_id", "telegram": "telegram_id", "linuxdo": "linux_do_id", + "phone": "phone", } column, ok := bindingColumnMap[bindingType] @@ -625,6 +627,18 @@ func (user *User) FillUserByEmail() error { return nil } +func (user *User) FillUserByPhone() error { + if user.Phone == "" { + return errors.New("phone 为空!") + } + DB.Where("phone = ?", user.Phone).First(user) + return nil +} + +func IsPhoneAlreadyTaken(phone string) bool { + return DB.Unscoped().Where("phone = ?", phone).Find(&User{}).RowsAffected == 1 +} + func (user *User) FillUserByGitHubId() error { if user.GitHubId == "" { return errors.New("GitHub id 为空!") diff --git a/router/api-router.go b/router/api-router.go index 35d113768be7..2b3037289f76 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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.POST("/sms/send", middleware.SmsRateLimit(), 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 @@ -58,6 +59,7 @@ func SetApiRouter(router *gin.Engine) { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) + userRoute.POST("/login/sms", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SmsLogin) userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin) userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) diff --git a/setting/system_setting/sms.go b/setting/system_setting/sms.go new file mode 100644 index 000000000000..86a443ac70bd --- /dev/null +++ b/setting/system_setting/sms.go @@ -0,0 +1,24 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type SmsSettings struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` // "aliyun", "aliyun_pnvs", "tencent" + AccessKeyId string `json:"access_key_id"` + AccessKeySecret string `json:"access_key_secret"` + SignName string `json:"sign_name"` + TemplateCode string `json:"template_code"` + AppId string `json:"app_id"` // Tencent Cloud specific + SchemeCode string `json:"scheme_code"` // Aliyun PNVS specific (optional) +} + +var defaultSmsSettings = SmsSettings{} + +func init() { + config.GlobalConfig.Register("sms", &defaultSmsSettings) +} + +func GetSmsSettings() *SmsSettings { + return &defaultSmsSettings +} diff --git a/web/bun.lock b/web/bun.lock index e3b293cb12a6..9a8419226a6e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-template", @@ -10,7 +11,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "1.12.0", + "axios": "1.13.5", "clsx": "^2.1.1", "dayjs": "^1.11.11", "history": "^5.3.0", @@ -776,7 +777,7 @@ "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], - "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -1104,13 +1105,13 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 7e8c0ce017f1..17ec4285f3a7 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -63,13 +63,18 @@ import { import OIDCIcon from '../common/logo/OIDCIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import PhoneIcon from '../common/logo/PhoneIcon'; import TwoFAVerification from './TwoFAVerification'; +import SmsLoginForm from './SmsLoginForm'; import { useTranslation } from 'react-i18next'; import { SiDiscord } from 'react-icons/si'; const LoginForm = () => { let navigate = useNavigate(); const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const isPopupMode = searchParams.get('mode') === 'popup'; + const callbackOrigin = searchParams.get('callback_origin') || ''; const githubButtonTextKeyByState = { idle: '使用 GitHub 继续', redirecting: '正在跳转 GitHub...', @@ -81,7 +86,6 @@ const LoginForm = () => { wechat_verification_code: '', }); const { username, password } = inputs; - const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); const [userState, userDispatch] = useContext(UserContext); const [statusState] = useContext(StatusContext); @@ -90,6 +94,7 @@ const LoginForm = () => { const [turnstileToken, setTurnstileToken] = useState(''); const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); const [showEmailLogin, setShowEmailLogin] = useState(false); + const [showSmsLogin, setShowSmsLogin] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [githubLoading, setGithubLoading] = useState(false); const [discordLoading, setDiscordLoading] = useState(false); @@ -140,6 +145,7 @@ const LoginForm = () => { status.wechat_login || status.linuxdo_oauth || status.telegram_oauth || + status.sms_login || hasCustomOAuthProviders, ); @@ -198,8 +204,12 @@ const LoginForm = () => { localStorage.setItem('user', JSON.stringify(data)); setUserData(data); updateAPI(); - navigate('/'); - showSuccess('登录成功!'); + if (isPopupMode) { + handlePopupCallback(data); + } else { + navigate('/'); + showSuccess('登录成功!'); + } setShowWeChatLoginModal(false); } else { showError(message); @@ -247,15 +257,19 @@ const LoginForm = () => { userDispatch({ type: 'login', payload: data }); setUserData(data); updateAPI(); - showSuccess('登录成功!'); - if (username === 'root' && password === '123456') { - Modal.error({ - title: '您正在使用默认密码!', - content: '请立刻修改默认密码!', - centered: true, - }); + if (isPopupMode) { + handlePopupCallback(data); + } else { + showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + Modal.error({ + title: '您正在使用默认密码!', + content: '请立刻修改默认密码!', + centered: true, + }); + } + navigate('/console'); } - navigate('/console'); } else { showError(message); } @@ -297,10 +311,14 @@ const LoginForm = () => { if (success) { userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); setUserData(data); updateAPI(); - navigate('/'); + if (isPopupMode) { + handlePopupCallback(data); + } else { + showSuccess('登录成功!'); + navigate('/'); + } } else { showError(message); } @@ -455,8 +473,12 @@ const LoginForm = () => { userDispatch({ type: 'login', payload: finish.data }); setUserData(finish.data); updateAPI(); - showSuccess('登录成功!'); - navigate('/console'); + if (isPopupMode) { + handlePopupCallback(finish.data); + } else { + showSuccess('登录成功!'); + navigate('/console'); + } } else { showError(finish.message || 'Passkey 登录失败,请重试'); } @@ -490,8 +512,65 @@ const LoginForm = () => { userDispatch({ type: 'login', payload: data }); setUserData(data); updateAPI(); - showSuccess('登录成功!'); - navigate('/console'); + if (isPopupMode) { + handlePopupCallback(data); + } else { + showSuccess('登录成功!'); + navigate('/console'); + } + }; + + // 弹窗模式:登录成功后获取 token 并回传给 opener + const handlePopupCallback = async (userData) => { + if (!window.opener || !callbackOrigin) { + showSuccess('登录成功!'); + navigate('/console'); + return; + } + try { + // 1. 获取 Access Token + const tokenRes = await API.get('/api/user/token'); + const accessToken = tokenRes.data.success ? tokenRes.data.data : ''; + + // 2. 获取 API Token 列表 + const tokensRes = await API.get('/api/token/'); + let apiTokenKey = ''; + let tokens = tokensRes.data?.data?.items || tokensRes.data?.data || []; + if (tokens.length === 0) { + // 没有 Token,创建一个 + const createRes = await API.post('/api/token/', { + name: 'AionUi Default', + remain_quota: 0, + expired_time: -1, + unlimited_quota: true, + }); + if (createRes.data.success) { + const newTokensRes = await API.get('/api/token/'); + tokens = newTokensRes.data?.data?.items || newTokensRes.data?.data || []; + } + } + if (tokens.length > 0) { + // 获取第一个 token 的 key + const keyRes = await API.post(`/api/token/${tokens[0].id}/key`); + if (keyRes.data.success) { + apiTokenKey = keyRes.data.data; + } + } + + // 3. postMessage 回传 + window.opener.postMessage({ + type: 'aionui-auth', + accessToken, + apiToken: apiTokenKey, + user: userData, + }, callbackOrigin); + + // 4. 关闭弹窗 + window.close(); + } catch (error) { + console.error('Popup callback failed:', error); + showError('获取令牌失败,请重试'); + } }; // 返回登录页面 @@ -642,6 +721,26 @@ const LoginForm = () => { )} + {status.sms_login && ( + + )} + {t('或')} @@ -958,10 +1057,19 @@ const LoginForm = () => { style={{ top: '50%', left: '-120px' }} />
- {showEmailLogin || - !hasOAuthLoginOptions - ? renderEmailLoginForm() - : renderOAuthOptions()} + {showSmsLogin && status.sms_login + ? setShowSmsLogin(false)} + logo={logo} + systemName={systemName} + isPopupMode={isPopupMode} + onPopupCallback={handlePopupCallback} + /> + : showEmailLogin || + !hasOAuthLoginOptions + ? renderEmailLoginForm() + : renderOAuthOptions() + } {renderWeChatLoginModal()} {render2FAModal()} diff --git a/web/src/components/auth/SmsLoginForm.jsx b/web/src/components/auth/SmsLoginForm.jsx new file mode 100644 index 000000000000..c007f23922b3 --- /dev/null +++ b/web/src/components/auth/SmsLoginForm.jsx @@ -0,0 +1,324 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; +import { + API, + showError, + showInfo, + showSuccess, + setUserData, + updateAPI, +} from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Checkbox, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { IconLock } from '@douyinfe/semi-icons'; +import PhoneIcon from '../common/logo/PhoneIcon'; +import TwoFAVerification from './TwoFAVerification'; +import { useTranslation } from 'react-i18next'; + +const SmsLoginForm = ({ onBack, logo, systemName, isPopupMode, onPopupCallback }) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [, userDispatch] = useContext(UserContext); + const [statusState] = useContext(StatusContext); + + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [logging, setLogging] = useState(false); + const [showTwoFA, setShowTwoFA] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(''); + + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [hasUserAgreement, setHasUserAgreement] = useState(false); + const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); + + const status = useMemo(() => { + if (statusState?.status) return statusState.status; + const savedStatus = localStorage.getItem('status'); + if (!savedStatus) return {}; + try { + return JSON.parse(savedStatus) || {}; + } catch (err) { + return {}; + } + }, [statusState?.status]); + + useEffect(() => { + setHasUserAgreement(status?.user_agreement_enabled || false); + setHasPrivacyPolicy(status?.privacy_policy_enabled || false); + }, [status]); + + useEffect(() => { + if (countdown <= 0) return; + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + }, [countdown]); + + const handleSendCode = async () => { + if (!phone) { + showInfo(t('请输入手机号')); + return; + } + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + if (status?.turnstile_check && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + + setSending(true); + try { + const res = await API.post( + `/api/sms/send?turnstile=${turnstileToken}`, + { phone }, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('验证码发送成功')); + setCountdown(60); + } else { + showError(message); + } + } catch (error) { + showError(t('验证码发送失败')); + } finally { + setSending(false); + } + }; + + const handleLogin = async () => { + if (!phone || !code) { + showInfo(t('请输入手机号和验证码')); + return; + } + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + if (status?.turnstile_check && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + + setLogging(true); + try { + const res = await API.post( + `/api/user/login/sms?turnstile=${turnstileToken}`, + { phone, code }, + ); + const { success, message, data } = res.data; + if (success) { + if (data && data.require_2fa) { + setShowTwoFA(true); + setLogging(false); + return; + } + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + if (isPopupMode && onPopupCallback) { + onPopupCallback(data); + } else { + showSuccess(t('登录成功!')); + navigate('/console'); + } + } else { + showError(message); + } + } catch (error) { + showError(t('登录失败,请重试')); + } finally { + setLogging(false); + } + }; + + const handle2FASuccess = (data) => { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + if (isPopupMode && onPopupCallback) { + onPopupCallback(data); + } else { + showSuccess(t('登录成功!')); + navigate('/console'); + } + }; + + const handleBackToLogin = () => { + setShowTwoFA(false); + }; + + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ + {t('短信验证码登录')} + +
+
+
+ setPhone(value)} + prefix={} />} + /> + +
+
+ setCode(value)} + prefix={} + /> +
+ +
+ + {(hasUserAgreement || hasPrivacyPolicy) && ( +
+ setAgreedToTerms(e.target.checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + + {t('用户协议')} + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + + {t('隐私政策')} + + )} + + +
+ )} + +
+ + + +
+ + + {status?.turnstile_check && ( +
+ setTurnstileToken(token)} + /> +
+ )} +
+
+
+ + {/* 2FA Modal */} + +
+ + + +
+ {t('两步验证')} +
+ } + visible={showTwoFA} + onCancel={handleBackToLogin} + footer={null} + width={450} + centered + > + + +
+ ); +}; + +export default SmsLoginForm; diff --git a/web/src/components/common/logo/PhoneIcon.jsx b/web/src/components/common/logo/PhoneIcon.jsx new file mode 100644 index 000000000000..7f0843381c88 --- /dev/null +++ b/web/src/components/common/logo/PhoneIcon.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +const PhoneIcon = ({ style = {}, ...props }) => ( + + + +); + +export default PhoneIcon; diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 91ef364509b7..77c98c2d4d88 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -100,6 +100,14 @@ const SystemSetting = () => { LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', ServerAddress: '', + 'sms.enabled': '', + 'sms.provider': '', + 'sms.access_key_id': '', + 'sms.access_key_secret': '', + 'sms.sign_name': '', + 'sms.template_code': '', + 'sms.app_id': '', + 'sms.scheme_code': '', // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, 'fetch_setting.allow_private_ip': '', @@ -586,6 +594,33 @@ const SystemSetting = () => { await updateOptions(options); }; + const submitSmsSettings = async () => { + const options = []; + const smsKeys = [ + 'sms.provider', + 'sms.access_key_id', + 'sms.sign_name', + 'sms.template_code', + 'sms.app_id', + 'sms.scheme_code', + ]; + for (const key of smsKeys) { + if (originInputs[key] !== inputs[key]) { + options.push({ key, value: inputs[key] }); + } + } + // Secret field: only send if non-empty + if (inputs['sms.access_key_secret'] !== '') { + options.push({ + key: 'sms.access_key_secret', + value: inputs['sms.access_key_secret'], + }); + } + if (options.length > 0) { + await updateOptions(options); + } + }; + const submitTurnstile = async () => { const options = []; @@ -1089,6 +1124,15 @@ const SystemSetting = () => { > {t('允许通过 OIDC 进行登录')} + + handleCheckboxChange('sms.enabled', e) + } + > + {t('允许通过短信验证码登录')} + @@ -1597,6 +1641,91 @@ const SystemSetting = () => { + + + {t('用以支持通过手机短信验证码进行登录注册,支持阿里云、腾讯云短信服务')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {t('用以支持用户校验')} diff --git a/web/src/helpers/auth.jsx b/web/src/helpers/auth.jsx index d841afed7842..13cf4e8e51ef 100644 --- a/web/src/helpers/auth.jsx +++ b/web/src/helpers/auth.jsx @@ -34,6 +34,12 @@ export function authHeader() { export const AuthRedirect = ({ children }) => { const user = localStorage.getItem('user'); + const params = new URLSearchParams(window.location.search); + + // In popup mode, don't redirect even if already logged in + if (params.get('mode') === 'popup') { + return children; + } if (user) { return ; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index aade2fd39032..c20929479635 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3376,6 +3376,44 @@ "从剪贴板粘贴配置": "Paste Config", "剪贴板中未检测到连接信息": "No connection info found in clipboard", "连接信息已填入": "Connection info applied", - "无法读取剪贴板": "Cannot read clipboard" + "无法读取剪贴板": "Cannot read clipboard", + "使用手机号登录": "Login with Phone", + "短信验证码登录": "SMS Login", + "手机号": "Phone Number", + "请输入手机号": "Please enter your phone number", + "请输入验证码": "Please enter verification code", + "请输入手机号和验证码": "Please enter phone number and verification code", + "获取验证码": "Get Code", + "秒后重新获取": "s to resend", + "验证码发送成功": "Verification code sent", + "验证码发送失败": "Failed to send verification code", + "两步验证": "Two-Step Verification", + "允许通过短信验证码登录": "Allow login via SMS verification code", + "配置短信登录": "Configure SMS Login", + "用以支持通过手机短信验证码进行登录注册,支持阿里云、腾讯云短信服务": "Enable login/registration via SMS verification code. Supports Alibaba Cloud and Tencent Cloud SMS.", + "短信服务商": "SMS Provider", + "请选择短信服务商": "Select SMS provider", + "阿里云": "Alibaba Cloud", + "腾讯云": "Tencent Cloud", + "短信签名": "SMS Signature", + "短信服务商后台配置的签名": "Signature configured in SMS provider console", + "短信模板 ID": "SMS Template ID", + "短信服务商后台配置的模板 ID": "Template ID configured in SMS provider console", + "App ID(腾讯云)": "App ID (Tencent Cloud)", + "仅腾讯云需要填写": "Only required for Tencent Cloud", + "保存短信登录设置": "Save SMS Login Settings", + "个人开发者推荐使用「阿里云号码认证(PNVS)」,无需企业资质、签名和模板审核,开通后即可使用系统赠送的签名和模板": "Individual developers: use Alibaba Cloud PNVS — no enterprise qualification needed, system-provided signatures and templates available immediately", + "阿里云号码认证 PNVS(个人推荐)": "Alibaba Cloud PNVS (Recommended for individuals)", + "阿里云短信服务(需企业资质)": "Alibaba Cloud SMS (Enterprise only)", + "PNVS 用户使用控制台赠送的签名": "Use system-provided signature from PNVS console", + "PNVS 用户使用控制台赠送的模板": "Use system-provided template from PNVS console", + "方案 Code(PNVS 可选)": "Scheme Code (PNVS, optional)", + "号码认证方案Code,可不填": "PNVS scheme code, can be left empty", + "设置用户名": "Set Username", + "欢迎注册!请设置您的用户名(也可以稍后在个人设置中修改)": "Welcome! Please set your username (you can also change it later in personal settings)", + "请输入用户名(最多20个字符)": "Enter username (max 20 characters)", + "用户名长度不能超过20": "Username cannot exceed 20 characters", + "用户名设置成功!": "Username set successfully!", + "跳过": "Skip" } } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 99a721ab81c9..881e9e93b3e4 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2980,6 +2980,44 @@ "从剪贴板粘贴配置": "从剪贴板粘贴配置", "剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息", "连接信息已填入": "连接信息已填入", - "无法读取剪贴板": "无法读取剪贴板" + "无法读取剪贴板": "无法读取剪贴板", + "使用手机号登录": "使用手机号登录", + "短信验证码登录": "短信验证码登录", + "手机号": "手机号", + "请输入手机号": "请输入手机号", + "请输入验证码": "请输入验证码", + "请输入手机号和验证码": "请输入手机号和验证码", + "获取验证码": "获取验证码", + "秒后重新获取": "秒后重新获取", + "验证码发送成功": "验证码发送成功", + "验证码发送失败": "验证码发送失败", + "两步验证": "两步验证", + "允许通过短信验证码登录": "允许通过短信验证码登录", + "配置短信登录": "配置短信登录", + "用以支持通过手机短信验证码进行登录注册,支持阿里云、腾讯云短信服务": "用以支持通过手机短信验证码进行登录注册,支持阿里云、腾讯云短信服务", + "短信服务商": "短信服务商", + "请选择短信服务商": "请选择短信服务商", + "阿里云": "阿里云", + "腾讯云": "腾讯云", + "短信签名": "短信签名", + "短信服务商后台配置的签名": "短信服务商后台配置的签名", + "短信模板 ID": "短信模板 ID", + "短信服务商后台配置的模板 ID": "短信服务商后台配置的模板 ID", + "App ID(腾讯云)": "App ID(腾讯云)", + "仅腾讯云需要填写": "仅腾讯云需要填写", + "保存短信登录设置": "保存短信登录设置", + "个人开发者推荐使用「阿里云号码认证(PNVS)」,无需企业资质、签名和模板审核,开通后即可使用系统赠送的签名和模板": "个人开发者推荐使用「阿里云号码认证(PNVS)」,无需企业资质、签名和模板审核,开通后即可使用系统赠送的签名和模板", + "阿里云号码认证 PNVS(个人推荐)": "阿里云号码认证 PNVS(个人推荐)", + "阿里云短信服务(需企业资质)": "阿里云短信服务(需企业资质)", + "PNVS 用户使用控制台赠送的签名": "PNVS 用户使用控制台赠送的签名", + "PNVS 用户使用控制台赠送的模板": "PNVS 用户使用控制台赠送的模板", + "方案 Code(PNVS 可选)": "方案 Code(PNVS 可选)", + "号码认证方案Code,可不填": "号码认证方案Code,可不填", + "设置用户名": "设置用户名", + "欢迎注册!请设置您的用户名(也可以稍后在个人设置中修改)": "欢迎注册!请设置您的用户名(也可以稍后在个人设置中修改)", + "请输入用户名(最多20个字符)": "请输入用户名(最多20个字符)", + "用户名长度不能超过20": "用户名长度不能超过20", + "用户名设置成功!": "用户名设置成功!", + "跳过": "跳过" } } From f91f262de94e30d956c0ec30d1498d1d593b3d5a Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 7 Apr 2026 21:05:16 +0800 Subject: [PATCH 3/6] feat(client): add MyClaw client API for desktop integration - Add /api/client/login_sms: SMS code login with auto-register and per-user reusable API token (returns full key, no cookie session) - Add /api/client/self: API key authenticated user info endpoint - Add /api/client/subscription/plans: public subscription plans list - Add /api/client/subscription/epay/pay: subscription order via API key Currency defaults switched from USD to CNY: - SubscriptionPlan.Currency default - subscription_plans table DDL default - AdminCreate/UpdateSubscriptionPlan force CNY - General quota_display_type default --- controller/client_auth.go | 247 +++++++++++++++++++ controller/subscription.go | 8 +- model/main.go | 4 +- model/option.go | 2 +- model/subscription.go | 2 +- router/api-router.go | 28 +++ setting/operation_setting/general_setting.go | 2 +- 7 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 controller/client_auth.go diff --git a/controller/client_auth.go b/controller/client_auth.go new file mode 100644 index 000000000000..eefdf53566c5 --- /dev/null +++ b/controller/client_auth.go @@ -0,0 +1,247 @@ +package controller + +import ( + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +// ClientLoginRequest 客户端 SMS 登录请求 +// 安全考虑: +// - 通过 CriticalRateLimit + SmsRateLimit 限速,防止暴力破解和短信轰炸 +// - 验证码一次性使用,校验后立即删除 +// - 自动注册需要全局开关 RegisterEnabled 启用 +// - Token 名称固定,同一用户多次登录复用同一个 Token,不重复生成 +// - 完整 Key 仅在登录响应中返回一次,落盘时由客户端自行加密 +// - 不返回 session_token / cookie,客户端只持有 API Key +type ClientLoginRequest struct { + Phone string `json:"phone"` + Code string `json:"code"` + ClientId string `json:"client_id,omitempty"` +} + +// ClientLoginResponseData 登录响应数据 +type ClientLoginResponseData struct { + User ClientUserInfo `json:"user"` + ApiKey string `json:"api_key"` +} + +// ClientUserInfo 返回给客户端的用户信息(精简版,不含敏感字段) +type ClientUserInfo struct { + Id int `json:"id"` + Phone string `json:"phone"` + Quota int `json:"quota"` + UsedQuota int `json:"used_quota"` + RequestCount int `json:"request_count"` +} + +// 客户端 Token 的固定名称,每个用户唯一 +const clientTokenName = "MyClaw Client" + +// ClientSmsLogin 客户端手机验证码登录(自动注册 + 自动创建/复用 Token) +// +// POST /api/client/login_sms +// 请求体: { phone, code, client_id? } +// 响应: { success, data: { user, api_key } } +// +// 与 SmsLogin 的区别: +// - 不创建 cookie session(客户端不需要) +// - 自动确保用户拥有一个名为 "MyClaw Client" 的 Token,并返回完整 Key +// - 不支持 2FA 流程(客户端登录场景简化) +func ClientSmsLogin(c *gin.Context) { + // 1. 检查 SMS 功能是否启用 + smsSettings := system_setting.GetSmsSettings() + if !smsSettings.Enabled { + common.ApiErrorI18n(c, i18n.MsgSmsLoginDisabled) + return + } + + // 2. 解析请求 + var req ClientLoginRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + + if req.Phone == "" { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneRequired) + return + } + if req.Code == "" { + common.ApiErrorI18n(c, i18n.MsgSmsCodeRequired) + return + } + if !phoneRegex.MatchString(req.Phone) { + common.ApiErrorI18n(c, i18n.MsgSmsPhoneInvalid) + return + } + + // 3. 验证短信验证码(验证后立即删除,防重放) + if !common.VerifyCodeWithKey(req.Phone, req.Code, common.SmsVerificationPurpose) { + common.ApiErrorI18n(c, i18n.MsgSmsVerificationCodeErr) + return + } + common.DeleteKey(req.Phone, common.SmsVerificationPurpose) + + // 4. 查找用户,若不存在则自动注册 + user := model.User{Phone: req.Phone} + err := user.FillUserByPhone() + if err != nil || user.Id == 0 { + // 必须开启注册功能,防止恶意注册 + if !common.RegisterEnabled { + common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled) + return + } + + nextId := model.GetMaxUserId() + 1 + username := fmt.Sprintf("sms_%d", nextId) + displayName := maskPhone(req.Phone) + + randPassword, perr := common.GenerateKey() + if perr != nil { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + + newUser := model.User{ + Username: username, + Password: randPassword, + DisplayName: displayName, + Phone: req.Phone, + Role: common.RoleCommonUser, + } + + if perr := newUser.Insert(0); perr != nil { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + + // 重新加载用户以拿到 Id + user = model.User{Phone: req.Phone} + if err := user.FillUserByPhone(); err != nil || user.Id == 0 { + common.ApiErrorI18n(c, i18n.MsgUserRegisterFailed) + return + } + } + + // 5. 状态校验 + if user.Status != common.UserStatusEnabled { + common.ApiErrorI18n(c, i18n.MsgUserDisabled) + return + } + + // 6. 查找或创建客户端专用 Token + apiKey, err := ensureClientToken(user.Id, user.Username) + if err != nil { + common.SysLog("ClientSmsLogin: failed to ensure client token: " + err.Error()) + common.ApiErrorMsg(c, "创建客户端令牌失败") + return + } + + // 7. 记录登录日志(便于审计异常登录) + model.RecordLog(user.Id, model.LogTypeManage, + fmt.Sprintf("客户端登录: phone=%s client_id=%s ip=%s", + maskPhone(req.Phone), req.ClientId, c.ClientIP())) + + // 8. 返回结果(一次性返回 Key,客户端必须妥善保存) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ClientLoginResponseData{ + User: ClientUserInfo{ + Id: user.Id, + Phone: user.Phone, + Quota: user.Quota, + UsedQuota: user.UsedQuota, + RequestCount: user.RequestCount, + }, + ApiKey: "sk-" + apiKey, + }, + }) +} + +// ensureClientToken 查找或创建用户的客户端专用 Token,返回完整 Key(不带 sk- 前缀) +// +// 安全考虑: +// - 同一用户只创建一个名为 clientTokenName 的 Token +// - Token 不限额度(依赖用户级别配额控制) +// - Token Key 仅在创建时返回;后续登录从数据库读取(数据库存的是明文,与现有逻辑一致) +func ensureClientToken(userId int, username string) (string, error) { + // 先尝试查找已有的客户端 Token + var existing model.Token + err := model.DB.Where("user_id = ? AND name = ?", userId, clientTokenName).First(&existing).Error + if err == nil { + // 已存在,直接返回 Key + return existing.Key, nil + } + + // 不存在则创建 + key, err := common.GenerateKey() + if err != nil { + return "", err + } + + token := model.Token{ + UserId: userId, + Name: clientTokenName, + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, + RemainQuota: 0, + UnlimitedQuota: true, // 依赖用户级别 quota 控制,避免单独维护 token 额度 + ModelLimitsEnabled: false, + } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } + + if err := token.Insert(); err != nil { + return "", err + } + + // 保留 constant 引用避免 import 被优化掉(如未来需要根据 GenerateDefaultToken 开关控制) + _ = constant.GenerateDefaultToken + + return token.Key, nil +} + +// ClientGetSelf 客户端获取自己的用户信息(基于 API Key 鉴权) +// +// GET /api/client/self +// Header: Authorization: Bearer sk-xxx +// +// 通过 TokenAuth 中间件验证 API Key,从 context 取出 user_id 加载完整信息 +func ClientGetSelf(c *gin.Context) { + userId := c.GetInt("id") + if userId <= 0 { + common.ApiErrorMsg(c, "未授权") + return + } + + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ClientUserInfo{ + Id: user.Id, + Phone: user.Phone, + Quota: user.Quota, + UsedQuota: user.UsedQuota, + RequestCount: user.RequestCount, + }, + }) +} diff --git a/controller/subscription.go b/controller/subscription.go index c6095312b776..001f869f88ab 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -127,9 +127,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { return } if req.Plan.Currency == "" { - req.Plan.Currency = "USD" + req.Plan.Currency = "CNY" } - req.Plan.Currency = "USD" + req.Plan.Currency = "CNY" if req.Plan.DurationUnit == "" { req.Plan.DurationUnit = model.SubscriptionDurationMonth } @@ -190,9 +190,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { } req.Plan.Id = id if req.Plan.Currency == "" { - req.Plan.Currency = "USD" + req.Plan.Currency = "CNY" } - req.Plan.Currency = "USD" + req.Plan.Currency = "CNY" if req.Plan.DurationUnit == "" { req.Plan.DurationUnit = model.SubscriptionDurationMonth } diff --git a/model/main.go b/model/main.go index f37cb667cd43..37bced5f9338 100644 --- a/model/main.go +++ b/model/main.go @@ -389,7 +389,7 @@ func ensureSubscriptionPlanTableSQLite() error { ` + "`title`" + ` varchar(128) NOT NULL, ` + "`subtitle`" + ` varchar(255) DEFAULT '', ` + "`price_amount`" + ` decimal(10,6) NOT NULL, -` + "`currency`" + ` varchar(8) NOT NULL DEFAULT 'USD', +` + "`currency`" + ` varchar(8) NOT NULL DEFAULT 'CNY', ` + "`duration_unit`" + ` varchar(16) NOT NULL DEFAULT 'month', ` + "`duration_value`" + ` integer NOT NULL DEFAULT 1, ` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0, @@ -422,7 +422,7 @@ PRIMARY KEY (` + "`id`" + `) {Name: "title", DDL: "`title` varchar(128) NOT NULL"}, {Name: "subtitle", DDL: "`subtitle` varchar(255) DEFAULT ''"}, {Name: "price_amount", DDL: "`price_amount` decimal(10,6) NOT NULL"}, - {Name: "currency", DDL: "`currency` varchar(8) NOT NULL DEFAULT 'USD'"}, + {Name: "currency", DDL: "`currency` varchar(8) NOT NULL DEFAULT 'CNY'"}, {Name: "duration_unit", DDL: "`duration_unit` varchar(16) NOT NULL DEFAULT 'month'"}, {Name: "duration_value", DDL: "`duration_value` integer NOT NULL DEFAULT 1"}, {Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"}, diff --git a/model/option.go b/model/option.go index 967fa0aa6708..59cfa79c5770 100644 --- a/model/option.go +++ b/model/option.go @@ -267,7 +267,7 @@ func updateOptionMap(key string, value string) (err error) { case "DisplayInCurrencyEnabled": // 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效) // true -> USD, false -> TOKENS - newVal := "USD" + newVal := "CNY" if !boolValue { newVal = "TOKENS" } diff --git a/model/subscription.go b/model/subscription.go index 2d23a8b5bf2c..171c3a128e5d 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -150,7 +150,7 @@ type SubscriptionPlan struct { // Display money amount (follow existing code style: float64 for money) PriceAmount float64 `json:"price_amount" gorm:"type:decimal(10,6);not null;default:0"` - Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'USD'"` + Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'CNY'"` DurationUnit string `json:"duration_unit" gorm:"type:varchar(16);not null;default:'month'"` DurationValue int `json:"duration_value" gorm:"type:int;not null;default:1"` diff --git a/router/api-router.go b/router/api-router.go index 2b3037289f76..d08a5877993b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -135,6 +135,34 @@ func SetApiRouter(router *gin.Engine) { } } + // ==================== MyClaw Client API ==================== + // 客户端专用接口(手机验证码登录 + 自动 Token 管理),独立于网页登录流程 + // 安全:CriticalRateLimit + SmsRateLimit 限流;登录响应不含 cookie,仅返回 API Key + clientRoute := apiRouter.Group("/client") + { + clientRoute.POST("/login_sms", + middleware.CriticalRateLimit(), + middleware.SmsRateLimit(), + middleware.TurnstileCheck(), + controller.ClientSmsLogin) + + // 基于 API Key 鉴权的客户端自查接口 + clientRoute.GET("/self", + middleware.TokenAuth(), + controller.ClientGetSelf) + + // 订阅套餐列表(公开,无需登录) + // Subscription plans list (public) + clientRoute.GET("/subscription/plans", controller.GetSubscriptionPlans) + + // 客户端发起订阅支付(API Key 鉴权 + 限流) + // Client subscription pay (API Key auth + rate limited) + clientRoute.POST("/subscription/epay/pay", + middleware.TokenAuth(), + middleware.CriticalRateLimit(), + controller.SubscriptionRequestEpay) + } + // Subscription billing (plans, purchase, admin management) subscriptionRoute := apiRouter.Group("/subscription") subscriptionRoute.Use(middleware.UserAuth()) diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go index b4a3ccccdaf3..2c09b3972a1c 100644 --- a/setting/operation_setting/general_setting.go +++ b/setting/operation_setting/general_setting.go @@ -27,7 +27,7 @@ var generalSetting = GeneralSetting{ DocsLink: "https://docs.newapi.pro", PingIntervalEnabled: false, PingIntervalSeconds: 60, - QuotaDisplayType: QuotaDisplayTypeUSD, + QuotaDisplayType: QuotaDisplayTypeCNY, CustomCurrencySymbol: "¤", CustomCurrencyExchangeRate: 1.0, } From 04d696f19cd26e740a332cda94c8211cfa908fc5 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 9 Apr 2026 18:00:25 +0800 Subject: [PATCH 4/6] feat(scripts): add db sync pull/push scripts for team dev workflow - sync-db-pull.sh: download server one-api.db via SSH, auto-backup local - sync-db-push.sh: upload local db to server with confirmation + rollback --- scripts/sync-db-pull.sh | 60 ++++++++++++++++++++++++++++++++ scripts/sync-db-push.sh | 77 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100755 scripts/sync-db-pull.sh create mode 100755 scripts/sync-db-push.sh diff --git a/scripts/sync-db-pull.sh b/scripts/sync-db-pull.sh new file mode 100755 index 000000000000..0730565b9a77 --- /dev/null +++ b/scripts/sync-db-pull.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 从服务器拉取 one-api.db 到本地 +# Pull one-api.db from production server to local workspace +set -euo pipefail + +REMOTE_HOST="${NEWAPI_REMOTE_HOST:-001}" +REMOTE_PATH="${NEWAPI_REMOTE_PATH:-/opt/newapi/data/one-api.db}" +LOCAL_PATH="$(cd "$(dirname "$0")/.." && pwd)/one-api.db" +BACKUP_DIR="$(cd "$(dirname "$0")/.." && pwd)/.db-backups" + +echo "==> 同步数据库" +echo " 源: ${REMOTE_HOST}:${REMOTE_PATH}" +echo " 目标: ${LOCAL_PATH}" +echo + +# 1. 备份本地已有 db +if [[ -f "$LOCAL_PATH" ]]; then + mkdir -p "$BACKUP_DIR" + BACKUP_FILE="${BACKUP_DIR}/one-api.db.$(date +%Y%m%d-%H%M%S)" + cp "$LOCAL_PATH" "$BACKUP_FILE" + echo "✓ 本地 db 已备份到: ${BACKUP_FILE}" +fi + +# 2. 检查服务器文件存在 +ssh "$REMOTE_HOST" "[ -f ${REMOTE_PATH} ]" || { + echo "✗ 服务器上找不到 ${REMOTE_PATH}" + exit 1 +} + +# 3. 获取文件大小并拉取 +REMOTE_SIZE=$(ssh "$REMOTE_HOST" "stat -c%s ${REMOTE_PATH}") +echo "✓ 服务器文件大小: $(numfmt --to=iec ${REMOTE_SIZE} 2>/dev/null || echo ${REMOTE_SIZE} bytes)" + +scp -q "${REMOTE_HOST}:${REMOTE_PATH}" "$LOCAL_PATH" + +echo +echo "✓ 同步完成!" +echo " 运行 'go run main.go' 启动本地开发服务器" +echo " 所有账号、套餐、API Key、系统设置已同步" + +# 4. 显示同步过来的数据概览 +if command -v sqlite3 &>/dev/null; then + echo + echo "==> 数据概览" + sqlite3 "$LOCAL_PATH" < ⚠️ 危险操作:即将覆盖服务器数据库" +echo " 源(本地): ${LOCAL_PATH}" +echo " 目标: ${REMOTE_HOST}:${REMOTE_PATH}" +echo " 将重启服务: ${REMOTE_SERVICE}" +echo + +# 显示本地数据概览 +if command -v sqlite3 &>/dev/null; then + echo "==> 本地数据概览" + sqlite3 "$LOCAL_PATH" < 服务器当前数据概览" +ssh "$REMOTE_HOST" "command -v sqlite3 >/dev/null && sqlite3 ${REMOTE_PATH} \" +SELECT 'users' AS t, count(*) AS c FROM users +UNION ALL SELECT 'tokens', count(*) FROM tokens +UNION ALL SELECT 'channels', count(*) FROM channels +UNION ALL SELECT 'subscription_plans', count(*) FROM subscription_plans;\" 2>/dev/null || echo '(sqlite3 not available on server)'" +echo + +# 二次确认 +read -p "确认上传并重启服务?(输入 yes 继续): " answer +if [[ "$answer" != "yes" ]]; then + echo "已取消" + exit 0 +fi + +# 1. 在服务器上备份 +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +echo "==> 服务器端备份现有 db..." +ssh "$REMOTE_HOST" "cp ${REMOTE_PATH} ${REMOTE_PATH}.bak.${TIMESTAMP}" +echo "✓ 备份完成: ${REMOTE_PATH}.bak.${TIMESTAMP}" + +# 2. 上传 +echo "==> 上传中..." +scp -q "$LOCAL_PATH" "${REMOTE_HOST}:${REMOTE_PATH}" +echo "✓ 上传完成" + +# 3. 重启服务 +echo "==> 重启服务 ${REMOTE_SERVICE}..." +ssh "$REMOTE_HOST" "systemctl restart ${REMOTE_SERVICE} && sleep 2 && systemctl is-active ${REMOTE_SERVICE}" || { + echo "✗ 服务重启失败,尝试回滚..." + ssh "$REMOTE_HOST" "cp ${REMOTE_PATH}.bak.${TIMESTAMP} ${REMOTE_PATH} && systemctl restart ${REMOTE_SERVICE}" + exit 1 +} + +echo +echo "✅ 推送成功,服务已重启" +echo " 如需回滚: ssh ${REMOTE_HOST} 'cp ${REMOTE_PATH}.bak.${TIMESTAMP} ${REMOTE_PATH} && systemctl restart ${REMOTE_SERVICE}'" + +# 4. 清理服务器老备份(保留最近 5 个) +ssh "$REMOTE_HOST" "ls -t ${REMOTE_PATH}.bak.* 2>/dev/null | tail -n +6 | xargs -r rm -f" From 17ea5e31bd94ab311a550dce6ad447dd2e311662 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 14 Apr 2026 21:50:53 +0800 Subject: [PATCH 5/6] docs: update fork changelog --- CHANGELOG_FORK.md | 116 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/CHANGELOG_FORK.md b/CHANGELOG_FORK.md index 8dcd798b6112..70d8770b5637 100644 --- a/CHANGELOG_FORK.md +++ b/CHANGELOG_FORK.md @@ -6,7 +6,17 @@ --- -## 新增功能:手机验证码登录 +## 改动一览 + +| # | 功能 | 添加日期 | 状态 | +|---|------|----------|------| +| 1 | 手机验证码登录 | 2026-03-31 | 已完成 | +| 2 | 跨域 CORS 修复 | 2026-04-01 | 已完成 | +| 3 | 弹窗登录模式(OAuth-style)| 2026-04-01 | 已完成 | + +--- + +## 1. 新增功能:手机验证码登录 ### 功能概述 @@ -177,14 +187,112 @@ Content-Type: application/json 1. **`model/user.go`** - 用户模型新增 `Phone` 字段,合并时保留 2. **`router/api-router.go`** - 新增短信登录路由,合并时保留 3. **数据库迁移** - 确保上游迁移不会删除 `phone` 字段 +4. **`middleware/cors.go`** - CORS 中间件已重写,不再使用 `AllowAllOrigins` +5. **`web/src/components/auth/LoginForm.jsx`** - 含弹窗登录逻辑,合并时仔细 review +6. **`web/src/helpers/auth.jsx`** - `AuthRedirect` 已支持 popup 模式 + +--- + +## 2. CORS 跨域修复 + +### 背景 + +为支持 AionUi 商业化集成(独立域名跨域调用),原有 CORS 配置 `AllowAllOrigins=true + AllowCredentials=true` 不符合 CORS 规范,浏览器会拒绝。 + +### 改动 + +**文件**: `middleware/cors.go` + +- 移除 `AllowAllOrigins: true` +- 改用 `AllowOriginFunc` 函数动态判断: + - 允许 `localhost` 和 `127.0.0.1`(开发环境) + - 允许所有 `https://` 来源(生产环境) +- 保持 `AllowCredentials: true` 以支持 cookie 跨域 + +### 与上游合并注意事项 + +如果上游修改了 CORS 配置,需手动 merge 我们的 `AllowOriginFunc` 实现。 + +--- + +## 3. 弹窗登录模式(Popup OAuth-style) + +### 背景 + +为支持 AionUi 等第三方应用通过弹窗方式集成 new-api 登录,新增 popup 模式:第三方应用打开 new-api 登录页弹窗 → 用户登录 → new-api 通过 `postMessage` 回传 token → 关闭弹窗。 + +### 流程 + +``` +1. AionUi 打开弹窗: + https://new-api.example.com/login?mode=popup&callback_origin=https://aionui.example.com + +2. 用户在弹窗中登录(支持密码/SMS/OAuth/2FA/Passkey/微信/Telegram 全部方式) + +3. 登录成功后,前端: + a. 调用 GET /api/user/token 获取 Access Token + b. 调用 GET /api/token/ 获取 API Token 列表 + - 若无 Token,自动调用 POST /api/token/ 创建一个 + c. 调用 POST /api/token/{id}/key 获取 API Token key + d. 通过 window.opener.postMessage 回传: + { type: 'aionui-auth', accessToken, apiToken: 'sk-xxx', user: {...} } + e. window.close() 关闭弹窗 +``` + +### 修改的文件 + +| 文件 | 改动说明 | +|------|----------| +| `web/src/components/auth/LoginForm.jsx` | 检测 `mode=popup` 参数,所有登录方式(密码/2FA/Passkey/WeChat/Telegram)成功后调用 `handlePopupCallback` | +| `web/src/components/auth/SmsLoginForm.jsx` | 接收 `isPopupMode` 和 `onPopupCallback` props,SMS 登录成功后回调 | +| `web/src/helpers/auth.jsx` | `AuthRedirect` 在 popup 模式下不重定向到 /console | + +### URL 参数 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `mode` | 设为 `popup` 启用弹窗模式 | `popup` | +| `callback_origin` | 弹窗 postMessage 的目标 origin | `https://aionui.example.com` | + +### postMessage 消息格式 + +```javascript +{ + type: 'aionui-auth', + accessToken: '', // 用于调用 new-api 管理 API + apiToken: 'sk-xxxxxxxxxxxx', // 用于走 /v1/ 代理的 Bearer Token + user: { + id: 1, + username: 'sms_1', + display_name: '138****8000', + role: 1, + status: 1, + group: 'default' + } +} +``` + +### 安全说明 + +- 第三方应用接收 message 时**必须**校验 `event.origin` 等于 new-api 的 origin +- new-api 的 `callback_origin` 参数应限制白名单(当前未限制,建议生产环境加强) +- 推荐启用 `GENERATE_DEFAULT_TOKEN=true` 环境变量,确保新用户注册后自动有 API Token + +### 与上游合并注意事项 + +`LoginForm.jsx` 改动较多,如果上游修改了登录流程,需仔细 merge: +- 保留 `isPopupMode` / `callbackOrigin` 状态变量 +- 保留 `handlePopupCallback` 函数 +- 保留所有登录成功路径中的 `if (isPopupMode)` 分支 --- ## 更新记录 -| 日期 | 上游 Commit | 同步状态 | -|------|-------------|----------| -| 2026-04-02 | d22f889e | 已同步 | +| 日期 | 改动 | 上游同步状态 | +|------|------|--------------| +| 2026-03-31 | 新增手机验证码登录 | 基于 d22f889e | +| 2026-04-01 | CORS 修复 + 弹窗登录模式 | 待同步 | --- From 7f4e07e37dfc990562d85705c7fe95d6da322c67 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 20 Apr 2026 14:59:04 +0800 Subject: [PATCH 6/6] feat: add client skill market admin management --- controller/client_skillhub.go | 90 +++ controller/client_skills.go | 379 +++++++++++ middleware/auth.go | 57 +- middleware/cache.go | 16 +- middleware/rate-limit.go | 11 +- model/client_skill_market.go | 119 ++++ model/main.go | 2 + router/api-router.go | 19 + web/src/App.jsx | 18 + web/src/components/layout/SiderBar.jsx | 13 +- web/src/helpers/render.jsx | 7 +- web/src/hooks/common/useSidebar.js | 1 + web/src/pages/SkillMarket/EditSkillModal.jsx | 292 +++++++++ web/src/pages/SkillMarket/Editor.jsx | 493 +++++++++++++++ web/src/pages/SkillMarket/index.jsx | 629 +++++++++++++++++++ 15 files changed, 2118 insertions(+), 28 deletions(-) create mode 100644 controller/client_skillhub.go create mode 100644 controller/client_skills.go create mode 100644 model/client_skill_market.go create mode 100644 web/src/pages/SkillMarket/EditSkillModal.jsx create mode 100644 web/src/pages/SkillMarket/Editor.jsx create mode 100644 web/src/pages/SkillMarket/index.jsx diff --git a/controller/client_skillhub.go b/controller/client_skillhub.go new file mode 100644 index 000000000000..f9ecd201ffc9 --- /dev/null +++ b/controller/client_skillhub.go @@ -0,0 +1,90 @@ +package controller + +import ( + "fmt" + "mime" + "net/http" + "path" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +const skillHubDownloadBaseURL = "https://api.skillhub.cn/api/v1/download?slug=" + +// ClientProxySkillHubDownload 代理下载 SkillHub zip 包。 +// +// 当前版本先做最小可用链路: +// 1. myclaw 只请求 NewAPI +// 2. NewAPI 代为访问 SkillHub 下载入口 +// 3. 把最终 zip 流直接回传给客户端 +// +// 后续如需“预缓存/镜像 zip”,可以在这里落盘缓存并优先命中本地文件。 +func ClientProxySkillHubDownload(c *gin.Context) { + slug := strings.TrimSpace(c.Query("slug")) + if slug == "" { + common.ApiErrorMsg(c, "缺少 slug 参数") + return + } + + upstreamURL := skillHubDownloadBaseURL + slug + resp, err := service.DoDownloadRequest(upstreamURL, "skillhub skill download proxy", slug) + if err != nil { + common.SysError(fmt.Sprintf("skillhub download proxy failed, slug=%s, err=%v", slug, err)) + common.ApiErrorMsg(c, "SkillHub 下载失败") + return + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + common.SysError(fmt.Sprintf("skillhub download proxy bad status, slug=%s, status=%d", slug, resp.StatusCode)) + common.ApiErrorMsg(c, "SkillHub 下载失败") + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/zip" + } + + fileName := extractDownloadFilename(resp.Header.Get("Content-Disposition")) + if fileName == "" { + if resp.Request != nil && resp.Request.URL != nil { + base := path.Base(resp.Request.URL.Path) + if base != "." && base != "/" && base != "" { + fileName = base + } + } + } + if fileName == "" { + fileName = slug + ".zip" + } + if !strings.HasSuffix(strings.ToLower(fileName), ".zip") { + fileName += ".zip" + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fileName)) + c.Header("Cache-Control", "private, no-store") + c.DataFromReader(http.StatusOK, resp.ContentLength, contentType, resp.Body, nil) +} + +func extractDownloadFilename(contentDisposition string) string { + if contentDisposition == "" { + return "" + } + + _, params, err := mime.ParseMediaType(contentDisposition) + if err != nil { + return "" + } + + if fileName := strings.TrimSpace(params["filename*"]); fileName != "" { + return fileName + } + if fileName := strings.TrimSpace(params["filename"]); fileName != "" { + return fileName + } + return "" +} diff --git a/controller/client_skills.go b/controller/client_skills.go new file mode 100644 index 000000000000..33b22ddf19af --- /dev/null +++ b/controller/client_skills.go @@ -0,0 +1,379 @@ +package controller + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ClientSkill struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + DisplayNameZh string `json:"display_name_zh,omitempty"` + Description string `json:"description"` + DescriptionZh string `json:"description_zh,omitempty"` + Category string `json:"category"` + Tags []string `json:"tags"` + Source string `json:"source"` + SourcePlatform string `json:"source_platform,omitempty"` + SourceSkillID string `json:"source_skill_id,omitempty"` + SourceSlug string `json:"source_slug,omitempty"` + SourceUpdatedAt int64 `json:"source_updated_at,omitempty"` + URL string `json:"url"` + DownloadURL string `json:"download_url,omitempty"` + Author string `json:"author,omitempty"` + Version string `json:"version,omitempty"` + Downloads int `json:"downloads,omitempty"` + Enabled bool `json:"enabled"` + IsPublic bool `json:"is_public"` + SortOrder int `json:"sort_order"` +} + +var defaultClientPublicSkills = []ClientSkill{ + { + ID: 1, + Name: "self-improving-agent", + Description: "A self-improving coding and workflow agent from SkillHub, proxied through New API for myclaw.", + Category: "开发技术", + Tags: []string{"agent", "coding", "automation", "skillhub"}, + Source: "community", + URL: "https://skillhub.cn/skills/self-improving-agent", + DownloadURL: "https://api.skillhub.cn/api/v1/download?slug=self-improving-agent", + Author: "SkillHub", + Version: "3.0.6", + Downloads: 0, + Enabled: true, + IsPublic: true, + }, +} + +func toClientSkill(item *model.ClientSkillMarketItem) ClientSkill { + return ClientSkill{ + ID: item.Id, + Name: item.Name, + DisplayName: item.DisplayName, + DisplayNameZh: item.DisplayNameZh, + Description: item.Description, + DescriptionZh: item.DescriptionZh, + Category: item.Category, + Tags: item.TagList(), + Source: item.Source, + SourcePlatform: item.SourcePlatform, + SourceSkillID: item.SourceSkillID, + SourceSlug: item.SourceSlug, + SourceUpdatedAt: item.SourceUpdatedAt, + URL: item.URL, + DownloadURL: item.DownloadURL, + Author: item.Author, + Version: item.Version, + Downloads: item.Downloads, + Enabled: item.Enabled, + IsPublic: item.IsPublic, + SortOrder: item.SortOrder, + } +} + +type AdminUpsertClientSkillRequest struct { + Skill ClientSkill `json:"skill"` +} + +type AdminUpdateClientSkillStatusRequest struct { + Enabled *bool `json:"enabled"` + IsPublic *bool `json:"is_public"` +} + +func tagsToJSON(tags []string) (model.JSONValue, error) { + if tags == nil { + tags = []string{} + } + tagBytes, err := json.Marshal(tags) + if err != nil { + return nil, err + } + return model.JSONValue(tagBytes), nil +} + +func normalizeClientSkillInput(skill ClientSkill) (*model.ClientSkillMarketItem, error) { + name := strings.TrimSpace(skill.Name) + if name == "" { + return nil, errors.New("技能原始名称不能为空") + } + + tags, err := tagsToJSON(skill.Tags) + if err != nil { + return nil, err + } + + source := strings.TrimSpace(skill.Source) + if source == "" { + source = "community" + } + + sourcePlatform := strings.TrimSpace(skill.SourcePlatform) + if sourcePlatform == "" { + sourcePlatform = "manual" + } + + return &model.ClientSkillMarketItem{ + Name: name, + DisplayName: strings.TrimSpace(skill.DisplayName), + DisplayNameZh: strings.TrimSpace(skill.DisplayNameZh), + Description: strings.TrimSpace(skill.Description), + DescriptionZh: strings.TrimSpace(skill.DescriptionZh), + Category: strings.TrimSpace(skill.Category), + Tags: tags, + Source: source, + SourcePlatform: sourcePlatform, + SourceSkillID: strings.TrimSpace(skill.SourceSkillID), + SourceSlug: strings.TrimSpace(skill.SourceSlug), + SourceUpdatedAt: skill.SourceUpdatedAt, + URL: strings.TrimSpace(skill.URL), + DownloadURL: strings.TrimSpace(skill.DownloadURL), + Author: strings.TrimSpace(skill.Author), + Version: strings.TrimSpace(skill.Version), + Downloads: skill.Downloads, + Enabled: skill.Enabled, + IsPublic: skill.IsPublic, + SortOrder: skill.SortOrder, + }, nil +} + +func loadClientPublicSkills() []ClientSkill { + items, err := model.ListPublicClientSkillMarketItems() + if err != nil { + common.SysError("load client public skills from db failed: " + err.Error()) + return defaultClientPublicSkills + } + if len(items) == 0 { + return defaultClientPublicSkills + } + + skills := make([]ClientSkill, 0, len(items)) + for _, item := range items { + skills = append(skills, toClientSkill(item)) + } + return skills +} + +func ClientListPublicSkills(c *gin.Context) { + common.ApiSuccess(c, loadClientPublicSkills()) +} + +func ClientGetPublicSkill(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil { + common.ApiErrorMsg(c, "无效的技能 ID") + return + } + + item, err := model.GetPublicClientSkillMarketItemByID(id) + if err == nil { + common.ApiSuccess(c, toClientSkill(item)) + return + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + common.SysError("get client public skill from db failed: " + err.Error()) + } + + for _, skill := range defaultClientPublicSkills { + if skill.ID == id { + common.ApiSuccess(c, skill) + return + } + } + + common.ApiErrorMsg(c, "技能不存在") +} + +func ClientRecordSkillDownload(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil { + common.ApiErrorMsg(c, "无效的技能 ID") + return + } + + if err = model.IncrementClientSkillMarketDownload(id); err == nil { + common.ApiSuccess(c, gin.H{"recorded": true}) + return + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + common.SysError("increment client public skill download failed: " + err.Error()) + } + + for _, skill := range defaultClientPublicSkills { + if skill.ID == id { + common.ApiSuccess(c, gin.H{"recorded": true}) + return + } + } + + common.ApiErrorMsg(c, "技能不存在") +} + +func AdminListClientSkills(c *gin.Context) { + items, err := model.ListClientSkillMarketItems() + if err != nil { + common.ApiError(c, err) + return + } + + skills := make([]ClientSkill, 0, len(items)) + for _, item := range items { + skills = append(skills, toClientSkill(item)) + } + common.ApiSuccess(c, skills) +} + +func AdminGetClientSkill(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "无效的技能 ID") + return + } + + item, err := model.GetClientSkillMarketItemByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "技能不存在") + return + } + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, toClientSkill(item)) +} + +func AdminCreateClientSkill(c *gin.Context) { + var req AdminUpsertClientSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + item, err := normalizeClientSkillInput(req.Skill) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + if err := model.DB.Create(item).Error; err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, toClientSkill(item)) +} + +func AdminUpdateClientSkill(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "无效的技能 ID") + return + } + + var req AdminUpsertClientSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + + item, err := normalizeClientSkillInput(req.Skill) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + updateMap := map[string]any{ + "name": item.Name, + "display_name": item.DisplayName, + "display_name_zh": item.DisplayNameZh, + "description": item.Description, + "description_zh": item.DescriptionZh, + "category": item.Category, + "tags": item.Tags, + "source": item.Source, + "source_platform": item.SourcePlatform, + "source_skill_id": item.SourceSkillID, + "source_slug": item.SourceSlug, + "source_updated_at": item.SourceUpdatedAt, + "url": item.URL, + "download_url": item.DownloadURL, + "author": item.Author, + "version": item.Version, + "downloads": item.Downloads, + "enabled": item.Enabled, + "is_public": item.IsPublic, + "sort_order": item.SortOrder, + "updated_time": common.GetTimestamp(), + } + + result := model.DB.Model(&model.ClientSkillMarketItem{}).Where("id = ?", id).Updates(updateMap) + if result.Error != nil { + common.ApiError(c, result.Error) + return + } + if result.RowsAffected == 0 { + common.ApiErrorMsg(c, "技能不存在") + return + } + + updated, err := model.GetClientSkillMarketItemByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, toClientSkill(updated)) +} + +func AdminUpdateClientSkillStatus(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "无效的技能 ID") + return + } + + var req AdminUpdateClientSkillStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if req.Enabled == nil && req.IsPublic == nil { + common.ApiErrorMsg(c, "至少提供一个状态字段") + return + } + + updateMap := map[string]any{ + "updated_time": common.GetTimestamp(), + } + if req.Enabled != nil { + updateMap["enabled"] = *req.Enabled + } + if req.IsPublic != nil { + updateMap["is_public"] = *req.IsPublic + } + + result := model.DB.Model(&model.ClientSkillMarketItem{}).Where("id = ?", id).Updates(updateMap) + if result.Error != nil { + common.ApiError(c, result.Error) + return + } + if result.RowsAffected == 0 { + common.ApiErrorMsg(c, "技能不存在") + return + } + + updated, err := model.GetClientSkillMarketItemByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, toClientSkill(updated)) +} diff --git a/middleware/auth.go b/middleware/auth.go index 342e7f49812f..00fc6b6291eb 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -74,32 +74,51 @@ func authHelper(c *gin.Context, minRole int) { } } // get header New-Api-User + // 对 access token 方式保持严格校验; + // 对 session 登录方式做容错,避免前端本地缓存 userId 过期导致整页接口 401。 apiUserIdStr := c.Request.Header.Get("New-Api-User") - if apiUserIdStr == "" { + currentUserID, ok := id.(int) + if !ok { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,未提供 New-Api-User", + "message": "无权进行此操作,登录用户 ID 无效", }) c.Abort() return } - apiUserId, err := strconv.Atoi(apiUserIdStr) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "无权进行此操作,New-Api-User 格式错误", - }) - c.Abort() - return - - } - if id != apiUserId { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "无权进行此操作,New-Api-User 与登录用户不匹配", - }) - c.Abort() - return + if apiUserIdStr == "" { + if useAccessToken { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,未提供 New-Api-User", + }) + c.Abort() + return + } + // session 登录场景缺失 header 时,自动回填,保证页面可用。 + c.Request.Header.Set("New-Api-User", strconv.Itoa(currentUserID)) + } else { + apiUserId, err := strconv.Atoi(apiUserIdStr) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 格式错误", + }) + c.Abort() + return + } + if currentUserID != apiUserId { + if useAccessToken { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 与登录用户不匹配", + }) + c.Abort() + return + } + // session 登录场景容错并修正请求头,避免前端缓存错位导致 401。 + c.Request.Header.Set("New-Api-User", strconv.Itoa(currentUserID)) + } } if status.(int) == common.UserStatusDisabled { c.JSON(http.StatusOK, gin.H{ diff --git a/middleware/cache.go b/middleware/cache.go index 1a9dff877d9e..3b8f84572f1a 100644 --- a/middleware/cache.go +++ b/middleware/cache.go @@ -1,17 +1,25 @@ package middleware import ( + "strings" + "github.com/gin-gonic/gin" ) func Cache() func(c *gin.Context) { return func(c *gin.Context) { - if c.Request.RequestURI == "/" { - c.Header("Cache-Control", "no-cache") + uri := c.Request.RequestURI + if strings.HasPrefix(uri, "/assets/") { + // 构建产物文件名带 hash,允许长缓存。 + c.Header("Cache-Control", "public, max-age=604800, immutable") } else { - c.Header("Cache-Control", "max-age=604800") // one week + // 页面路由(如 /console/skill-market)和其他入口统一禁用缓存, + // 避免 IAB 持续命中旧前端代码导致“编辑空白”。 + c.Header("Cache-Control", "no-store, no-cache, must-revalidate") } - c.Header("Cache-Version", "b688f2fb5be447c25e5aa3bd063087a83db32a288bf6a4f35f2d8db310e40b14") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Header("Cache-Version", "skill-market-hotfix-20260419") c.Next() } } diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go index d8dd15d9c5d7..ff698e4292d2 100644 --- a/middleware/rate-limit.go +++ b/middleware/rate-limit.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -89,7 +90,15 @@ func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gi func GlobalWebRateLimit() func(c *gin.Context) { if common.GlobalWebRateLimitEnable { - return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW") + limiter := rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW") + return func(c *gin.Context) { + clientIP := c.ClientIP() + if clientIP == "127.0.0.1" || clientIP == "::1" || strings.HasPrefix(clientIP, "localhost") { + c.Next() + return + } + limiter(c) + } } return defNext } diff --git a/model/client_skill_market.go b/model/client_skill_market.go new file mode 100644 index 000000000000..57debf1cd18c --- /dev/null +++ b/model/client_skill_market.go @@ -0,0 +1,119 @@ +package model + +import ( + "encoding/json" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// ClientSkillMarketItem 存储 myclaw 技能商店的公开技能。 +// 供 /api/client/skills 直接读取,后续可以通过数据库配置技能商店内容。 +type ClientSkillMarketItem struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_client_skill_name_delete_at,priority:1"` + DisplayName string `json:"display_name" gorm:"size:191;default:''"` + DisplayNameZh string `json:"display_name_zh" gorm:"size:191;default:''"` + Description string `json:"description" gorm:"type:text"` + DescriptionZh string `json:"description_zh" gorm:"type:text"` + Category string `json:"category" gorm:"size:64;index"` + Tags JSONValue `json:"tags" gorm:"type:json"` + Source string `json:"source" gorm:"size:32;default:'community'"` + SourcePlatform string `json:"source_platform" gorm:"size:64;not null;default:'manual';index;uniqueIndex:uk_client_skill_source_slug_delete_at,priority:1"` + SourceSkillID string `json:"source_skill_id" gorm:"size:128;index"` + SourceSlug string `json:"source_slug" gorm:"size:191;not null;default:'';uniqueIndex:uk_client_skill_source_slug_delete_at,priority:2"` + SourceUpdatedAt int64 `json:"source_updated_at" gorm:"bigint;default:0;index"` + RawPayload JSONValue `json:"raw_payload" gorm:"type:json"` + URL string `json:"url" gorm:"type:text"` + DownloadURL string `json:"download_url,omitempty" gorm:"type:text"` + Author string `json:"author,omitempty" gorm:"size:128"` + Version string `json:"version,omitempty" gorm:"size:64"` + Downloads int `json:"downloads,omitempty" gorm:"default:0"` + Enabled bool `json:"enabled" gorm:"default:true;index"` + IsPublic bool `json:"is_public" gorm:"default:true;index"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_client_skill_name_delete_at,priority:2;uniqueIndex:uk_client_skill_source_slug_delete_at,priority:3"` +} + +func (s *ClientSkillMarketItem) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + if s.CreatedTime == 0 { + s.CreatedTime = now + } + s.UpdatedTime = now + return nil +} + +func (s *ClientSkillMarketItem) BeforeUpdate(_ *gorm.DB) error { + s.UpdatedTime = common.GetTimestamp() + return nil +} + +func (s *ClientSkillMarketItem) TagList() []string { + if len(s.Tags) == 0 { + return []string{} + } + var tags []string + if err := json.Unmarshal(s.Tags, &tags); err != nil { + return []string{} + } + return tags +} + +func ListPublicClientSkillMarketItems() ([]*ClientSkillMarketItem, error) { + var items []*ClientSkillMarketItem + err := DB.Model(&ClientSkillMarketItem{}). + Where("enabled = ? AND is_public = ?", true, true). + Order("sort_order ASC, id ASC"). + Find(&items).Error + return items, err +} + +func GetPublicClientSkillMarketItemByID(id int) (*ClientSkillMarketItem, error) { + var item ClientSkillMarketItem + err := DB.Model(&ClientSkillMarketItem{}). + Where("id = ? AND enabled = ? AND is_public = ?", id, true, true). + First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +func ListClientSkillMarketItems() ([]*ClientSkillMarketItem, error) { + var items []*ClientSkillMarketItem + err := DB.Model(&ClientSkillMarketItem{}). + Order("sort_order ASC, id ASC"). + Find(&items).Error + return items, err +} + +func GetClientSkillMarketItemByID(id int) (*ClientSkillMarketItem, error) { + var item ClientSkillMarketItem + err := DB.Model(&ClientSkillMarketItem{}). + Where("id = ?", id). + First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +func FindClientSkillMarketItemBySource(platform string, slug string) (*ClientSkillMarketItem, error) { + var item ClientSkillMarketItem + err := DB.Model(&ClientSkillMarketItem{}). + Where("source_platform = ? AND source_slug = ?", platform, slug). + First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +func IncrementClientSkillMarketDownload(id int) error { + return DB.Model(&ClientSkillMarketItem{}). + Where("id = ? AND enabled = ? AND is_public = ?", id, true, true). + Update("downloads", gorm.Expr("downloads + ?", 1)).Error +} diff --git a/model/main.go b/model/main.go index 37bced5f9338..3b5433f7c8e2 100644 --- a/model/main.go +++ b/model/main.go @@ -280,6 +280,7 @@ func migrateDB() error { &SubscriptionPreConsumeRecord{}, &CustomOAuthProvider{}, &UserOAuthBinding{}, + &ClientSkillMarketItem{}, ) if err != nil { return err @@ -328,6 +329,7 @@ func migrateDBFast() error { {&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"}, {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, + {&ClientSkillMarketItem{}, "ClientSkillMarketItem"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/router/api-router.go b/router/api-router.go index d08a5877993b..3cec74845eba 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -155,12 +155,31 @@ func SetApiRouter(router *gin.Engine) { // Subscription plans list (public) clientRoute.GET("/subscription/plans", controller.GetSubscriptionPlans) + // Public skills list/detail for myclaw skills market + clientRoute.GET("/skills", controller.ClientListPublicSkills) + clientRoute.GET("/skills/:id", controller.ClientGetPublicSkill) + clientRoute.POST("/skills/download/:id", controller.ClientRecordSkillDownload) + // 客户端发起订阅支付(API Key 鉴权 + 限流) // Client subscription pay (API Key auth + rate limited) clientRoute.POST("/subscription/epay/pay", middleware.TokenAuth(), middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay) + + // SkillHub 下载代理(公开技能下载入口,无需客户端直接访问外网) + clientRoute.GET("/skills/skillhub/download", + controller.ClientProxySkillHubDownload) + } + + clientAdminRoute := apiRouter.Group("/client/admin") + clientAdminRoute.Use(middleware.AdminAuth()) + { + clientAdminRoute.GET("/skills", controller.AdminListClientSkills) + clientAdminRoute.GET("/skills/:id", controller.AdminGetClientSkill) + clientAdminRoute.POST("/skills", controller.AdminCreateClientSkill) + clientAdminRoute.PUT("/skills/:id", controller.AdminUpdateClientSkill) + clientAdminRoute.PATCH("/skills/:id/status", controller.AdminUpdateClientSkillStatus) } // Subscription billing (plans, purchase, admin management) diff --git a/web/src/App.jsx b/web/src/App.jsx index a5d1ebc00b32..36542ec97e30 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -49,6 +49,8 @@ import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; +import SkillMarket from './pages/SkillMarket'; +import SkillMarketEditor from './pages/SkillMarket/Editor'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -247,6 +249,22 @@ function App() { } /> + + + + } + /> + + + + } + /> {} }) => { to: '/console/models', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('技能管理'), + itemKey: 'skill-market', + to: '/console/skill-market', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('模型部署'), itemKey: 'deployment', @@ -193,6 +200,8 @@ const SiderBar = ({ onNavigate = () => {} }) => { // 根据配置过滤项目 const filteredItems = items.filter((item) => { + // 技能管理是本地扩展核心入口,管理员下始终显示,避免被配置误隐藏。 + if (item.itemKey === 'skill-market') return isAdmin(); const configVisible = isModuleVisible('admin', item.itemKey); return configVisible; }); @@ -475,8 +484,8 @@ const SiderBar = ({ onNavigate = () => {} }) => { )} - {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */} - {isAdmin() && hasSectionVisibleModules('admin') && ( + {/* 管理员区域 - 管理员始终显示,避免配置误隐藏 */} + {isAdmin() && ( <>
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f472e..4638b975dabb 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -73,6 +73,7 @@ import { Settings, CircleUser, Package, + Store, Server, CalendarClock, } from 'lucide-react'; @@ -89,7 +90,6 @@ import { SiGitlab, SiGoogle, SiKeycloak, - SiLinkedin, SiNextcloud, SiNotion, SiOkta, @@ -101,6 +101,7 @@ import { SiWechat, SiX, } from 'react-icons/si'; +import { BsLinkedin } from 'react-icons/bs'; // 获取侧边栏Lucide图标组件 export function getLucideIcon(key, selected = false) { @@ -141,6 +142,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'models': return ; + case 'skill-market': + return ; case 'deployment': return ; case 'subscription': @@ -504,7 +507,7 @@ const oauthProviderIconMap = { google: SiGoogle, discord: SiDiscord, facebook: SiFacebook, - linkedin: SiLinkedin, + linkedin: BsLinkedin, x: SiX, twitter: SiX, slack: SiSlack, diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index cd74ada20280..74c7de90263b 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -48,6 +48,7 @@ export const DEFAULT_ADMIN_CONFIG = { enabled: true, channel: true, models: true, + 'skill-market': true, deployment: true, redemption: true, user: true, diff --git a/web/src/pages/SkillMarket/EditSkillModal.jsx b/web/src/pages/SkillMarket/EditSkillModal.jsx new file mode 100644 index 000000000000..56c1acb58953 --- /dev/null +++ b/web/src/pages/SkillMarket/EditSkillModal.jsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { + Button, + Input, + InputNumber, + SideSheet, + Space, + Switch, + Tag, + Typography, +} from '@douyinfe/semi-ui'; + +const EditSkillModal = ({ + visible, + editingSkill, + setEditingSkill, + editLoading, + editError, + saving, + onCancel, + onSubmit, + statusLoadingMap, + updateSkillStatus, + getPreviewUrl, + matchedEditSkill, + skillsCount, + categoryPresets, + getMylclawNavLabel, +}) => ( + + + {editingSkill?.id ? '编辑' : '新增'} + + + {editingSkill?.id ? `设置技能 #${editingSkill.id}` : '新增技能'} + + + } + footer={ +
+ + + + +
+ } + bodyStyle={{ padding: 0 }} + > +
+
+
调试面板(模型管理同款 SideSheet 版)
+
+ {`visible=${visible ? 'yes' : 'no'} | listCount=${skillsCount} | matched=${ + matchedEditSkill ? 'yes' : 'no' + } | formId=${editingSkill?.id ?? ''} | formName=${editingSkill?.name || ''}`} +
+
+ +
+ build=skill-market-sidesheet-v1 +
+ + {editError ? ( +
+ 当前展示的是列表缓存数据:{editError} +
+ ) : null} + + {editLoading ? ( +
正在加载技能详情...
+ ) : null} + +
+
+
原始名称
+ setEditingSkill((prev) => ({ ...prev, name: value }))} + /> +
+
+
分类
+ setEditingSkill((prev) => ({ ...prev, category: value }))} + /> +
+
导航分类快捷选择
+ +
+
+ 当前会显示到 myclaw 导航:{getMylclawNavLabel(editingSkill.category)} +
+
+
+
展示名
+ setEditingSkill((prev) => ({ ...prev, display_name: value }))} + /> +
+
+
中文别名
+ setEditingSkill((prev) => ({ ...prev, display_name_zh: value }))} + /> +
+
+
作者
+ setEditingSkill((prev) => ({ ...prev, author: value }))} + /> +
+
+
版本
+ setEditingSkill((prev) => ({ ...prev, version: value }))} + /> +
+
+
来源平台
+ + setEditingSkill((prev) => ({ ...prev, source_platform: value })) + } + /> +
+
+
来源 slug
+ setEditingSkill((prev) => ({ ...prev, source_slug: value }))} + /> +
+
+
排序
+ + setEditingSkill((prev) => ({ ...prev, sort_order: Number(value) || 0 })) + } + style={{ width: '100%' }} + /> +
+
+
下载量
+ + setEditingSkill((prev) => ({ ...prev, downloads: Number(value) || 0 })) + } + style={{ width: '100%' }} + /> +
+
+ +
+
标签
+ setEditingSkill((prev) => ({ ...prev, tags_text: value }))} + /> +
+
+
详情地址
+ setEditingSkill((prev) => ({ ...prev, url: value }))} + /> +
+
+
下载地址
+ setEditingSkill((prev) => ({ ...prev, download_url: value }))} + /> +
+
+
描述
+ setEditingSkill((prev) => ({ ...prev, description: value }))} + /> +
+
+
中文描述
+ setEditingSkill((prev) => ({ ...prev, description_zh: value }))} + /> +
+ +
+
+ 启用 + setEditingSkill((prev) => ({ ...prev, enabled: checked }))} + /> +
+
+ 公开上架 + setEditingSkill((prev) => ({ ...prev, is_public: checked }))} + /> +
+ {editingSkill.id ? ( + + ) : null} + {editingSkill.id ? ( + + ) : null} + {editingSkill.id ? ( + + ) : null} +
+
+
+); + +export default EditSkillModal; diff --git a/web/src/pages/SkillMarket/Editor.jsx b/web/src/pages/SkillMarket/Editor.jsx new file mode 100644 index 000000000000..74e05a598630 --- /dev/null +++ b/web/src/pages/SkillMarket/Editor.jsx @@ -0,0 +1,493 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { API, setUserData, showError, showSuccess, updateAPI } from '../../helpers'; + +const defaultSkillForm = { + id: undefined, + name: '', + display_name: '', + display_name_zh: '', + description: '', + description_zh: '', + category: '', + tags_text: '', + source: 'community', + source_platform: 'manual', + source_skill_id: '', + source_slug: '', + url: '', + download_url: '', + author: '', + version: '', + downloads: 0, + sort_order: 0, + enabled: true, + is_public: true, +}; + +const CATEGORY_PRESETS = [ + { value: 'productivity', label: '通用基础 / productivity' }, + { value: 'content-creation', label: '创作 / content-creation' }, + { value: 'education', label: '学术教育 / education' }, + { value: 'developer-tools', label: '开发技术 / developer-tools' }, + { value: 'ai-intelligence', label: '开发技术 / ai-intelligence' }, + { value: 'security-compliance', label: '安全合规 / security-compliance' }, + { value: 'legal', label: '安全合规 / legal' }, + { value: 'life', label: '生活 / life' }, + { value: 'communication-collaboration', label: '生活 / communication-collaboration' }, + { value: 'marketing', label: '自媒体营销 / marketing' }, + { value: '自媒体-小红书创作', label: '自媒体营销 / 小红书创作' }, + { value: '自媒体-小红书配图', label: '自媒体营销 / 小红书配图' }, + { value: '自媒体-小红书选题', label: '自媒体营销 / 小红书选题' }, + { value: '自媒体-小红书分析', label: '自媒体营销 / 小红书分析' }, + { value: '自媒体-小红书发布', label: '自媒体营销 / 小红书发布' }, + { value: '自媒体-抖音运营', label: '自媒体营销 / 抖音运营' }, + { value: '自媒体-多平台运营', label: '自媒体营销 / 多平台运营' }, + { value: 'finance', label: '财务金融 / finance' }, + { value: 'data-analysis', label: '财务金融 / data-analysis' }, +]; + +const MYCLAW_NAV_LABELS = { + productivity: '通用基础', + 'content-creation': '创作', + education: '学术教育', + 'developer-tools': '开发技术', + 'ai-intelligence': '开发技术', + 'security-compliance': '安全合规', + legal: '安全合规', + life: '生活', + 'communication-collaboration': '生活', + marketing: '自媒体营销', + '自媒体-小红书创作': '自媒体营销', + '自媒体-小红书配图': '自媒体营销', + '自媒体-小红书选题': '自媒体营销', + '自媒体-小红书分析': '自媒体营销', + '自媒体-小红书发布': '自媒体营销', + '自媒体-抖音运营': '自媒体营销', + '自媒体-多平台运营': '自媒体营销', + '自媒体-精选': '自媒体营销', + finance: '财务金融', + 'data-analysis': '财务金融', +}; + +const normalizeSkillForm = (skill) => ({ + ...defaultSkillForm, + ...skill, + tags_text: Array.isArray(skill?.tags) ? skill.tags.join(', ') : '', +}); + +const parseTags = (value) => + String(value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +const getMylclawNavLabel = (category) => MYCLAW_NAV_LABELS[category] || '未匹配到现有导航'; + +const fieldClassName = + 'mt-1 w-full rounded-lg border border-[#d9d9d9] bg-white px-3 py-2 text-sm outline-none'; + +const labelClassName = 'block text-sm font-medium text-[#1f2329]'; + +const SkillMarketEditor = () => { + const navigate = useNavigate(); + const { skillId } = useParams(); + const [editingSkill, setEditingSkill] = useState(defaultSkillForm); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [statusLoading, setStatusLoading] = useState(false); + const [editError, setEditError] = useState(''); + const [pageError, setPageError] = useState(''); + + const isCreate = skillId === 'new'; + + const ensureSessionSynced = useCallback(async () => { + const res = await API.get('/api/user/self'); + if (!res?.data?.success || !res?.data?.data) { + throw new Error(res?.data?.message || '获取当前登录用户失败'); + } + + const serverUser = res.data.data; + let localUser = null; + try { + const raw = localStorage.getItem('user'); + localUser = raw ? JSON.parse(raw) : null; + } catch { + localUser = null; + } + + const needSync = + !localUser || + String(localUser.id) !== String(serverUser.id) || + Number(localUser.role) !== Number(serverUser.role); + + if (needSync) { + setUserData(serverUser); + updateAPI(); + } + }, []); + + const loadSkill = useCallback(async () => { + setLoading(true); + setEditError(''); + setPageError(''); + try { + await ensureSessionSynced(); + if (isCreate) { + setEditingSkill(defaultSkillForm); + return; + } + + const res = await API.get(`/api/client/admin/skills/${skillId}`, { skipErrorHandler: true }); + if (!res?.data?.success || !res?.data?.data) { + throw new Error(res?.data?.message || '获取技能详情失败'); + } + + setEditingSkill(normalizeSkillForm(res.data.data)); + } catch (error) { + const message = error.message || '获取技能详情失败'; + setEditError(message); + setPageError(message); + showError(message); + } finally { + setLoading(false); + } + }, [ensureSessionSynced, isCreate, skillId]); + + useEffect(() => { + void loadSkill(); + }, [loadSkill]); + + const previewUrl = useMemo(() => { + if (editingSkill?.url?.trim()) { + const url = editingSkill.url.trim(); + if (/^https?:\/\//i.test(url)) return url; + if (url.startsWith('/')) return `${window.location.origin}${url}`; + return `https://${url}`; + } + return editingSkill?.id + ? `${window.location.origin}/api/client/skills/${editingSkill.id}` + : `${window.location.origin}/api/client/skills`; + }, [editingSkill]); + + const updateField = (key, value) => { + setEditingSkill((prev) => ({ ...prev, [key]: value })); + }; + + const submitSkill = async () => { + if (!editingSkill.name.trim()) { + showError('原始名称不能为空'); + return; + } + + setSaving(true); + try { + const payload = { + skill: { + ...editingSkill, + name: editingSkill.name.trim(), + display_name: editingSkill.display_name.trim(), + display_name_zh: editingSkill.display_name_zh.trim(), + description: editingSkill.description.trim(), + description_zh: editingSkill.description_zh.trim(), + category: editingSkill.category.trim(), + tags: parseTags(editingSkill.tags_text), + source: editingSkill.source.trim(), + source_platform: editingSkill.source_platform.trim(), + source_skill_id: editingSkill.source_skill_id.trim(), + source_slug: editingSkill.source_slug.trim(), + url: editingSkill.url.trim(), + download_url: editingSkill.download_url.trim(), + author: editingSkill.author.trim(), + version: editingSkill.version.trim(), + downloads: Number(editingSkill.downloads) || 0, + sort_order: Number(editingSkill.sort_order) || 0, + }, + }; + + const res = isCreate + ? await API.post('/api/client/admin/skills', payload) + : await API.put(`/api/client/admin/skills/${editingSkill.id}`, payload); + + if (!res?.data?.success) { + throw new Error(res?.data?.message || '保存失败'); + } + + const nextId = res.data.data?.id || editingSkill.id; + showSuccess(isCreate ? '技能已创建' : '技能已更新'); + navigate( + nextId ? `/console/skill-market/edit/${nextId}?ts=editor-plain-v2` : '/console/skill-market', + { replace: isCreate } + ); + if (isCreate && nextId) { + setEditingSkill((prev) => ({ ...prev, id: nextId })); + } + } catch (error) { + showError(error.message || '保存失败'); + } finally { + setSaving(false); + } + }; + + const updateSkillStatus = async (patch) => { + if (!editingSkill?.id) return; + setStatusLoading(true); + try { + const res = await API.patch(`/api/client/admin/skills/${editingSkill.id}/status`, patch); + if (!res?.data?.success) { + throw new Error(res?.data?.message || '状态更新失败'); + } + showSuccess('状态已更新'); + setEditingSkill((prev) => ({ ...prev, ...patch })); + } catch (error) { + showError(error.message || '状态更新失败'); + } finally { + setStatusLoading(false); + } + }; + + return ( +
+
+
+ build=skill-market-editor-plain-v2 +
+ +
+
+

+ {isCreate ? '新增技能' : `设置技能 #${editingSkill.id || skillId}`} +

+

纯页面表单版,专门用于绕开弹层白屏问题

+
+
+ {!isCreate && editingSkill?.id ? ( + + ) : null} + + +
+
+ + {pageError ? ( +
+ 页面错误:{pageError} +
+ ) : null} + + {editError ? ( +
+ 接口提示:{editError} +
+ ) : null} + + {loading ? ( +
正在加载技能信息...
+ ) : ( +
+
+ + + + + + + + + + +
+ + + + + + + +