mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 22:37:25 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a2fc5852 | ||
|
|
9d9070c899 | ||
|
|
9a48ed47f4 | ||
|
|
155f67e960 | ||
|
|
71778f4174 | ||
|
|
7bb66b8bec | ||
|
|
7bdec28e5f | ||
|
|
5ffdd9f542 | ||
|
|
4c72f2abed | ||
|
|
fd51f71e0f | ||
|
|
59f12d2582 | ||
|
|
f17a419520 | ||
|
|
ee114e14c3 |
11
.env.example
11
.env.example
@@ -73,3 +73,14 @@
|
||||
# 节点类型
|
||||
# 如果是主节点则为master
|
||||
# NODE_TYPE=master
|
||||
|
||||
|
||||
# JavaScript 运行时配置
|
||||
# 是否启用(默认:false)
|
||||
# JS_RUNTIME_ENABLED=true
|
||||
# 最大虚拟机数量(默认:8)
|
||||
# JS_MAX_VM_COUNT=
|
||||
# 运行超时时间(单位:秒,默认:5)
|
||||
# JS_SCRIPT_TIMEOUT=
|
||||
# 脚本文件夹(默认:scripts/)
|
||||
# JS_SCRIPT_PATH=
|
||||
|
||||
149
common/struct_reflect.go
Normal file
149
common/struct_reflect.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// StructToMap 递归地把任意结构体 v 转成 map[string]any。
|
||||
// - 只处理导出字段;未导出字段会被跳过。
|
||||
// - 优先使用 `json:"name"` 里逗号前的部分作为键;如果是 "-" 则忽略该字段;若无 tag,则使用字段名。
|
||||
// - 对指针、切片、数组、嵌套结构体、map 做深度遍历,保持原始结构。
|
||||
func StructToMap(v any) (map[string]any, error) {
|
||||
val := reflect.ValueOf(v)
|
||||
if !val.IsValid() {
|
||||
return nil, fmt.Errorf("nil value")
|
||||
}
|
||||
for val.Kind() == reflect.Pointer {
|
||||
if val.IsNil() {
|
||||
return nil, fmt.Errorf("nil pointer")
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("expect struct, got %s", val.Kind())
|
||||
}
|
||||
|
||||
return structValueToMap(val), nil
|
||||
}
|
||||
|
||||
func structValueToMap(val reflect.Value) map[string]any {
|
||||
out := make(map[string]any, val.NumField())
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
f := typ.Field(i)
|
||||
if f.PkgPath != "" { // 未导出字段
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 json tag
|
||||
tag := f.Tag.Get("json")
|
||||
name, opts := parseTag(tag)
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
|
||||
fv := val.Field(i)
|
||||
out[name] = valueToAny(fv, opts.Contains("omitempty"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// valueToAny 递归处理各种值类型。
|
||||
func valueToAny(v reflect.Value, omitEmpty bool) any {
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
if omitEmpty {
|
||||
return nil
|
||||
}
|
||||
// 保持与 encoding/json 行为一致,nil 指针写成 null
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
|
||||
case reflect.Struct:
|
||||
return structValueToMap(v)
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
l := v.Len()
|
||||
arr := make([]any, l)
|
||||
for i := 0; i < l; i++ {
|
||||
arr[i] = valueToAny(v.Index(i), false)
|
||||
}
|
||||
return arr
|
||||
|
||||
case reflect.Map:
|
||||
m := make(map[string]any, v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
k := iter.Key()
|
||||
// 只支持 string key,与 encoding/json 一致
|
||||
if k.Kind() == reflect.String {
|
||||
m[k.String()] = valueToAny(iter.Value(), false)
|
||||
}
|
||||
}
|
||||
return m
|
||||
|
||||
default:
|
||||
// 基本类型直接返回其接口值
|
||||
return v.Interface()
|
||||
}
|
||||
}
|
||||
|
||||
// tagOptions 用于判断是否包含 "omitempty"
|
||||
type tagOptions string
|
||||
|
||||
func (o tagOptions) Contains(opt string) bool {
|
||||
if len(o) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, s := range splitComma(string(o)) {
|
||||
if s == opt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseTag(tag string) (string, tagOptions) {
|
||||
if idx := indexComma(tag); idx != -1 {
|
||||
return tag[:idx], tagOptions(tag[idx+1:])
|
||||
}
|
||||
return tag, tagOptions("")
|
||||
}
|
||||
|
||||
// 避免 strings.Split 额外分配
|
||||
func indexComma(s string) int {
|
||||
for i, r := range s {
|
||||
if r == ',' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func splitComma(s string) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
if r == ',' {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start <= len(s) {
|
||||
parts = append(parts, s[start:])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/middleware"
|
||||
"one-api/middleware/jsrt"
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"one-api/setting/console_setting"
|
||||
@@ -33,7 +35,6 @@ func TestStatus(c *gin.Context) {
|
||||
"message": "Server is running",
|
||||
"http_stats": httpStats,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetStatus(c *gin.Context) {
|
||||
@@ -106,7 +107,6 @@ func GetStatus(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetNotice(c *gin.Context) {
|
||||
@@ -117,7 +117,6 @@ func GetNotice(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": common.OptionMap["Notice"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetAbout(c *gin.Context) {
|
||||
@@ -128,7 +127,6 @@ func GetAbout(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": common.OptionMap["About"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetMidjourney(c *gin.Context) {
|
||||
@@ -139,7 +137,6 @@ func GetMidjourney(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": common.OptionMap["Midjourney"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetHomePageContent(c *gin.Context) {
|
||||
@@ -150,7 +147,6 @@ func GetHomePageContent(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": common.OptionMap["HomePageContent"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func SendEmailVerification(c *gin.Context) {
|
||||
@@ -173,13 +169,7 @@ func SendEmailVerification(c *gin.Context) {
|
||||
localPart := parts[0]
|
||||
domainPart := parts[1]
|
||||
if common.EmailDomainRestrictionEnabled {
|
||||
allowed := false
|
||||
for _, domain := range common.EmailDomainWhitelist {
|
||||
if domainPart == domain {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
allowed := slices.Contains(common.EmailDomainWhitelist, domainPart)
|
||||
if !allowed {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -224,7 +214,6 @@ func SendEmailVerification(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func SendPasswordResetEmail(c *gin.Context) {
|
||||
@@ -263,7 +252,6 @@ func SendPasswordResetEmail(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type PasswordResetRequest struct {
|
||||
@@ -303,5 +291,13 @@ func ResetPassword(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": password,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func ReloadJSScripts(c *gin.Context) {
|
||||
jsrt.ReloadJSScripts()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "JavaScript 脚本已重新加载",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
|
||||
return fmt.Errorf("Get Task status code: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./logs:/app/logs
|
||||
- ${JS_SCRIPT_DIR:-./scripts}:/app/scripts
|
||||
environment:
|
||||
- SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
|
||||
- REDIS_CONN_STRING=redis://redis
|
||||
@@ -21,7 +22,6 @@ services:
|
||||
# - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
# - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
|
||||
|
||||
depends_on:
|
||||
- redis
|
||||
- mysql
|
||||
|
||||
5
go.mod
5
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-contrib/gzip v0.0.6
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
@@ -31,6 +32,7 @@ require (
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.11.0
|
||||
gorm.io/driver/mysql v1.4.3
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/gorm v1.25.2
|
||||
@@ -56,9 +58,11 @@ require (
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
@@ -84,7 +88,6 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,5 +1,7 @@
|
||||
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
|
||||
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
|
||||
@@ -40,6 +42,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
|
||||
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
@@ -83,6 +87,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
@@ -97,8 +103,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
|
||||
62
middleware/jsrt/cfg.go
Normal file
62
middleware/jsrt/cfg.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runtime 配置
|
||||
type JSRuntimeConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxVMCount int `json:"max_vm_count"`
|
||||
ScriptTimeout time.Duration `json:"script_timeout"`
|
||||
ScriptDir string `json:"script_dir"`
|
||||
FetchTimeout time.Duration `json:"fetch_timeout"`
|
||||
}
|
||||
|
||||
var (
|
||||
jsConfig = JSRuntimeConfig{}
|
||||
)
|
||||
|
||||
const (
|
||||
defaultScriptDir = "scripts/"
|
||||
defaultScriptTimeout = 5 * time.Second
|
||||
defaultFetchTimeout = 10 * time.Second
|
||||
defaultMaxVMCount = 8
|
||||
)
|
||||
|
||||
func loadCfg() {
|
||||
if enabled := os.Getenv("JS_RUNTIME_ENABLED"); enabled != "" {
|
||||
jsConfig.Enabled = enabled == "true"
|
||||
}
|
||||
|
||||
if maxCount := os.Getenv("JS_MAX_VM_COUNT"); maxCount != "" {
|
||||
if count, err := strconv.Atoi(maxCount); err == nil && count > 0 {
|
||||
jsConfig.MaxVMCount = count
|
||||
}
|
||||
} else {
|
||||
jsConfig.MaxVMCount = defaultMaxVMCount
|
||||
}
|
||||
|
||||
if timeout := os.Getenv("JS_SCRIPT_TIMEOUT"); timeout != "" {
|
||||
if t, err := time.ParseDuration(timeout + "s"); err == nil && t > 0 {
|
||||
jsConfig.ScriptTimeout = t
|
||||
}
|
||||
} else {
|
||||
jsConfig.ScriptTimeout = defaultScriptTimeout
|
||||
}
|
||||
|
||||
if fetchTimeout := os.Getenv("JS_FETCH_TIMEOUT"); fetchTimeout != "" {
|
||||
if t, err := time.ParseDuration(fetchTimeout + "s"); err == nil && t > 0 {
|
||||
jsConfig.FetchTimeout = t
|
||||
}
|
||||
} else {
|
||||
jsConfig.FetchTimeout = defaultFetchTimeout
|
||||
}
|
||||
|
||||
jsConfig.ScriptDir = os.Getenv("JS_SCRIPT_DIR")
|
||||
if jsConfig.ScriptDir == "" {
|
||||
jsConfig.ScriptDir = defaultScriptDir
|
||||
}
|
||||
}
|
||||
69
middleware/jsrt/db.go
Normal file
69
middleware/jsrt/db.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func dbQuery(db *gorm.DB, sql string, args ...any) []map[string]any {
|
||||
if db == nil {
|
||||
common.SysError("JS DB is nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := db.Raw(sql, args...).Rows()
|
||||
if err != nil {
|
||||
common.SysError("JS DB Query Error: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
common.SysError("JS DB Columns Error: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, 100)
|
||||
for rows.Next() {
|
||||
values := make([]any, len(columns))
|
||||
valuePtrs := make([]any, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
common.SysError("JS DB Scan Error: " + err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
row := make(map[string]any, len(columns))
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func dbExec(db *gorm.DB, sql string, args ...any) map[string]any {
|
||||
if db == nil {
|
||||
return map[string]any{
|
||||
"rowsAffected": int64(0),
|
||||
"error": "database is nil",
|
||||
}
|
||||
}
|
||||
|
||||
result := db.Exec(sql, args...)
|
||||
return map[string]any{
|
||||
"rowsAffected": result.RowsAffected,
|
||||
"error": result.Error,
|
||||
}
|
||||
}
|
||||
137
middleware/jsrt/fetch.go
Normal file
137
middleware/jsrt/fetch.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type JSFetchRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body any `json:"body"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type JSFetchResponse struct {
|
||||
Status int `json:"status"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) fetch(url string, options ...any) *JSFetchResponse {
|
||||
req := &JSFetchRequest{
|
||||
Method: "GET",
|
||||
URL: url,
|
||||
Headers: make(map[string]string),
|
||||
Timeout: int(jsConfig.FetchTimeout.Seconds()),
|
||||
}
|
||||
|
||||
// 解析选项
|
||||
if len(options) > 0 && options[0] != nil {
|
||||
if optMap, ok := options[0].(map[string]any); ok {
|
||||
if method, exists := optMap["method"]; exists {
|
||||
if methodStr, ok := method.(string); ok {
|
||||
req.Method = strings.ToUpper(methodStr)
|
||||
}
|
||||
}
|
||||
|
||||
if headers, exists := optMap["headers"]; exists {
|
||||
if headersMap, ok := headers.(map[string]any); ok {
|
||||
for k, v := range headersMap {
|
||||
if vStr, ok := v.(string); ok {
|
||||
req.Headers[k] = vStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if body, exists := optMap["body"]; exists {
|
||||
req.Body = body
|
||||
}
|
||||
|
||||
if timeout, exists := optMap["timeout"]; exists {
|
||||
if timeoutNum, ok := timeout.(float64); ok {
|
||||
req.Timeout = int(timeoutNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建HTTP请求
|
||||
var bodyReader io.Reader
|
||||
switch body := req.Body.(type) {
|
||||
case string:
|
||||
bodyReader = strings.NewReader(body)
|
||||
case []byte:
|
||||
bodyReader = bytes.NewReader(body)
|
||||
case nil:
|
||||
bodyReader = nil
|
||||
default:
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return &JSFetchResponse{
|
||||
Error: fmt.Sprintf("Failed to marshal body: %v", err),
|
||||
}
|
||||
}
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, req.URL, bodyReader)
|
||||
if err != nil {
|
||||
return &JSFetchResponse{
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// 设置默认User-Agent
|
||||
if httpReq.Header.Get("User-Agent") == "" {
|
||||
httpReq.Header.Set("User-Agent", "JS-Runtime-Fetch/1.0")
|
||||
}
|
||||
|
||||
// 创建带超时的上下文
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
httpReq = httpReq.WithContext(ctx)
|
||||
|
||||
// 执行请求
|
||||
resp, err := p.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return &JSFetchResponse{}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &JSFetchResponse{
|
||||
Status: resp.StatusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应头
|
||||
headers := make(map[string]string)
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &JSFetchResponse{
|
||||
Status: resp.StatusCode,
|
||||
Headers: headers,
|
||||
Body: string(bodyBytes),
|
||||
}
|
||||
}
|
||||
570
middleware/jsrt/jsrt.go
Normal file
570
middleware/jsrt/jsrt.go
Normal file
@@ -0,0 +1,570 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 池化
|
||||
type JSRuntimePool struct {
|
||||
pool chan *goja.Runtime
|
||||
maxSize int
|
||||
createFunc func() *goja.Runtime
|
||||
scripts string
|
||||
mu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
jsRuntimePool *JSRuntimePool
|
||||
jsPoolOnce sync.Once
|
||||
)
|
||||
|
||||
func NewJSRuntimePool(maxSize int) *JSRuntimePool {
|
||||
// 创建HTTP客户端
|
||||
httpClient := &http.Client{
|
||||
Timeout: jsConfig.FetchTimeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
},
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
pool := &JSRuntimePool{
|
||||
pool: make(chan *goja.Runtime, maxSize),
|
||||
maxSize: maxSize,
|
||||
scripts: "",
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
pool.createFunc = func() *goja.Runtime {
|
||||
vm := goja.New()
|
||||
pool.setupGlobals(vm)
|
||||
pool.loadScripts(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
// 预创建VM
|
||||
preCreate := min(maxSize/2, 4)
|
||||
for range preCreate {
|
||||
select {
|
||||
case pool.pool <- pool.createFunc():
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) Get() *goja.Runtime {
|
||||
select {
|
||||
case vm := <-p.pool:
|
||||
return vm
|
||||
default:
|
||||
return p.createFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) Put(vm *goja.Runtime) {
|
||||
if vm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case p.pool <- vm:
|
||||
default:
|
||||
// 池满,丢弃VM让GC回收
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) setupGlobals(vm *goja.Runtime) {
|
||||
// console
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(args ...any) {
|
||||
var strs []string
|
||||
for _, arg := range args {
|
||||
strs = append(strs, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
common.SysLog("JS: " + strings.Join(strs, " "))
|
||||
})
|
||||
console.Set("error", func(args ...any) {
|
||||
var strs []string
|
||||
for _, arg := range args {
|
||||
strs = append(strs, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
common.SysError("JS: " + strings.Join(strs, " "))
|
||||
})
|
||||
console.Set("warn", func(args ...any) {
|
||||
var strs []string
|
||||
for _, arg := range args {
|
||||
strs = append(strs, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
common.SysError("JS WARN: " + strings.Join(strs, " "))
|
||||
})
|
||||
vm.Set("console", console)
|
||||
|
||||
// JSON
|
||||
jsonObj := vm.NewObject()
|
||||
jsonObj.Set("parse", func(str string) any {
|
||||
var result any
|
||||
err := json.Unmarshal([]byte(str), &result)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return result
|
||||
})
|
||||
jsonObj.Set("stringify", func(obj any) string {
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
return string(data)
|
||||
})
|
||||
vm.Set("JSON", jsonObj)
|
||||
|
||||
// fetch 实现
|
||||
vm.Set("fetch", func(url string, options ...any) *JSFetchResponse {
|
||||
return p.fetch(url, options...)
|
||||
})
|
||||
|
||||
// 数据库
|
||||
setDB(vm, model.DB, "db")
|
||||
setDB(vm, model.LOG_DB, "logdb")
|
||||
|
||||
// 定时器
|
||||
vm.Set("setTimeout", func(fn func(), delay int) {
|
||||
go func() {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
fn()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) loadScripts(vm *goja.Runtime) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
// 如果已经缓存了合并的脚本,直接使用
|
||||
if p.scripts != "" {
|
||||
if _, err := vm.RunString(p.scripts); err != nil {
|
||||
common.SysError("Failed to load cached scripts: " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 首次加载时,读取 scripts/ 文件夹中的所有脚本
|
||||
p.mu.RUnlock()
|
||||
p.mu.Lock()
|
||||
defer func() {
|
||||
p.mu.Unlock()
|
||||
p.mu.RLock()
|
||||
}()
|
||||
|
||||
if p.scripts != "" {
|
||||
if _, err := vm.RunString(p.scripts); err != nil {
|
||||
common.SysError("Failed to load cached scripts: " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 读取所有脚本文件
|
||||
var combinedScript strings.Builder
|
||||
scriptDir := jsConfig.ScriptDir
|
||||
|
||||
// 检查目录是否存在
|
||||
if _, err := os.Stat(scriptDir); os.IsNotExist(err) {
|
||||
common.SysLog("Scripts directory does not exist: " + scriptDir)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取目录中的所有 .js 文件
|
||||
files, err := filepath.Glob(filepath.Join(scriptDir, "*.js"))
|
||||
if err != nil {
|
||||
common.SysError("Failed to read scripts directory: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
common.SysLog("No JavaScript files found in: " + scriptDir)
|
||||
return
|
||||
}
|
||||
|
||||
// 按文件名排序以确保加载顺序一致
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
common.SysError("Failed to read script file " + file + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加文件注释和内容
|
||||
combinedScript.WriteString("// File: " + filepath.Base(file) + "\n")
|
||||
combinedScript.WriteString(string(content))
|
||||
combinedScript.WriteString("\n\n")
|
||||
|
||||
common.SysLog("Loaded script: " + filepath.Base(file))
|
||||
}
|
||||
|
||||
// 缓存合并后的脚本
|
||||
p.scripts = combinedScript.String()
|
||||
|
||||
// 执行脚本
|
||||
if p.scripts != "" {
|
||||
if _, err := vm.RunString(p.scripts); err != nil {
|
||||
common.SysError("Failed to load combined scripts: " + err.Error())
|
||||
} else {
|
||||
common.SysLog("Successfully loaded and combined all JavaScript files from: " + scriptDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) ReloadScripts() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 清空缓存的脚本
|
||||
p.scripts = ""
|
||||
|
||||
// 清空VM池,强制重新创建
|
||||
for {
|
||||
select {
|
||||
case <-p.pool:
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
common.SysLog("JavaScript scripts reloaded")
|
||||
}
|
||||
|
||||
func initJSRuntimePool() *JSRuntimePool {
|
||||
jsPoolOnce.Do(func() {
|
||||
jsRuntimePool = NewJSRuntimePool(jsConfig.MaxVMCount)
|
||||
common.SysLog("JavaScript runtime pool initialized successfully")
|
||||
})
|
||||
return jsRuntimePool
|
||||
}
|
||||
|
||||
func validateGinContext(c *gin.Context) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("gin context is nil")
|
||||
}
|
||||
if c.Request == nil {
|
||||
return fmt.Errorf("gin context request is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) executeWithTimeout(_ *goja.Runtime, fn func() (goja.Value, error)) (goja.Value, error) {
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
|
||||
resultChan := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultChan <- result{err: fmt.Errorf("JS panic: %v", r)}
|
||||
}
|
||||
}()
|
||||
|
||||
value, err := fn()
|
||||
resultChan <- result{value: value, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultChan:
|
||||
return res.value, res.err
|
||||
case <-time.After(jsConfig.ScriptTimeout):
|
||||
return nil, fmt.Errorf("script execution timeout after %v", jsConfig.ScriptTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) PreProcessRequest(c *gin.Context) error {
|
||||
if err := validateGinContext(c); err != nil {
|
||||
common.SysError("JS PreProcess Validation Error: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
vm := p.Get()
|
||||
defer p.Put(vm)
|
||||
|
||||
preProcessFunc := vm.Get("preProcessRequest")
|
||||
if preProcessFunc == nil || goja.IsUndefined(preProcessFunc) {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsReq, err := common.StructToMap(createJSReq(c))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JS context: %v", err)
|
||||
}
|
||||
|
||||
result, err := p.executeWithTimeout(vm, func() (goja.Value, error) {
|
||||
fn, ok := goja.AssertFunction(preProcessFunc)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("preProcessRequest is not a function")
|
||||
}
|
||||
return fn(goja.Undefined(), vm.ToValue(jsReq))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
common.SysError("JS PreProcess Error: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理返回结果
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
resultObj := result.Export()
|
||||
if resultMap, ok := resultObj.(map[string]any); ok {
|
||||
// 是否修改请求
|
||||
if newBody, exists := resultMap["body"]; exists {
|
||||
switch v := newBody.(type) {
|
||||
case string:
|
||||
c.Request.Body = io.NopCloser(strings.NewReader(v))
|
||||
c.Request.ContentLength = int64(len(v))
|
||||
case []byte:
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(v))
|
||||
c.Request.ContentLength = int64(len(v))
|
||||
case map[string]any:
|
||||
bodyBytes, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
c.Request.ContentLength = int64(len(bodyBytes))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
common.SysError("JS PreProcess JSON Marshal Error: " + err.Error())
|
||||
}
|
||||
default:
|
||||
common.SysError("JS PreProcess Unsupported Body Type: " + fmt.Sprintf("%T", newBody))
|
||||
}
|
||||
}
|
||||
|
||||
// 是否修改 headers
|
||||
if newHeaders, exists := resultMap["headers"]; exists {
|
||||
if headersMap, ok := newHeaders.(map[string]any); ok {
|
||||
for key, value := range headersMap {
|
||||
if valueStr, ok := value.(string); ok {
|
||||
c.Request.Header.Set(key, valueStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 是否阻止请求
|
||||
if block, exists := resultMap["block"]; exists {
|
||||
if blockBool, ok := block.(bool); ok && blockBool {
|
||||
status := http.StatusForbidden
|
||||
if statusCode, exists := resultMap["statusCode"]; exists {
|
||||
if statusInt, ok := statusCode.(float64); ok {
|
||||
status = int(statusInt)
|
||||
}
|
||||
}
|
||||
|
||||
message := "Request blocked by pre-process script"
|
||||
if msg, exists := resultMap["message"]; exists {
|
||||
if msgStr, ok := msg.(string); ok {
|
||||
message = msgStr
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(status, gin.H{"error": message})
|
||||
c.Abort()
|
||||
return fmt.Errorf("request blocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) PostProcessResponse(c *gin.Context, statusCode int, body []byte) (int, []byte, error) {
|
||||
if err := validateGinContext(c); err != nil {
|
||||
common.SysError("JS PostProcess Validation Error: " + err.Error())
|
||||
return statusCode, body, err
|
||||
}
|
||||
|
||||
vm := p.Get()
|
||||
defer p.Put(vm)
|
||||
|
||||
postProcessFunc := vm.Get("postProcessResponse")
|
||||
if postProcessFunc == nil || goja.IsUndefined(postProcessFunc) {
|
||||
return statusCode, body, nil
|
||||
}
|
||||
|
||||
jsReq, err := common.StructToMap(createJSReq(c))
|
||||
if err != nil {
|
||||
return statusCode, body, fmt.Errorf("failed to create JS context: %v", err)
|
||||
}
|
||||
|
||||
jsResp := &JSResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: make(map[string]string),
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
// 获取响应头
|
||||
if c.Writer != nil {
|
||||
for key, values := range c.Writer.Header() {
|
||||
if len(values) > 0 {
|
||||
jsResp.Headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
jsResponse, err := common.StructToMap(jsResp)
|
||||
if err != nil {
|
||||
return statusCode, body, fmt.Errorf("failed to create JS response context: %v", err)
|
||||
}
|
||||
|
||||
result, err := p.executeWithTimeout(vm, func() (goja.Value, error) {
|
||||
fn, ok := goja.AssertFunction(postProcessFunc)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("postProcessResponse is not a function")
|
||||
}
|
||||
return fn(goja.Undefined(), vm.ToValue(jsReq), vm.ToValue(jsResponse))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
common.SysError("JS PostProcess Error: " + err.Error())
|
||||
return statusCode, body, err
|
||||
}
|
||||
|
||||
// 处理返回
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
resultObj := result.Export()
|
||||
if resultMap, ok := resultObj.(map[string]any); ok {
|
||||
if newStatusCode, exists := resultMap["statusCode"]; exists {
|
||||
if statusInt, ok := newStatusCode.(float64); ok {
|
||||
statusCode = int(statusInt)
|
||||
}
|
||||
}
|
||||
|
||||
if newBody, exists := resultMap["body"]; exists {
|
||||
if bodyStr, ok := newBody.(string); ok {
|
||||
body = []byte(bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
if newHeaders, exists := resultMap["headers"]; exists {
|
||||
if headersMap, ok := newHeaders.(map[string]any); ok {
|
||||
for key, value := range headersMap {
|
||||
if valueStr, ok := value.(string); ok {
|
||||
c.Header(key, valueStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statusCode, body, nil
|
||||
}
|
||||
|
||||
func (p *JSRuntimePool) hasPostProcessFunction() bool {
|
||||
vm := p.Get()
|
||||
defer p.Put(vm)
|
||||
postProcessFunc := vm.Get("postProcessResponse")
|
||||
return postProcessFunc != nil && !goja.IsUndefined(postProcessFunc)
|
||||
}
|
||||
|
||||
func JSRuntimeMiddleware() *gin.HandlerFunc {
|
||||
loadCfg()
|
||||
if !jsConfig.Enabled {
|
||||
common.SysLog("JavaScript Runtime is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
pool := initJSRuntimePool()
|
||||
var fn gin.HandlerFunc
|
||||
fn = func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
// 预处理
|
||||
if err := pool.PreProcessRequest(c); err != nil {
|
||||
common.SysError("JS Runtime PreProcess Error: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
if duration > time.Millisecond*100 {
|
||||
common.SysLog(fmt.Sprintf("JS Runtime PreProcess took %v", duration))
|
||||
}
|
||||
|
||||
// 后处理
|
||||
if pool.hasPostProcessFunction() {
|
||||
writer := newResponseWriter(c.Writer)
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
// 后处理响应
|
||||
if writer.body.Len() > 0 {
|
||||
start := time.Now()
|
||||
|
||||
statusCode, body, err := pool.PostProcessResponse(c, writer.statusCode, writer.body.Bytes())
|
||||
if err == nil {
|
||||
c.Writer = writer.ResponseWriter
|
||||
|
||||
for k, v := range writer.headerMap {
|
||||
for _, value := range v {
|
||||
c.Writer.Header().Add(k, value)
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(statusCode)
|
||||
|
||||
if len(body) >= 0 {
|
||||
c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
c.Writer.Write(body)
|
||||
} else {
|
||||
c.Writer.Header().Del("Content-Length")
|
||||
c.Writer.Write(body)
|
||||
}
|
||||
} else {
|
||||
// 出错时回复原响应
|
||||
c.Writer = writer.ResponseWriter
|
||||
c.Status(writer.statusCode)
|
||||
|
||||
common.SysError(fmt.Sprintf("JS Runtime PostProcess Error: %v", err))
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
if duration > time.Millisecond*100 {
|
||||
common.SysLog(fmt.Sprintf("JS Runtime PostProcess took %v", duration))
|
||||
}
|
||||
} else {
|
||||
// 没有响应体时,恢复原始writer
|
||||
c.Writer = writer.ResponseWriter
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
return &fn
|
||||
}
|
||||
|
||||
func ReloadJSScripts() {
|
||||
if jsRuntimePool != nil {
|
||||
jsRuntimePool.ReloadScripts()
|
||||
common.SysLog("JavaScript scripts reloaded")
|
||||
}
|
||||
}
|
||||
139
middleware/jsrt/req.go
Normal file
139
middleware/jsrt/req.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 请求
|
||||
type JSReq struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body any `json:"body"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
RemoteIP string `json:"remoteIP"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
type JSResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
headerMap http.Header
|
||||
written bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func createJSReq(c *gin.Context) *JSReq {
|
||||
var bodyBytes []byte
|
||||
if c.Request != nil && c.Request.Body != nil {
|
||||
bodyBytes, _ = io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
|
||||
// headers map
|
||||
headers := make(map[string]string)
|
||||
if c.Request != nil && c.Request.Header != nil {
|
||||
for key, values := range c.Request.Header {
|
||||
if len(values) > 0 {
|
||||
headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
method := ""
|
||||
url := ""
|
||||
userAgent := ""
|
||||
remoteIP := ""
|
||||
contentType := ""
|
||||
|
||||
if c.Request != nil {
|
||||
method = c.Request.Method
|
||||
if c.Request.URL != nil {
|
||||
url = c.Request.URL.String()
|
||||
}
|
||||
userAgent = c.Request.UserAgent()
|
||||
contentType = c.ContentType()
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
remoteIP = c.ClientIP()
|
||||
}
|
||||
|
||||
parsedBody := parseBodyByType(bodyBytes, contentType)
|
||||
|
||||
return &JSReq{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Headers: headers,
|
||||
Body: parsedBody,
|
||||
UserAgent: userAgent,
|
||||
RemoteIP: remoteIP,
|
||||
Extra: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func newResponseWriter(w gin.ResponseWriter) *responseWriter {
|
||||
return &responseWriter{
|
||||
ResponseWriter: w,
|
||||
body: &bytes.Buffer{},
|
||||
statusCode: 200,
|
||||
headerMap: make(http.Header),
|
||||
written: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *responseWriter) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if !w.written {
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
return w.body.Write(data)
|
||||
}
|
||||
|
||||
func (w *responseWriter) WriteString(s string) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if !w.written {
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
return w.body.WriteString(s)
|
||||
}
|
||||
|
||||
func (w *responseWriter) WriteHeader(statusCode int) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.written {
|
||||
return
|
||||
}
|
||||
w.statusCode = statusCode
|
||||
w.written = true
|
||||
|
||||
maps.Copy(w.headerMap, w.ResponseWriter.Header())
|
||||
}
|
||||
|
||||
func (w *responseWriter) Header() http.Header {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
if w.headerMap == nil {
|
||||
w.headerMap = make(http.Header)
|
||||
}
|
||||
return w.headerMap
|
||||
}
|
||||
86
middleware/jsrt/utils.go
Normal file
86
middleware/jsrt/utils.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package jsrt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setDB(vm *goja.Runtime, db *gorm.DB, name string) {
|
||||
if db == nil {
|
||||
common.SysError("JS DB is nil")
|
||||
return
|
||||
}
|
||||
|
||||
obj := vm.NewObject()
|
||||
obj.Set("query", func(sql string, params ...any) []map[string]any {
|
||||
return dbQuery(db, sql, params...)
|
||||
})
|
||||
obj.Set("exec", func(sql string, params ...any) map[string]any {
|
||||
return dbExec(db, sql, params...)
|
||||
})
|
||||
if err := vm.Set(name, obj); err != nil {
|
||||
common.SysError("Failed to set JS DB: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func parseBodyByType(bodyBytes []byte, contentType string) any {
|
||||
if len(bodyBytes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bodyStr := string(bodyBytes)
|
||||
contentLower := strings.ToLower(contentType)
|
||||
|
||||
switch {
|
||||
case strings.Contains(contentLower, "application/json"):
|
||||
var jsonObj any
|
||||
if err := json.Unmarshal(bodyBytes, &jsonObj); err == nil {
|
||||
return jsonObj
|
||||
}
|
||||
return bodyStr
|
||||
|
||||
case strings.Contains(contentLower, "application/x-www-form-urlencoded"):
|
||||
if values, err := url.ParseQuery(bodyStr); err == nil {
|
||||
result := make(map[string]string, len(values))
|
||||
for k, v := range values {
|
||||
if len(v) > 0 {
|
||||
result[k] = v[0]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return bodyStr
|
||||
|
||||
case strings.Contains(contentLower, "multipart/form-data"):
|
||||
return bodyBytes
|
||||
|
||||
case strings.Contains(contentLower, "text/"):
|
||||
return bodyStr
|
||||
|
||||
default:
|
||||
// 尝试JSON解析
|
||||
var jsonObj any
|
||||
if json.Unmarshal(bodyBytes, &jsonObj) == nil {
|
||||
return jsonObj
|
||||
}
|
||||
|
||||
// 尝试form解析
|
||||
if values, err := url.ParseQuery(bodyStr); err == nil && len(values) > 0 {
|
||||
result := make(map[string]string, len(values))
|
||||
for k, v := range values {
|
||||
if len(v) > 0 {
|
||||
result[k] = v[0]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
|
||||
apiRouter.GET("/jsrt/reload", middleware.AdminAuth(), controller.ReloadJSScripts)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
|
||||
@@ -3,14 +3,21 @@ package router
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/middleware/jsrt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
jsrtMid := jsrt.JSRuntimeMiddleware()
|
||||
if jsrtMid != nil {
|
||||
router.Use(*jsrtMid)
|
||||
}
|
||||
|
||||
SetApiRouter(router)
|
||||
SetDashboardRouter(router)
|
||||
SetRelayRouter(router)
|
||||
|
||||
@@ -12,6 +12,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.DecompressRequestMiddleware())
|
||||
router.Use(middleware.StatsMiddleware())
|
||||
|
||||
// https://platform.openai.com/docs/api-reference/introduction
|
||||
modelsRouter := router.Group("/v1/models")
|
||||
modelsRouter.Use(middleware.TokenAuth())
|
||||
|
||||
15
scripts/01_utils.js
Normal file
15
scripts/01_utils.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Utility functions for JavaScript runtime
|
||||
|
||||
function logWithReq(req, message) {
|
||||
let reqPath = req.url || 'unknown path';
|
||||
console.log(`[${req.method} ${reqPath}] ${message}`);
|
||||
}
|
||||
|
||||
function safeJsonParse(str, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
console.error('JSON parse error:', e.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
5
scripts/02_pre_process.js
Normal file
5
scripts/02_pre_process.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Pre-processing function for incoming requests
|
||||
|
||||
function preProcessRequest(req) {
|
||||
logWithReq(req, 'Pre-processing request');
|
||||
}
|
||||
5
scripts/03_post_process.js
Normal file
5
scripts/03_post_process.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Post-processing function for outgoing responses
|
||||
|
||||
function postProcessResponse(req, resp) {
|
||||
logWithReq(req, 'Post-processing response with: ' + resp.statusCode);
|
||||
}
|
||||
238
scripts/README.md
Normal file
238
scripts/README.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# JavaScript Runtime Scripts
|
||||
|
||||
本目录包含 JavaScript Runtime 中间件使用的脚本文件。
|
||||
|
||||
## 脚本加载
|
||||
|
||||
- 系统会自动读取 `scripts/` 目录下的所有 `.js` 文件
|
||||
- 脚本按文件名字母顺序加载
|
||||
- 建议使用数字前缀来控制加载顺序(如:`01_utils.js`, `02_pre_process.js`)
|
||||
- 所有脚本会被合并到一个 JavaScript 运行时环境中
|
||||
|
||||
## 配置
|
||||
|
||||
通过环境变量配置:
|
||||
|
||||
- `JS_RUNTIME_ENABLED=true` - 启用 JavaScript Runtime
|
||||
- `JS_SCRIPT_DIR=scripts/` - 脚本目录路径
|
||||
- `JS_MAX_VM_COUNT=8` - 最大虚拟机数量
|
||||
- `JS_SCRIPT_TIMEOUT=5s` - 脚本执行超时时间
|
||||
- `JS_FETCH_TIMEOUT=10s` - HTTP 请求超时时间
|
||||
|
||||
更多的详细配置可以在 `.env.example` 文件中找到,并在实际使用时重命名为 `.env`。
|
||||
|
||||
## 必需的函数
|
||||
|
||||
脚本中必须定义以下两个函数:
|
||||
|
||||
### 1. preProcessRequest(req)
|
||||
|
||||
在请求被转发到后端 API 之前调用。
|
||||
|
||||
**参数:**
|
||||
|
||||
- `req`: 请求对象,包含 `method`, `url`, `headers`, `body` 等属性
|
||||
|
||||
**返回值:**
|
||||
返回一个对象,可包含以下属性:
|
||||
|
||||
- `block`: boolean - 是否阻止请求继续执行
|
||||
- `statusCode`: number - 阻止请求时返回的状态码
|
||||
- `message`: string - 阻止请求时返回的错误消息
|
||||
- `headers`: object - 要修改或添加的请求头
|
||||
- `body`: any - 修改后的请求体
|
||||
|
||||
### 2. postProcessResponse(req, resp)
|
||||
|
||||
在响应返回给客户端之前调用。
|
||||
|
||||
**参数:**
|
||||
|
||||
- `req`: 原始请求对象
|
||||
- `resp`: 响应对象,包含 `statusCode`, `headers`, `body` 等属性
|
||||
|
||||
**返回值:**
|
||||
返回一个对象,可包含以下属性:
|
||||
|
||||
- `statusCode`: number - 修改后的状态码
|
||||
- `headers`: object - 要修改或添加的响应头
|
||||
- `body`: string - 修改后的响应体
|
||||
|
||||
## 可用的全局对象和函数
|
||||
|
||||
- `console.log()`, `console.error()`, `console.warn()` - 日志输出
|
||||
- `JSON.parse()`, `JSON.stringify()` - JSON 处理
|
||||
- `fetch(url, options)` - HTTP 请求
|
||||
- `db` - 主数据库连接
|
||||
- `logdb` - 日志数据库连接
|
||||
- `setTimeout(fn, delay)` - 定时器
|
||||
|
||||
## 示例脚本
|
||||
|
||||
参考现有的示例脚本:
|
||||
|
||||
- `01_utils.js` - 工具函数
|
||||
- `02_pre_process.js` - 请求预处理
|
||||
- `03_post_process.js` - 响应后处理
|
||||
|
||||
## 使用示例
|
||||
|
||||
```js
|
||||
// 例子:基于数据库的速率限制
|
||||
if (req.url.includes("/v1/chat/completions")) {
|
||||
try {
|
||||
// Check recent requests from this IP
|
||||
var recentRequests = db.query(
|
||||
"SELECT COUNT(*) as count FROM logs WHERE created_at > ? AND ip = ?",
|
||||
Math.floor(Date.now() / 1000) - 60, // last minute
|
||||
req.remoteIP
|
||||
);
|
||||
if (recentRequests && recentRequests.length > 0 && recentRequests[0].count > 10) {
|
||||
console.log("速率限制 IP:", req.remoteIP);
|
||||
return {
|
||||
block: true,
|
||||
statusCode: 429,
|
||||
message: "超过速率限制"
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ratelimit 数据库错误:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 例子:修改请求
|
||||
if (req.url.includes("/chat/completions")) {
|
||||
try {
|
||||
var bodyObj = req.body;
|
||||
let firstMsg = { // 需要新建一个对象,不能修改原有对象
|
||||
role: "user",
|
||||
content: "喵呜🐱~嘻嘻"
|
||||
};
|
||||
bodyObj.messages[0] = firstMsg;
|
||||
console.log("Modified first message:", JSON.stringify(firstMsg));
|
||||
console.log("Modified body:", JSON.stringify(bodyObj));
|
||||
return {
|
||||
body: bodyObj,
|
||||
headers: {
|
||||
...req.headers,
|
||||
"X-Modified-Body": "true"
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to modify request body:", {
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
bodyType: typeof req.body,
|
||||
url: req.url
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 例子:读取最近一条日志,新增 jsrt 日志,并输出日志总数
|
||||
try {
|
||||
// 1. 读取最近一条日志
|
||||
var recentLogs = logdb.query(
|
||||
"SELECT id, user_id, username, content, created_at FROM logs ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
var recentLog = null;
|
||||
if (recentLogs && recentLogs.length > 0) {
|
||||
recentLog = recentLogs[0];
|
||||
console.log("最近一条日志:", JSON.stringify(recentLog));
|
||||
}
|
||||
// 2. 新增一条 jsrt 日志
|
||||
var currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
var jsrtLogContent = "JSRT 预处理中间件执行 - " + req.URL + " - " + new Date().toISOString();
|
||||
var insertResult = logdb.exec(
|
||||
"INSERT INTO logs (user_id, username, created_at, type, content) VALUES (?, ?, ?, ?, ?)",
|
||||
req.UserID || 0,
|
||||
req.Username || "jsrt-system",
|
||||
currentTimestamp,
|
||||
4, // LogTypeSystem
|
||||
jsrtLogContent
|
||||
);
|
||||
if (insertResult.error) {
|
||||
console.error("插入 JSRT 日志失败:", insertResult.error);
|
||||
} else {
|
||||
console.log("成功插入 JSRT 日志,影响行数:", insertResult.rowsAffected);
|
||||
}
|
||||
// 3. 输出日志总数
|
||||
var totalLogsResult = logdb.query("SELECT COUNT(*) as total FROM logs");
|
||||
var totalLogs = 0;
|
||||
if (totalLogsResult && totalLogsResult.length > 0) {
|
||||
totalLogs = totalLogsResult[0].total;
|
||||
}
|
||||
console.log("当前日志总数:", totalLogs);
|
||||
console.log("JSRT 日志管理示例执行完成");
|
||||
} catch (e) {
|
||||
console.error("JSRT 日志管理示例执行失败:", {
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
url: req.URL
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 例子:使用 fetch 调用外部 API
|
||||
if (req.url.includes("/api/uptime/status")) {
|
||||
try {
|
||||
// 使用 httpbin.org/ip 测试 fetch 功能
|
||||
var response = fetch("https://httpbin.org/ip", {
|
||||
method: "GET",
|
||||
timeout: 5, // 5秒超时
|
||||
headers: {
|
||||
"User-Agent": "JSRT/1.0"
|
||||
}
|
||||
});
|
||||
if (response.Error.length === 0) {
|
||||
// 解析响应体
|
||||
var ipData = JSON.parse(response.Body);
|
||||
// 可以根据获取到的 IP 信息进行后续处理
|
||||
if (ipData.origin) {
|
||||
console.log("外部 IP 地址:", ipData.origin);
|
||||
// 示例:记录 IP 信息到数据库
|
||||
var currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
var logContent = "Fetch 示例 - 外部 IP: " + ipData.origin + " - " + new Date().toISOString();
|
||||
var insertResult = logdb.exec(
|
||||
"INSERT INTO logs (user_id, username, created_at, type, content) VALUES (?, ?, ?, ?, ?)",
|
||||
0,
|
||||
"jsrt-fetch",
|
||||
currentTimestamp,
|
||||
4, // LogTypeSystem
|
||||
logContent
|
||||
);
|
||||
if (insertResult.error) {
|
||||
console.error("记录 IP 信息失败:", insertResult.error);
|
||||
} else {
|
||||
console.log("成功记录 IP 信息到数据库");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Fetch 失败 ", response.Status, " ", response.Error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch 失败:", {
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
url: req.url
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 管理接口
|
||||
|
||||
### 重新加载脚本
|
||||
|
||||
```bash
|
||||
curl -X POST http://host:port/api/jsrt/reload \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization Bearer <admin_token>'
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
- 查看服务日志中的 JavaScript 相关错误信息
|
||||
- 使用 `console.log()` 调试脚本逻辑
|
||||
- 确保 JavaScript 语法正确(不支持所有 ES6+ 特性)
|
||||
Reference in New Issue
Block a user