This commit is contained in:
Star 2024-11-10 21:34:26 +08:00
commit 209ef78919
11 changed files with 1983 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.*
!.gitignore
go.sum
env.yml
build
release
node_modules
package.json

829
api.go Normal file
View File

@ -0,0 +1,829 @@
package cloud
import (
"bytes"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"text/template"
"time"
"apigo.cc/gojs"
"apigo.cc/gojs/goja"
"github.com/gorilla/websocket"
"github.com/ssgo/config"
"github.com/ssgo/httpclient"
"github.com/ssgo/log"
"github.com/ssgo/u"
)
type Action struct {
Desc string
Config map[string]any
Method string
Path string
RequestFormat string
ResponseFormat string
Timeout int
RequestParams []*RequestParam
RequestHeaders []*HeaderParam
Response []*ResponseParam
SuccessBy string
FailedMessageBy string
}
type FixedAction struct {
Action
RequiredRequestParams []*RequestParam
NotRequiredRequestParams []*RequestParam
FuncParams string
}
type RequestParam struct {
Name string
Type string
Required bool
Value any
Desc string
Sub []*RequestParam
}
type HeaderParam struct {
Name string
Value string
Desc string
}
type ResponseParam struct {
Name string
Type string
Desc string
Sub []*ResponseParam
}
type CloudConfig struct {
Desc string
Config map[string]any
RequestFormat string
ResponseFormat string
Timeout int
RequestParams []*RequestParam
RequestHeaders []*HeaderParam
Response []*ResponseParam
Api map[string]*Action
}
// var encryptdMatcher = regexp.MustCompile(`<\*\*([\w-=]+)\*\*>`)
var encryptdMatcher = regexp.MustCompile(`^[\w-=]+$`)
var confAes = u.NewAes([]byte("?GQ$0K0GgLdO=f+~L68PLm$uhKr4'=tV"), []byte("VFs7@sK61cj^f?HZ"))
var keysIsSet = false
func SetSSKey(key, iv []byte) {
if !keysIsSet {
confAes = u.NewAes(key, iv)
keysIsSet = true
}
}
//go:embed export.ts
var exportTS string
var cloudConfigs map[string]*CloudConfig
var fixedCloudConfigs map[string]map[string]*FixedAction
var httpClients = map[string]map[int]*httpclient.ClientPool{}
var httpClientsLock = sync.RWMutex{}
func init() {
gojs.Register("apigo.cc/cloud", gojs.Module{
ObjectMaker: func(vm *goja.Runtime) gojs.Map {
logger := gojs.GetLogger(vm)
makeConfig(logger)
gojsObj := gojs.Map{}
for cloudName, actions := range fixedCloudConfigs {
httpClients[cloudName] = map[int]*httpclient.ClientPool{}
cloudObj := map[string]any{}
for actionName, action := range actions {
if _, ok := httpClients[cloudName][action.Timeout]; !ok {
httpClients[cloudName][action.Timeout] = httpclient.GetClient(time.Duration(action.Timeout) * time.Millisecond)
}
cloudObj[actionName] = makeAction(cloudName, action)
}
gojsObj[cloudName] = cloudObj
}
return gojsObj
},
TsCodeMaker: func() string {
makeConfig(log.DefaultLogger)
var err error
tsCode := ""
tpl := template.New("export")
tpl = tpl.Funcs(template.FuncMap{
"makeMap": func(args ...any) map[string]any {
out := map[string]any{}
for i := 0; i < len(args); i += 2 {
out[u.String(args[i])] = args[i+1]
}
// fmt.Println(u.BGreen(u.JsonP(out)))
return out
},
})
if tpl, err = tpl.Parse(strings.ReplaceAll(exportTS, "//----", "")); err == nil {
buf := bytes.NewBuffer(make([]byte, 0))
if err = tpl.Execute(buf, fixedCloudConfigs); err == nil {
tsCode = buf.String()
} else {
fmt.Println(u.BRed(err.Error()))
}
} else {
fmt.Println(u.BRed(err.Error()))
}
return tsCode
},
SetSSKey: SetSSKey,
})
}
func getHttpClient(cloudName string, timeout int) *httpclient.ClientPool {
httpClientsLock.RLock()
defer httpClientsLock.RUnlock()
return httpClients[cloudName][timeout]
}
var expressionMatcher = regexp.MustCompile(`(?ms){{.+?}}`)
var funcNameExcludesMatcher = regexp.MustCompile(`[^\w]`)
func makeAction(cloudName string, action *FixedAction) any {
return func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value {
// 解析参数
requiredCount := len(action.RequiredRequestParams)
args := gojs.MakeArgs(&argsIn, vm).Check(requiredCount)
params := args.Map(requiredCount + 1)
headers := args.Map(requiredCount + 2)
// 合并请求参数
for i, param := range action.RequiredRequestParams {
params[param.Name] = args.Any(i)
}
// 处理默认值
for _, param := range action.RequestParams {
if param.Value != nil && params[param.Name] == nil {
if strValue, ok := param.Value.(string); ok {
if !strings.Contains(strValue, "{{") {
params[param.Name] = param.Value
}
} else {
params[param.Name] = param.Value
}
}
}
for _, header := range action.RequestHeaders {
if header.Value != "" && headers[header.Name] == nil {
if !strings.Contains(header.Value, "{{") {
headers[header.Name] = header.Value
}
}
}
// 生成request信息
requestUrl, err := url.Parse(u.String(action.Config["endpoint"]) + action.Path)
if err != nil {
panic(vm.NewGoError(err))
}
paramsSortJoin := func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value {
args := gojs.MakeArgs(&argsIn, vm)
joinTag := args.Str(0)
linkTag := args.Str(1)
if joinTag == "" {
joinTag = "&"
}
if linkTag == "" {
linkTag = "="
}
a := make([]string, 0)
for k, v := range params {
a = append(a, k+linkTag+u.String(v))
}
slices.Sort(a)
return vm.ToValue(strings.Join(a, joinTag))
}
if requestUrl.RawPath == "" {
requestUrl.RawPath = requestUrl.Path
}
requestInfo := map[string]any{
"method": action.Method,
"scheme": requestUrl.Scheme,
"host": requestUrl.Host,
"path": requestUrl.Path,
"rawPath": requestUrl.RawPath,
"rawQuery": requestUrl.RawQuery,
"fragment": requestUrl.Fragment,
"rawFragment": requestUrl.RawFragment,
"fullPath": requestUrl.RequestURI(),
"url": requestUrl.String(),
"sortJoin": paramsSortJoin,
}
requestValue := vm.ToValue(requestInfo)
// 计算请求参数中的动态值
configValue := vm.ToValue(action.Config)
actionValue := vm.ToValue(gojs.MakeMap(action))
var paramsValue goja.Value
var headersValue goja.Value
var dataValue goja.Value
headersValue = vm.ToValue(headers)
// 处理基本信息中的动态值
if action.Path != "" && strings.Contains(action.Path, "{{") {
action.Path = expressionMatcher.ReplaceAllStringFunc(action.Path, func(s string) string {
fnCode := s[2 : len(s)-2]
newValue := runFn(fnCode, vm, "action, request, config, params, headers", actionValue, requestValue, configValue, paramsValue, headersValue)
return u.String(newValue)
})
requestUrl, err = url.Parse(u.String(action.Config["endpoint"]) + action.Path)
if err != nil {
panic(vm.NewGoError(err))
}
if requestUrl.RawPath == "" {
requestUrl.RawPath = requestUrl.Path
}
requestInfo = map[string]any{
"method": action.Method,
"scheme": requestUrl.Scheme,
"host": requestUrl.Host,
"path": requestUrl.Path,
"rawPath": requestUrl.RawPath,
"rawQuery": requestUrl.RawQuery,
"fragment": requestUrl.Fragment,
"rawFragment": requestUrl.RawFragment,
"fullPath": requestUrl.RequestURI(),
"url": requestUrl.String(),
"sortJoin": paramsSortJoin,
}
requestValue = vm.ToValue(requestInfo)
}
// 处理请求参数中的动态值
for _, param := range action.RequestParams {
if param.Value != nil && params[param.Name] == nil {
if strValue, ok := param.Value.(string); ok && strings.Contains(strValue, "{{") {
isTest := strings.Contains(param.Name, "TEST__")
if isTest {
fmt.Println("make param:", u.Cyan(param.Name))
}
// 将数据拼接到URL中部分API在请求中需要计算签名
if action.RequestFormat == "query" || action.RequestFormat == "binary" || action.Method == "GET" || action.Method == "WS" {
q := requestUrl.Query()
for k, v := range params {
q.Set(k, u.String(v))
}
requestUrl.RawQuery = q.Encode()
requestInfo["rawQuery"] = requestUrl.RawQuery
requestInfo["fullPath"] = requestUrl.RequestURI()
requestInfo["url"] = requestUrl.String()
requestValue = vm.ToValue(requestInfo)
}
// 计算动态值
var lastRealValue any
replaceTimes := 0
newValue := expressionMatcher.ReplaceAllStringFunc(strValue, func(s string) string {
replaceTimes++
fnCode := s[2 : len(s)-2]
// fnName := "_CLOUD_FN_" + funcNameExcludesMatcher.ReplaceAllString(fnCode, "_")
// fnValue := vm.Get(fnName)
// if fnValue == nil {
// if _, err := vm.RunString("function " + fnName + "(action, request, config, params, headers){return " + fnCode + "}"); err != nil {
// panic(vm.NewGoError(err))
// }
// fnValue = vm.Get(fnName)
// }
// if fn, ok := goja.AssertFunction(fnValue); ok {
// if r, err := fn(nil, actionValue, requestValue, configValue, vm.ToValue(params), vm.ToValue(headers)); err != nil {
// panic(vm.NewGoError(err))
// } else {
// lastRealValue = r.Export()
// if isTest {
// fmt.Println(" >", u.BMagenta(s), u.BCyan(u.JsonP(lastRealValue)), ".")
// }
// return u.String(r.Export())
// }
// } else {
// panic(vm.NewGoError(errors.New("failed to make function " + fnName)))
// }
paramsValue = vm.ToValue(params)
lastRealValue = runFn(fnCode, vm, "action, request, config, params, headers", actionValue, requestValue, configValue, paramsValue, headersValue)
if isTest {
fmt.Println(" >", u.BMagenta(s), u.BCyan(lastRealValue), ".")
}
return u.String(lastRealValue)
})
if replaceTimes == 1 && newValue == u.String(lastRealValue) {
// 如果是单一公式,直接使用最后一次替换真实类型的结果
if !isTest {
params[param.Name] = lastRealValue
}
} else {
// 替换为计算的结果
if !isTest {
params[param.Name] = newValue
} else {
// 打印调试信息
fmt.Println(" >", u.Cyan(u.JsonP(newValue)), ".")
fmt.Println()
}
}
}
}
}
paramsValue = vm.ToValue(params)
// 根据RequestFormat处理请求数据
if action.RequestFormat == "query" || action.RequestFormat == "binary" || action.Method == "GET" || action.Method == "WS" {
// 将数据拼接到URL中
q := requestUrl.Query()
for k, v := range params {
q.Set(k, u.String(v))
}
requestUrl.RawQuery = q.Encode()
requestInfo["rawQuery"] = requestUrl.RawQuery
requestInfo["fullPath"] = requestUrl.RequestURI()
requestInfo["url"] = requestUrl.String()
requestValue = vm.ToValue(requestInfo)
}
var data any
switch action.RequestFormat {
case "query":
// 将数据拼接到URL中
case "binary":
// 将数据以二进制形式POST参数拼接到URL中
args.Check(requiredCount + 1)
data = args.Bytes(requiredCount)
case "form", "upload":
// 将数据以表单类型提交
args.Check(requiredCount + 1)
form := make(map[string]string, 0)
for k, v := range params {
form[k] = u.String(v)
}
data = form
case "json":
// 将数据以JSON格式提交
data = u.Json(params)
default:
if action.Method == "GET" {
// 将数据拼接到URL中
} else {
// 将数据以JSON格式提交
data = params
}
}
// data = `{"EngSerViceType":"8k_zh","SourceType":1,"VoiceFormat":"mp3","Data":"AA","DataLen":0,"WordInfo":0,"FilterDirty":0,"FilterModal":0,"FilterPunc":0,"ConvertNumMode":1,"CustomizationId":"","InputSampleRate":0}`
dataValue = vm.ToValue(data)
// 计算请求头中的动态值
for _, header := range action.RequestHeaders {
if header.Value != "" && headers[header.Name] == nil {
if strings.Contains(header.Value, "{{") {
isTest := strings.Contains(header.Name, "TEST__")
if isTest {
fmt.Println("make header:", u.Cyan(header.Name))
}
var lastRealValue any
replaceTimes := 0
newValue := expressionMatcher.ReplaceAllStringFunc(header.Value, func(s string) string {
replaceTimes++
fnCode := s[2 : len(s)-2]
// fnName := "_CLOUD_FN_" + funcNameExcludesMatcher.ReplaceAllString(fnCode, "_")
// fnValue := vm.Get(fnName)
// if fnValue == nil {
// if _, err := vm.RunString("function " + fnName + "(action, request, config, params, headers, data){return " + fnCode + "}"); err != nil {
// panic(vm.NewGoError(err))
// }
// fnValue = vm.Get(fnName)
// }
// if fn, ok := goja.AssertFunction(fnValue); ok {
// if r, err := fn(nil, actionValue, requestValue, configValue, vm.ToValue(params), vm.ToValue(headers), vm.ToValue(data)); err != nil {
// panic(vm.NewGoError(err))
// } else {
// lastRealValue = r.Export()
// if isTest {
// fmt.Println(" >", u.BMagenta(s), u.BCyan(u.JsonP(lastRealValue)), ".")
// }
// return u.String(lastRealValue)
// }
// } else {
// panic(vm.NewGoError(errors.New("failed to make function " + fnName)))
// }
headersValue = vm.ToValue(headers)
lastRealValue = runFn(fnCode, vm, "action, request, config, params, headers, data", actionValue, requestValue, configValue, paramsValue, headersValue, dataValue)
if isTest {
fmt.Println(" >", u.BMagenta(s), u.BCyan(lastRealValue), ".")
}
// if header.Name == "TEST__待签名" {
// fmt.Println(u.BGreen(u.Json(lastRealValue)), ".")
// fmt.Println(u.BGreen(hex.EncodeToString(u.Sha256([]byte(u.String(lastRealValue))))))
// }
return u.String(lastRealValue)
})
if replaceTimes == 1 && newValue == u.String(lastRealValue) {
// 如果是单一公式,直接使用最后一次替换真实类型的结果
if !isTest {
headers[header.Name] = lastRealValue
}
} else {
// 替换为计算的结果
if !isTest {
headers[header.Name] = newValue
} else {
// 打印调试信息
fmt.Println(" >", u.Cyan(u.JsonP(newValue)), ".")
fmt.Println()
}
}
}
}
}
// TODO 支持SSE
// TODO 支持Download异步回调
// 处理请求头
headersArr := make([]string, 0)
for k, v := range headers {
headersArr = append(headersArr, k, u.String(v))
}
// 发送请求
hc := getHttpClient(cloudName, action.Timeout)
var httpResult *httpclient.Result
var otherResult any
t1 := time.Now().UnixMilli()
if action.RequestFormat == "upload" {
// 处理上传文件
files := args.Map(requiredCount)
httpResult, _ = hc.MPost(requestUrl.String(), data.(map[string]string), files, headersArr...)
} else if action.Method == "WS" {
// 处理WS请求
args.Check(requiredCount + 1)
callback := args.Func(requiredCount)
reqHeader := http.Header{}
for k, v := range headers {
reqHeader.Set(k, u.String(v))
}
if conn, resp, err := websocket.DefaultDialer.Dial(strings.Replace(requestUrl.String(), "http", "ws", 1), reqHeader); err == nil {
for {
if conn != nil {
var data any
var msgType string
if typ, buf, err := conn.ReadMessage(); err == nil {
switch typ {
case websocket.TextMessage:
msgType = "text"
if action.ResponseFormat == "json" {
obj := map[string]any{}
if err := json.Unmarshal(buf, &obj); err == nil {
msgType = "json"
data = obj
} else {
data = string(buf)
}
}
case websocket.BinaryMessage:
msgType = "binary"
data = buf
default:
msgType = "unknown"
data = string(buf)
}
} else {
break
}
if msgType == action.ResponseFormat {
// 最后一个类型相同的消息作为输出
otherResult = data
}
if callback != nil {
if r, err := callback(nil, vm.ToValue(data), vm.ToValue(msgType)); err != nil {
conn.Close()
panic(vm.NewGoError(err))
} else if u.Bool(r.Export()) {
fmt.Println(222, r.Export())
conn.Close()
break
} else {
fmt.Println(333, r.Export())
}
}
} else {
break
}
}
httpResult = &httpclient.Result{
Response: resp,
Error: err,
}
} else {
panic(vm.NewGoError(err))
}
} else {
// fmt.Println("request", action.Method, requestUrl.String(), u.Json(headersArr), "...")
httpResult = hc.Do(action.Method, requestUrl.String(), data, headersArr...)
// fmt.Println("response", result.Response.StatusCode, result.Response.Status, result.String(), "...")
}
if httpResult.Error != nil {
panic(vm.NewGoError(httpResult.Error))
}
usedTime := time.Now().UnixMilli() - t1
outHeaders := map[string]string{}
for k, v := range httpResult.Response.Header {
outHeaders[k] = v[0]
}
gojsResult := gojs.Map{
"statusCode": httpResult.Response.StatusCode,
"status": httpResult.Response.Status,
"headers": outHeaders,
"usedTime": usedTime,
}
var result any
if action.Method == "WS" {
result = otherResult
} else {
switch action.ResponseFormat {
case "json":
result = httpResult.Map()
case "binary":
gojsResult["result"] = httpResult.Bytes()
default:
gojsResult["result"] = httpResult.String()
}
}
var responseValue goja.Value
var resultValue goja.Value
if action.SuccessBy != "" || action.FailedMessageBy != "" {
responseValue = vm.ToValue(gojsResult)
resultValue = vm.ToValue(result)
}
if action.SuccessBy != "" {
fnResult := runFn(action.SuccessBy, vm, "action, request, config, params, headers, data, response, result", actionValue, requestValue, configValue, paramsValue, headersValue, dataValue, responseValue, resultValue)
gojsResult["success"] = u.Bool(fnResult)
} else {
gojsResult["success"] = httpResult.Response.StatusCode == 200
}
if gojsResult["success"] == true {
gojsResult["failedMessage"] = ""
} else {
if action.FailedMessageBy != "" {
fnResult := runFn(action.FailedMessageBy, vm, "action, request, config, params, headers, data, response, result", actionValue, requestValue, configValue, paramsValue, headersValue, dataValue, responseValue, resultValue)
gojsResult["failedMessage"] = u.String(fnResult)
} else {
gojsResult["failedMessage"] = httpResult.Response.Status
}
}
gojsResult["result"] = result
return vm.ToValue(gojsResult)
}
}
func runFn(fnCode string, vm *goja.Runtime, argsParams string, args ...goja.Value) any {
fnName := "_CLOUD_FN_" + funcNameExcludesMatcher.ReplaceAllString(fnCode, "_")
fnValue := vm.Get(fnName)
if fnValue == nil {
if _, err := vm.RunString("function " + fnName + "(" + argsParams + "){return " + fnCode + "}"); err != nil {
panic(vm.NewGoError(err))
}
fnValue = vm.Get(fnName)
}
if fn, ok := goja.AssertFunction(fnValue); ok {
if r, err := fn(nil, args...); err != nil {
panic(vm.NewGoError(err))
} else {
return r.Export()
}
} else {
panic(vm.NewGoError(errors.New("failed to make function " + fnName)))
}
}
func makeConfig(logger *log.Logger) {
if cloudConfigs != nil {
return
}
// 读取所有的配置文件
cloudConfigs = map[string]*CloudConfig{}
fixedCloudConfigs = map[string]map[string]*FixedAction{}
for _, f := range u.ReadDirN("api") {
if strings.HasSuffix(f.Name, ".yml") {
apiName := f.Name[:len(f.Name)-4]
cloudConf := CloudConfig{}
if err := u.LoadX(f.FullName, &cloudConf); err != nil {
logger.Error(err.Error())
continue
}
cloudConfigs[apiName] = &cloudConf
}
}
// 载入补充的配置
config.LoadConfig("cloud", &cloudConfigs)
// 根据配置生成gojs对象
for cloudName, cloudConf := range cloudConfigs {
// 解密 & 合并配置
if cloudConf.Config == nil {
cloudConf.Config = map[string]any{}
}
decryptConfig(cloudConf.Config)
fixedActions := map[string]*FixedAction{}
for actionName, apiConf := range cloudConf.Api {
// var newConf map[string]any
// if v, ok := cloudConf.ApiConfig[actionName]; ok && v != nil {
// newConf = *v
// }
// if newConf == nil {
// newConf = map[string]any{}
// }
// decryptConfig(newConf)
// for k, v := range cloudConf.Config {
// if _, ok := newConf[k]; !ok {
// newConf[k] = v
// }
// }
decryptConfig(apiConf.Config)
for k, v := range cloudConf.Config {
if _, ok := apiConf.Config[k]; !ok {
apiConf.Config[k] = v
}
}
fixedActions[actionName] = &FixedAction{
Action: *apiConf,
// Config: newConf,
RequiredRequestParams: make([]*RequestParam, 0),
NotRequiredRequestParams: make([]*RequestParam, 0),
}
}
for actionName, apiConf := range fixedActions {
// 合并请求参数
existsRequestParams := map[string]bool{}
for _, param := range apiConf.RequestParams {
existsRequestParams[param.Name] = true
}
for _, param := range cloudConf.RequestParams {
if !existsRequestParams[param.Name] {
existsRequestParams[param.Name] = true
apiConf.RequestParams = append(apiConf.RequestParams, param)
}
}
for _, param := range apiConf.RequestParams {
if param.Value == nil {
if param.Required {
apiConf.RequiredRequestParams = append(apiConf.RequiredRequestParams, param)
} else {
apiConf.NotRequiredRequestParams = append(apiConf.NotRequiredRequestParams, param)
}
} else {
apiConf.NotRequiredRequestParams = append(apiConf.NotRequiredRequestParams, param)
}
}
fixedActions[actionName].RequestParams = apiConf.RequestParams
fixedActions[actionName].RequiredRequestParams = apiConf.RequiredRequestParams
fixedActions[actionName].NotRequiredRequestParams = apiConf.NotRequiredRequestParams
// 合并请求头
if apiConf.RequestHeaders == nil {
apiConf.RequestHeaders = make([]*HeaderParam, 0)
}
existsRequestHeaders := map[string]bool{}
for _, header := range apiConf.RequestHeaders {
existsRequestHeaders[header.Name] = true
}
for _, header := range cloudConf.RequestHeaders {
if !existsRequestHeaders[header.Name] {
existsRequestHeaders[header.Name] = true
apiConf.RequestHeaders = append(apiConf.RequestHeaders, header)
}
}
fixedActions[actionName].RequestHeaders = apiConf.RequestHeaders
// 合并响应参数
if apiConf.Response == nil {
apiConf.Response = make([]*ResponseParam, 0)
}
existsResponse := map[string]bool{}
for _, resp := range apiConf.Response {
existsResponse[resp.Name] = true
}
for _, resp := range cloudConf.Response {
if !existsResponse[resp.Name] {
existsResponse[resp.Name] = true
apiConf.Response = append(apiConf.Response, resp)
}
}
fixedActions[actionName].Response = apiConf.Response
// 处理Sub类型
makeRequestType(cloudName, actionName, fixedActions[actionName].RequestParams)
makeResponseType(cloudName, actionName, fixedActions[actionName].Response)
// 合并其他
apiConf.Method = strings.ToUpper(apiConf.Method)
if apiConf.Method == "" {
apiConf.Method = "POST"
}
apiConf.RequestFormat = strings.ToLower(apiConf.RequestFormat)
if apiConf.RequestFormat == "" {
apiConf.RequestFormat = cloudConf.RequestFormat
}
apiConf.ResponseFormat = strings.ToLower(apiConf.ResponseFormat)
if apiConf.ResponseFormat == "" {
apiConf.ResponseFormat = cloudConf.ResponseFormat
}
if apiConf.Timeout == 0 {
apiConf.Timeout = cloudConf.Timeout
}
// 生成函数参数
funcParams := make([]string, 0)
for _, param := range apiConf.RequiredRequestParams {
if len(param.Sub) > 0 {
funcParams = append(funcParams, fmt.Sprintf("%s:%s", param.Name, param.Type))
} else {
funcParams = append(funcParams, fmt.Sprintf("%s:%s", param.Name, param.Type))
}
}
if apiConf.RequestFormat == "binary" {
funcParams = append(funcParams, "data:any")
}
if apiConf.Method == "WS" {
funcParams = append(funcParams, "callback:(data:any,type:string)=>boolean")
}
if len(apiConf.NotRequiredRequestParams) > 0 {
funcParams = append(funcParams, fmt.Sprintf("options?:%s_%sParams", cloudName, actionName))
}
if len(apiConf.RequestHeaders) > 0 {
funcParams = append(funcParams, fmt.Sprintf("headers?:%s_%sHeaders", cloudName, actionName))
}
apiConf.FuncParams = strings.Join(funcParams, ", ")
fixedActions[actionName].FuncParams = apiConf.FuncParams
}
fixedCloudConfigs[cloudName] = fixedActions
}
}
func makeRequestType(cloudName, actionName string, params []*RequestParam) {
for i, param := range params {
if len(param.Sub) > 0 {
subType := fmt.Sprintf("%s_%sParams%s", cloudName, actionName, param.Name)
if param.Type == "Array" {
params[i].Type = fmt.Sprintf("Array<%s>", subType)
} else {
params[i].Type = subType
}
makeRequestType(cloudName, actionName, param.Sub)
}
}
}
func makeResponseType(cloudName, actionName string, params []*ResponseParam) {
for i, param := range params {
if len(param.Sub) > 0 {
subType := fmt.Sprintf("%s_%sResult%s", cloudName, actionName, param.Name)
if param.Type == "Array" {
params[i].Type = fmt.Sprintf("Array<%s>", subType)
} else {
params[i].Type = subType
}
makeResponseType(cloudName, actionName, param.Sub)
}
}
}
func decryptConfig(conf map[string]any) {
for k, v := range conf {
if vStr, ok := v.(string); ok && encryptdMatcher.MatchString(vStr) {
conf[k] = confAes.DecryptUrlBase64ToString(vStr)
}
}
}

80
export.ts Normal file
View File

@ -0,0 +1,80 @@
//----{{- define "subType" -}}
//----{{$cloudName:=.cloudName}}{{$actionName:=.actionName}}{{$paramType:=.paramType}}
//---- {{- range $k, $v := .list }}
//---- {{- if $v.Sub }}
//----{{- template "subType" (makeMap "list" $v.Sub "cloudName" $cloudName "actionName" $actionName "paramType" $paramType) }}
//----interface {{ $cloudName }}_{{ $actionName }}{{ $paramType }}{{ $v.Name }} {
//---- {{- range $k2, $v2 := $v.Sub }}
//---- {{ $v2.Name }}: {{ $v2.Type }} // {{ $v2.Desc }}
//---- {{- end }}
//----}
//---- {{- end }}
//---- {{- end }}
//----{{- end }}
//----{{- range $cloudName, $cloudConf := . -}}
//----interface {{ $cloudName }}Cloud {
//----{{- range $actionName, $api := $cloudConf }}
//---- /**
//---- * {{ $actionName }} {{ $api.Desc }}
//---- {{- range $k, $v := $api.RequiredRequestParams }}
//---- * @param {{ $v.Name }} {{ $v.Desc }}
//---- {{- end }}
//---- {{- if eq $api.RequestFormat "binary" }}
//---- * @param data binary data
//---- {{- end }}
//---- */
//---- {{ $actionName }}: ({{ $api.FuncParams }}) => {{ $cloudName }}_{{ $actionName }}Response
//----{{- end }}
//----}
//----{{ range $actionName, $api := $cloudConf }}
//----// {{ $actionName }} {{ $api.Desc }}
//----{{- template "subType" (makeMap "list" $api.RequestParams "cloudName" $cloudName "actionName" $actionName "paramType" "Params") }}
//---- {{- if $api.NotRequiredRequestParams }}
//----interface {{ $cloudName }}_{{ $actionName }}Params {
//---- {{- range $k, $v := $api.NotRequiredRequestParams }}
//---- {{ $v.Name }}: {{ $v.Type }} // {{ $v.Desc }}
//---- {{- end }}
//----}
//---- {{- end }}
//---- {{- if $api.RequestHeaders }}
//----interface {{ $cloudName }}_{{ $actionName }}Headers {
//---- {{- range $k, $v := $api.RequestHeaders }}
//---- "{{ $v.Name }}": string // {{ $v.Desc }}
//---- {{- end }}
//----}
//---- {{- end }}
//---- {{- if eq $api.ResponseFormat "json" }}
//----{{- template "subType" (makeMap "list" $api.Response "cloudName" $cloudName "actionName" $actionName "paramType" "Result") }}
//----interface {{ $cloudName }}_{{ $actionName }}Result {
//---- {{- range $k, $v := $api.Response }}
//---- {{ $v.Name }}: {{ $v.Type }} // {{ $v.Desc }}
//---- {{- end }}
//----}
//---- {{- end }}
//----interface {{ $cloudName }}_{{ $actionName }}Response {
//---- success: boolean
//---- failedMessage: string
//---- statusCode: number
//---- status: string
//---- headers: Object
//---- usedTime: number
//---- {{- if eq $api.ResponseFormat "json" }}
//---- result: {{ $cloudName }}_{{ $actionName }}Result
//---- {{- else if eq $api.ResponseFormat "binary" }}
//---- result: any
//---- {{- else }}
//---- result: string
//---- {{- end }}
//----}
//----{{- end }}
//----
//----{{ end }}
//----{{- range $cloudName, $cloudConf := . }}
//----let {{ $cloudName }}: {{ $cloudName }}Cloud = null as any
//----{{- end }}
export {
//----{{- range $cloudName, $cloudConf := . }}
//----{{ $cloudName }},
//----{{- end }}
}

25
go.mod Normal file
View File

@ -0,0 +1,25 @@
module apigo.cc/cloud
go 1.18
require (
apigo.cc/gojs v0.0.4
github.com/gorilla/websocket v1.5.3
github.com/ssgo/config v1.7.9
github.com/ssgo/httpclient v1.7.8
github.com/ssgo/log v1.7.7
github.com/ssgo/u v1.7.11
)
require (
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
github.com/ssgo/standard v1.7.7 // indirect
github.com/ssgo/tool v0.4.27 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

10
tests/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.*
!.gitignore
go.sum
build
release
node_modules
package.json
env.yml
*.mp3
*.wav

450
tests/api/tencent.yml Normal file
View File

@ -0,0 +1,450 @@
config:
requestHeaders:
api:
fastAsr:
config:
service: asr
endpoint: https://asr.tencentcloudapi.com
desc: 本接口用于对60秒之内的短音频文件进行识别
method: POST
path: /
requestFormat: json
responseFormat: json
requestHeaders:
- name: Content-Type
value: application/json
desc: 请求体格式
- name: X-TC-Action
value: SentenceRecognition
desc: 公共参数本接口取值SentenceRecognition
- name: X-TC-Version
value: "2019-06-14"
desc: 公共参数本接口取值2019-06-14
- name: X-TC-Region
value: ap-shanghai
desc: HTTP 请求头X-TC-Region。地域参数用来标识希望操作哪个地域的数据。取值参考接口文档中输入参数章节关于公共参数 Region 的说明。注意:某些接口不需要传递该参数,接口文档中会对此特别说明,此时即使传递该参数也不会生效。
- name: X-TC-Timestamp
value: "{{util.timestamp()}}"
desc: HTTP 请求头X-TC-Timestamp。当前 UNIX 时间戳,可记录发起 API 请求的时间。例如 1529223702。注意如果与服务器时间相差超过5分钟会引起签名过期错误。
# 接口文档https://cloud.tencent.com/document/product/1093/35646
# 签名文档https://cloud.tencent.com/document/product/1093/35641
# 签名调试https://console.cloud.tencent.com/api/explorer?Product=asr&Version=2019-06-14&Action=SentenceRecognition&SignVersion=api3v3
# - name: TEST__请求主体
# value: "{{ data }}"
# - name: TEST__请求主体哈希
# value: "{{ util.hex(util.sha256(data)) }}"
# - name: TEST__待签名
# value: "{{request.method+'\\n'+request.path+'\\n'+'\\n'+'content-type:'+headers['Content-Type']+'\\nhost:'+request.host+'\\nx-tc-action:'+headers['X-TC-Action'].toLowerCase()+'\\n'+'\\n'+'content-type;host;x-tc-action'+'\\n'+util.hex(util.sha256(data))}}"
# - name: TEST__签名1
# value: "{{util.hex(util.sha256(request.method+'\\n'+request.path+'\\n'+'\\n'+'content-type:'+headers['Content-Type']+'\\nhost:'+request.host+'\\nx-tc-action:'+headers['X-TC-Action'].toLowerCase()+'\\n'+'\\n'+'content-type;host;x-tc-action'+'\\n'+util.hex(util.sha256(data))))}}"
# - name: TEST__拼接
# value: "{{'TC3-HMAC-SHA256\\n'+headers['X-TC-Timestamp']+'\\n'+util.toDate(headers['X-TC-Timestamp'])+'/'+config.service+'/tc3_request\\n'+util.hex(util.sha256(request.method+'\\n'+request.path+'\\n'+'\\n'+'content-type:'+headers['Content-Type']+'\\nhost:'+request.host+'\\nx-tc-action:'+headers['X-TC-Action'].toLowerCase()+'\\n'+'\\n'+'content-type;host;x-tc-action'+'\\n'+util.hex(util.sha256(data))))}}"
# - name: TEST__签名参数1_Key
# value: "{{ 'TC3'+config.secretKey }}"
# - name: TEST__签名参数2_Date
# value: "{{ util.toDate(headers['X-TC-Timestamp']) }}"
# - name: TEST__签名参数3_service
# value: "{{ config.service }}"
# - name: TEST__签名参数4_tc3_request
# value: "{{ 'tc3_request' }}"
# - name: TEST__计算签名
# value: "{{ util.hex( util.hmacSHA256( util.hmacSHA256(util.hmacSHA256(util.hmacSHA256('TC3'+config.secretKey, util.toDate(headers['X-TC-Timestamp'])), config.service), 'tc3_request'), 'TC3-HMAC-SHA256\\n'+headers['X-TC-Timestamp']+'\\n'+util.toDate(headers['X-TC-Timestamp'])+'/'+config.service+'/tc3_request\\n'+util.hex(util.sha256(request.method+'\\n'+request.path+'\\n'+'\\n'+'content-type:'+headers['Content-Type']+'\\nhost:'+request.host+'\\nx-tc-action:'+headers['X-TC-Action'].toLowerCase()+'\\n'+'\\n'+'content-type;host;x-tc-action'+'\\n'+util.hex(util.sha256(data)))) ) ) }}"
- name: Authorization
value: "TC3-HMAC-SHA256 Credential={{config.secretId}}/{{util.toDate(headers['X-TC-Timestamp'])}}/{{config.service}}/tc3_request, SignedHeaders=content-type;host;x-tc-action, Signature={{ util.hex( util.hmacSHA256( util.hmacSHA256(util.hmacSHA256(util.hmacSHA256('TC3'+config.secretKey, util.toDate(headers['X-TC-Timestamp'])), config.service), 'tc3_request'), 'TC3-HMAC-SHA256\\n'+headers['X-TC-Timestamp']+'\\n'+util.toDate(headers['X-TC-Timestamp'])+'/'+config.service+'/tc3_request\\n'+util.hex(util.sha256(request.method+'\\n'+request.path+'\\n'+'\\n'+'content-type:'+headers['Content-Type']+'\\nhost:'+request.host+'\\nx-tc-action:'+headers['X-TC-Action'].toLowerCase()+'\\n'+'\\n'+'content-type;host;x-tc-action'+'\\n'+util.hex(util.sha256(data)) )) ) ) }}"
desc: 签名认证信息
requestParams:
- name: EngSerViceType
type: string
required: true
value: 8k_zh
desc: 引擎模型类型。电话场景8k_zh中文电话通用、8k_en英文电话通用非电话场景16k_zh中文通用、16k_zh-PY中英粤、16k_zh_medical中文医疗、16k_en英语、16k_yue粤语、16k_ja日语、16k_ko韩语、16k_vi越南语、16k_ms马来语、16k_id印度尼西亚语、16k_fil菲律宾语、16k_th泰语、16k_pt葡萄牙语、16k_tr土耳其语、16k_ar阿拉伯语、16k_es西班牙语、16k_hi印地语、16k_fr法语、16k_de德语、16k_zh_dialect多方言支持23种方言
- name: SourceType
type: number
required: true
value: 1
desc: 语音数据来源。0语音URL1语音数据post body
- name: VoiceFormat
type: string
required: true
desc: 识别音频的音频格式支持wav、pcm、ogg-opus、speex、silk、mp3、m4a、aac、amr
- name: Url
type: string
required: false
desc: 语音的URL地址需要公网环境浏览器可下载。当SourceType值为0时须填写该字段为1时不填。音频时长不能超过60s音频文件大小不能超过3MB。推荐使用腾讯云COS来存储音频、生成URL并提交请求
- name: Data
type: string
required: true
desc: 语音数据当SourceType值为1本地语音数据上传时必须填写当SourceType值为0语音URL上传可不写。要使用base64编码采用python语言时注意读取文件应该为string而不是byte以byte格式读取后要decode()。编码后的数据不可带有回车换行符。音频时长不能超过60s音频文件大小不能超过3MBBase64后
- name: DataLen
type: number
required: false
value: "{{util.unBase64(params.Data).length}}"
desc: 数据长度单位为字节。当SourceType值为1本地语音数据上传时必须填写当SourceType值为0语音URL上传可不写此数据长度为数据未进行base64编码时的数据长度
- name: WordInfo
type: number
required: false
value: 0
desc: 是否显示词级别时间戳。0不显示1显示不包含标点时间戳2显示包含标点时间戳。默认值为0
- name: FilterDirty
type: number
required: false
value: 0
desc: 是否过滤脏词目前支持中文普通话引擎。0不过滤脏词1过滤脏词2将脏词替换为*。默认值为0
- name: FilterModal
type: number
required: false
value: 0
desc: 是否过滤语气词目前支持中文普通话引擎。0不过滤语气词1部分过滤2严格过滤。默认值为0
- name: FilterPunc
type: number
required: false
value: 0
desc: 是否过滤标点符号目前支持中文普通话引擎。0不过滤1过滤句末标点2过滤所有标点。默认值为0
- name: ConvertNumMode
type: number
required: false
value: 1
desc: 是否进行阿拉伯数字智能转换。0不转换直接输出中文数字1根据场景智能转换为阿拉伯数字。默认值为1
- name: HotwordId
type: string
required: false
desc: 热词id。用于调用对应的热词表如果在调用语音识别服务时不进行单独的热词id设置自动生效默认热词如果进行了单独的热词id设置那么将生效单独设置的热词id
- name: CustomizationId
type: string
required: false
value: ""
desc: 自学习模型id。如设置了该参数将生效对应的自学习模型
- name: HotwordList
type: string
required: false
desc: 临时热词表:该参数用于提升识别准确率。单个热词限制:"热词|权重"单个热词不超过30个字符最多10个汉字权重1-11或者100临时热词表限制多个热词用英文逗号分割最多支持128个热词。参数hotword_list临时热词表与hotword_id热词表区别hotword_id热词表。需要先在控制台或接口创建热词表获得对应hotword_id传入参数来使用热词功能hotword_list临时热词表。每次请求时直接传入临时热词表来使用热词功能云端不保留临时热词表。适用于有极大量热词需求的用户。如果同时传入了hotword_id和hotword_list会优先使用hotword_list
- name: InputSampleRate
type: number
required: false
value: 0
desc: 支持pcm格式的8k音频在与引擎采样率不匹配的情况下升采样到16k后识别能有效提升识别准确率。仅支持8000。如传入8000则pcm音频采样率为8k当引擎选用16k_zh那么该8k采样率的pcm音频可以在16k_zh引擎下正常识别。注此参数仅适用于pcm格式音频不传入值将维持默认状态即默认调用的引擎采样率等于pcm音频采样率
response:
- name: Response
type: Object
desc: 识别结果
sub:
- name: Result
type: string
desc: 识别结果
- name: AudioDuration
type: number
desc: 请求的音频时长单位为ms
- name: WordSize
type: number
desc: 词时间戳列表的长度
- name: WordList
type: string[]
desc: 词时间戳列表
- name: RequestId
type: string
desc: 唯一请求ID由服务端生成每次请求都会返回若请求因其他原因未能抵达服务端则该次请求不会获得RequestId。定位问题时需要提供该次请求的RequestId
successBy: "!result.Response.Error"
failedMessageBy: result.Response.Error.Message
longAsr:
config:
endpoint: https://asr.cloud.tencent.com
desc: 本接口支持使用者通过HTTPS POST方式上传一段音频并在极短时间内同步返回识别结果通常30分钟音频可在10秒内完成识别可满足音视频字幕、准实时质检等场景下对语音文件识别时效性的要求。
method: POST
path: /asr/flash/v1/{{config.appId}}
requestFormat: binary
responseFormat: json
requestHeaders:
- name: Content-Type
value: application/octet-stream
desc: 固定值为application/octet-stream
# - name: TEST__签名参数
# value: "{{request.method+request.host+request.fullPath}}"
# - name: TEST__生成签名
# value: "{{ util.base64(util.hmacSHA1(config.secretKey, request.method+request.host+request.fullPath)) }}"
- name: Authorization
value: "{{ util.base64(util.hmacSHA1(config.secretKey, request.method+request.host+request.fullPath)) }}"
desc: 用户的签名字符串,用于鉴权
requestParams:
- name: secretid
type: string
required: true
value: "{{config.secretId}}"
desc: 用户的secretId用于鉴权
- name: engine_type
type: string
required: true
value: 16k_zh
desc: 引擎模型类型16k_zh中文通用16k_zh_large普方英大模型引擎16k_multi_lang多语种大模型引擎16k_zh_dialect多方言等详见https://cloud.tencent.com/document/product/1093/52097#sign
- name: voice_format
type: string
required: true
value:
desc: 音频格式支持wav、pcm、ogg-opus、speex、silk、mp3、m4a、aac、amr
- name: timestamp
type: number
required: true
value: "{{util.timestamp()}}"
desc: 当前UNIX时间戳如果与当前时间相差超过3分钟会报签名失败错误
- name: speaker_diarization
type: number
required: false
value: 0
desc: 是否开启说话人分离目前支持中文普通话引擎默认为00不开启1开启
- name: hotword_id
type: string
required: false
value:
desc: 热词表id。如不设置该参数自动生效默认热词表如设置了该参数那么将生效对应的热词表
- name: customization_id
type: string
required: false
value:
desc: 自学习模型id。如设置了该参数将生效对应的自学习模型
- name: filter_dirty
type: number
required: false
value: 0
desc: 是否过滤脏词目前支持中文普通话引擎默认为0。0不过滤脏词1过滤脏词2将脏词替换为*
- name: filter_modal
type: number
required: false
value: 0
desc: 是否过滤语气词目前支持中文普通话引擎默认为0。0不过滤语气词1部分过滤2严格过滤
- name: filter_punc
type: number
required: false
value: 0
desc: 是否过滤标点符号目前支持中文普通话引擎默认为0。0不过滤1过滤句末标点2过滤所有标点
- name: convert_num_mode
type: number
required: false
value: 1
desc: 是否进行阿拉伯数字智能转换默认为1。0全部转为中文数字1根据场景智能转换为阿拉伯数字
- name: word_info
type: number
required: false
value: 0
desc: 是否显示词级别时间戳默认为0。0不显示1显示词时间戳不包含标点2显示词时间戳包含标点3标点符号分段包含每段时间戳特别适用于字幕场景包含词级时间、标点、语速值
- name: first_channel_only
type: number
required: false
value: 1
desc: 是否只识别首个声道默认为1。0识别所有声道1识别首个声道
- name: sentence_max_length
type: number
required: false
value: 0
desc: 单标点最多字数,取值范围:[640]。默认为0不开启该功能。该参数可用于字幕生成场景控制单行字幕最大字数。仅支持8k_zh、16k_zh引擎
- name: hotword_list
type: string
required: false
value:
desc: 临时热词表:该参数用于提升识别准确率。单个热词限制:"热词|权重"单个热词不超过30个字符最多10个汉字权重[1-11]或者100“腾讯云|5”或“ASR|11”临时热词表限制多个热词用英文逗号分割最多支持128个热词“腾讯云|10,语音识别|5,ASR|11”
- name: input_sample_rate
type: number
required: false
value:
desc: 支持pcm格式的8k音频在与引擎采样率不匹配的情况下升采样到16k后识别能有效提升识别准确率。仅支持8000
response:
- name: code
type: number
desc: 0:正常,其他,发生错误
- name: message
type: string
desc: code非0时message中会有错误消息
- name: request_id
type: string
desc: 请求唯一标识,请您记录该值,以便排查错误
- name: audio_duration
type: number
desc: 音频时长,单位为毫秒
- name: flash_result
type: Array
desc: 声道识别结果列表
sub:
- name: channel_id
type: number
desc: 声道标识从0开始对应音频声道数
- name: text
type: string
desc: 声道音频完整识别结果
- name: sentence_list
type: Array
desc: 句子/段落级别的识别结果列表
sub:
- name: text
type: string
desc: 句子/段落的识别结果
- name: start_time
type: number
desc: 句子/段落的起始时间
- name: end_time
type: number
desc: 句子/段落的结束时间
- name: speaker_id
type: number
desc: 说话人 Id请求中如果设置了 speaker_diarization可以按照 speaker_id 来区分说话人)
- name: word_list
type: Array
desc: 词级别的识别结果列表
sub:
- name: word
type: string
desc: 词的识别结果
- name: start_time
type: number
desc: 词的起始时间
- name: end_time
type: number
desc: 词的结束时间
successBy: result.code == 0
failedMessageBy: result.message
tts:
config:
endpoint: wss://tts.cloud.tencent.com
desc: 将请求文本合成为音频,同步返回合成音频数据及相关文本信息,达到“边合成边播放”的效果
method: WS
path: /stream_ws
requestFormat: query
responseFormat: json
requestParams:
- name: Action
type: string
required: true
value: TextToStreamAudioWS
desc: 调用接口名取值为TextToStreamAudioWS
- name: AppId
type: number
required: true
value: "{{config.appId}}"
desc: 账号AppId请确保该字段数据类型为整型int
- name: SecretId
type: string
required: true
value: "{{config.secretId}}"
desc: 腾讯云注册账号的密钥SecretId
- name: Timestamp
type: number
required: true
value: "{{util.timestamp()}}"
desc: 当前UNIX时间戳可记录发起API请求的时间
- name: Expired
type: number
required: true
value: "{{util.timestamp()+86400}}"
desc: 签名的有效期是一个符合UNIX Epoch时间戳规范的数值单位为秒Expired必须大于Timestamp且Expired-Timestamp小于90天
- name: SessionId
type: string
required: true
value: "{{util.uniqueId()}}"
desc: 语音合成全局唯一标识一个websocket连接对应一个用户自己生成推荐使用uuid最长128位
- name: Text
type: string
required: true
value:
desc: 合成语音的源文本按UTF-8编码统一计算。中文最大支持600个汉字全角标点符号算一个汉字英文最大支持1800个字母半角标点符号算一个字母
- name: VoiceType
type: number
required: true
value:
desc: 音色ID包括标准音色与精品音色精品音色拟真度更高价格不同于标准音色请参见购买指南。完整的音色ID列表请参见音色列表 https://cloud.tencent.com/document/product/1073/92668#55924b56-1a73-4663-a7a1-a8dd82d6e823
- name: Volume
type: number
required: false
value: 0
desc: 音量大小,范围[-1010]对应音量大小。默认为0代表正常音量值越大音量越高
- name: Speed
type: number
required: false
value: 0
desc: 语速,范围:[-26],分别对应不同语速
- name: SampleRate
type: number
required: false
value: 16000
desc: 音频采样率
- name: Codec
type: string
required: false
value: mp3
desc: 返回音频格式
- name: EnableSubtitle
type: boolean
required: false
value: true
desc: 是否开启时间戳功能默认为false
- name: EmotionCategory
type: string
required: false
value: neutral
desc: 控制合成音频的情感,仅支持多情感音色使用
- name: EmotionIntensity
type: number
required: false
value: 100
desc: 控制合成音频情感程度,取值范围为 [50,200]默认为100只有EmotionCategory不为空时生效
- name: SegmentRate
type: number
required: false
value: 0
desc: 断句敏感阈值,取值范围:[0,1,2]默认值0
- name: FastVoiceType
type: string
required: false
value:
desc: 一句话版声音复刻音色ID使用一句话版声音复刻音色时需填写
# - name: TEST__签名参数
# value: "{{'GET'+request.host+request.rawPath+'?'+request.sortJoin()}}"
# - name: TEST__生成签名
# value: "{{ util.base64(util.hmacSHA1(config.secretKey, 'GET'+request.host+request.rawPath+'?'+request.sortJoin())) }}"
- name: Signature
type: string
value: "{{ util.base64(util.hmacSHA1(config.secretKey, 'GET'+request.host+request.rawPath+'?'+request.sortJoin())) }}"
desc: 接口签名参数
response:
- name: code
type: number
desc: 状态码0代表正常非0值表示发生错误
- name: message
type: string
desc: 错误说明,发生错误时显示这个错误发生的具体原因,随着业务发展或体验优化,此文本可能会经常保持变更或更新
- name: session_id
type: string
desc: 音频流唯一id由客户端在握手阶段生成并赋值在调用参数中
- name: request_id
type: string
desc: 音频流唯一id由服务端在握手阶段自动生成
- name: message_id
type: string
desc: 本message唯一id
- name: final
type: number
desc: 该字段返回1时表示文本全部合成结束客户端收到后需主动关闭websocket连接
- name: result
type: Array
desc: 最新语音合成文本结果
sub:
- name: subtitles
type: array
desc: 当前一段话的词列表
sub:
- name: Text
type: string
desc: 该字的内容
- name: BeginTime
type: number
desc: 该字在整个音频流中的起始时间
- name: EndTime
type: number
desc: 该字在整个音频流中的结束时间
- name: BeginIndex
type: number
desc: 该字在整个文本中的开始位置从0开始
- name: EndIndex
type: number
desc: 该字在整个文本中的结束位置从0开始
- name: Phoneme
type: string
desc: 该字的音素注意此字段可能返回null表示取不到有效值
successBy: result.code == 0
failedMessageBy: result.message

200
tests/api/xunfei.yml Normal file
View File

@ -0,0 +1,200 @@
config:
endpoint: https://raasr.xfyun.cn/v2/api
appId:
secretKey:
apiConfig:
requestParams:
- name: appId
type: string
required: true
value: "{{config.appId}}"
desc: 应用ID从配置中获取
- name: ts
type: string
required: true
value: "{{Math.round(new Date().getTime()/1000)}}"
desc: 时间戳,单位为秒
- name: signa
type: string
required: true
value: "{{util.base64(util.hmacSHA1(config.secretKey,util.hex(util.md5(config.appId+params.ts))))}}"
desc: 时间戳,单位为秒
api:
- name: upload
desc: 首先调用文件上传接口上传待转写音频文件的基本信息文件名、大小等和相关的可配置参数。调用成功返回订单IDorderId用于查询结果或者联调排查问题时使用
method: POST
path: /upload
timeout: 60000
requestFormat: binary
responseFormat: json
requestParams:
- name: fileName
type: string
required: true
value:
desc: 音频文件名称,最好携带音频真实的后缀名,避免影响转码
- name: fileSize
type: number
required: true
value:
desc: 音频文件大小字节数当前只针对本地文件流方式校验使用url外链方式不校验可随机传一个数字。传递真实的音频文件大小音频流模式服务端会根据这个参数和实际获取到的进行比较不一致可能是文件丢失直接导致创建订单失败
- name: duration
type: number
required: false
value:
desc: 音频真实时长.当前未验证,可随机传一个数字
- name: language
type: string
required: false
value: cn
desc: 语种类型默认cn。cn中文通用方言包括普通话、天津、河北、东北、甘肃、山东、太原en英文ja日语ko韩语ru俄语fr法语es西班牙语vi越南语ar阿拉伯语cn_xinanese西南官话包括四川、重庆、云南、贵州cn_cantonese粤语cn_henanese河南cn_uyghur维吾尔语cn_tibetan藏语ar:阿拉伯语de:德语it:意大利语
- name: callbackUrl
type: string
required: false
value:
desc: "回调地址订单完成时回调该地址通知完成支持get 请求我们会在回调地址中拼接参数长度限制512 http://{ip}/{port}?xxx&OrderId=xxxx&status=1参数orderId为订单号、status为订单状态: 1(转写识别成功) 、-1(转写识别失败)"
- name: hotWord
type: string
required: false
value:
desc: 热词用以提升专业词汇的识别率。格式热词1|热词2|热词3。单个热词长度 [2,16] ,格式要求正则:[\uD800\uDC00-\uDBFF\uDFFF\uD800-\uDFFF]热词个数限制200个
- name: candidate
type: number
required: false
value:
desc: 多候选开关。0关闭 (默认)1打开
- name: roleType
type: number
required: false
value:
desc: 是否开启角色分离。0不开启角色分离(默认)1通用角色分离
- name: roleNum
type: number
required: false
value:
desc: 说话人数取值范围0-10默认为0进行盲分。注该字段只有在开通了角色分离功能的前提下才会生效正确传入该参数后角色分离效果会有所提升
- name: pd
type: string
required: false
value:
desc: 领域个性化参数。court法律edu教育finance金融medical医疗tech科技culture人文历史isp运营商sport体育gov政府game游戏ecom电商mil军事com企业life生活ent娱乐car汽车
- name: audioMode
type: string
required: false
value: fileStream
desc: 转写音频上传方式。fileStream文件流 (默认)urlLink音频url外链
- name: audioUrl
type: string
required: false
value:
desc: 音频url外链地址。当audioMode为urlLink时该值必传如果url中包含特殊字符audioUrl需要UrlEncode(不包含签名时需要的 UrlEncode)长度限制512
- name: standardWav
type: number
required: false
value:
desc: 是否标准pcm/wav(16k/16bit/单声道)。0非标准 wav (默认)1标准pcm/wav
- name: languageType
type: number
required: false
value:
desc: 语言识别模式选择支持的语言识别模式选择如下language为cn时1自动中英文模式 (默认)2:中文模式可能包含少量英文4:品中文模式(不包含英文)
- name: trackMode
type: number
required: false
value:
desc: 按声道分轨转写模式支持语种cn、en1-不分轨模式 (默认)2-双声道分轨模式。备注如果转写任务使用双声道分轨模式角色分离roleType功能失效。转写结果字段请参考getResult接口协议定义字段
- name: transLanguage
type: string
required: false
value:
desc: 需要翻译的语种(转写语种和翻译语种不能相同)。支持的语种请参考语种支持
- name: transMode
type: number
required: false
value:
desc: 翻译模式默认2按段落进行翻译目前只支持按段落进行翻译使用翻译能力时该字段生效。1按VAD进行翻译2品段落进行翻译3按整篇进行翻译
- name: eng_seg_max
type: number
required: false
value:
desc: 控制分段的最大字数,取值范围[0-500],不传使用引擎默认值
- name: eng_seg_min
type: number
required: false
value:
desc: 控制分段的最小字数,取值范围[0-50],不传使用引擎默认值
- name: eng_seg_weight
type: number
required: false
value:
desc: 控制分段字数的权重权重比越高表示引擎分段逻辑采用字数控制分段的比重越高。取值0-0.05)不传即不采用字数来控制分段,采用引擎默认分段逻辑
- name: eng_smoothproc
type: boolean
required: false
value:
desc: 顺滑开关。true表示开启 (默认)false表示关闭
- name: eng_colloqproc
type: boolean
required: false
value:
desc: 口语规整开关口语规整是顺滑的升级版本。true表示开启false表示关闭 (默认)
- name: eng_vad_mdn
type: number
required: false
value:
desc: 远近场模式。1远场模式 (默认)2近场模式
- name: eng_vad_margin
type: number
required: false
value:
desc: 首尾是否带静音信息。0不显示1显示 (默认)
- name: eng_rlang
type: number
required: false
value:
desc: 针对粤语转写后的字体转换。0输出简体1输出繁体 (默认)
requestHeaders:
- name: Content-Type
value: application/octet-stream
desc:
response:
- name: code
type: string
desc: 响应状态码
- name: descInfo
type: string
desc: 响应描述信息
- name: content
type: Object
desc: 包含订单ID和任务预估时间等信息
successBy: response.code == '000000'
failedMessageBy: response.descInfo
- name: getUploadResult
desc: 根据实际应用场景该接口调用分两种场景:正常场景:转写订单按时完成会返回订单号和对应的状态,如果成功,集成方在接收到我们回调成功的请求后调用该接口查询转写结果。异常场景:即我们回调集成方超时或失败后(我们有重试几次的机制)或者服务订单有积压,按照转写耗时的评估表在订单创建成功后超过预计的转写耗时后可主动调用该接口查询结果,可以考虑采用分梯度间 隔时间查询(避免一直轮询导致其他正常订单结果的查询,因为我们对接口查询频率有限制)
method: GET
path: /getResult
requestFormat: json
responseFormat: json
requestParams:
- name: orderId
type: string
required: true
value:
desc: 非实时转写订单号
- name: resultType
type: string
required: false
value: transfer
desc: 查询结果类型默认返回转写结果。转写结果transfer翻译结果translate质检结果predict组合结果查询多个类型结果使用”,”隔开,目前只支持转写和质检结果一起返回,不家支持转写和翻译结果一起返回(如果任务有失败则只返回处理成功的成果)
response:
- name: code
type: string
desc: 响应状态码
- name: descInfo
type: string
desc: 响应描述信息
- name: content
type: Object
desc: 包含订单信息、订单结果、任务预估时间等信息
successBy: response.code == '000000'
failedMessageBy: response.descInfo

28
tests/apigo.yml Normal file
View File

@ -0,0 +1,28 @@
name:
version: v0.0.1
main: main.js
target:
darwin: amd64 arm64
linux: amd64 arm64
windows: amd64
module:
apigo.cc/gojs/console: latest
apigo.cc/gojs/util: latest
apigo.cc/gojs/log: latest
apigo.cc/gojs/file: latest
apigo.cc/gojs/http: latest
apigo.cc/gojs/db: latest
modulealias:
extraimport:
cachefile:
sskey:
cgoenable: false
buildfrom:
buildenv:
linux:
darwin:
windows:
buildldflags:
linux:
darwin:
windows:

43
tests/go.mod Normal file
View File

@ -0,0 +1,43 @@
module tests
go 1.18
require (
apigo.cc/cloud v0.0.0
apigo.cc/gojs v0.0.4
apigo.cc/gojs/console v0.0.2
apigo.cc/gojs/db v0.0.3
apigo.cc/gojs/file v0.0.2
apigo.cc/gojs/http v0.0.3
apigo.cc/gojs/log v0.0.1
apigo.cc/gojs/util v0.0.5
github.com/ssgo/log v1.7.7
github.com/ssgo/u v1.7.11
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/ZZMarquis/gm v1.3.2 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/emmansun/gmsm v0.29.2 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect
github.com/ssgo/config v1.7.9 // indirect
github.com/ssgo/dao v0.1.5 // indirect
github.com/ssgo/db v1.7.11 // indirect
github.com/ssgo/httpclient v1.7.8 // indirect
github.com/ssgo/standard v1.7.7 // indirect
github.com/ssgo/tool v0.4.27 // indirect
golang.org/x/crypto v0.29.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace apigo.cc/cloud v0.0.0 => ../

280
tests/main.go Normal file
View File

@ -0,0 +1,280 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"apigo.cc/gojs"
_ "apigo.cc/cloud"
_ "apigo.cc/gojs/console"
_ "apigo.cc/gojs/db"
_ "apigo.cc/gojs/file"
_ "apigo.cc/gojs/http"
_ "apigo.cc/gojs/log"
_ "apigo.cc/gojs/util"
"github.com/ssgo/log"
"github.com/ssgo/u"
)
var _mainFile = "main.js"
var appName = "tests"
var appVersion = "v0.0.1"
func init() {
gojs.Alias("cloud", "apigo.cc/cloud")
gojs.Alias("console", "apigo.cc/gojs/console")
gojs.Alias("db", "apigo.cc/gojs/db")
gojs.Alias("file", "apigo.cc/gojs/file")
gojs.Alias("http", "apigo.cc/gojs/http")
gojs.Alias("log", "apigo.cc/gojs/log")
gojs.Alias("util", "apigo.cc/gojs/util")
}
var idFixMatcher = regexp.MustCompile(`[^a-zA-Z0-9_]`)
var testCaseOnStartMatcher = regexp.MustCompile(`(?m)^\s*function\s+onStart\s*\(`)
var testCaseOnStopMatcher = regexp.MustCompile(`(?m)^\s*function\s+onStop\s*\(`)
var testCaseMatcher = regexp.MustCompile(`(?m)^\s*function\s+test_(\w+)\s*\(`)
func setSSKey(key, iv []byte) {
gojs.SetSSKey(key, iv)
}
func main() {
if appName == "" {
appName = filepath.Base(os.Args[0])
appName = strings.TrimSuffix(appName, ".exe")
}
if appVersion == "" {
appVersion = "0.0.1"
}
cmd := ""
id := ""
mainFile := _mainFile
isWatch := false
startArgs := make([]string, 0)
mainArgs := make([]any, 0)
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
switch arg {
case "--help", "-help", "-h":
printUsage()
return
case "--version", "-version", "-v":
fmt.Println(appName, appVersion)
return
case "--export", "-export":
fmt.Println(u.Cyan(gojs.ExportForDev()))
return
case "start", "stop", "restart", "reload", "test":
if len(mainArgs) == 0 && cmd == "" {
cmd = arg
} else {
mainArgs = append(mainArgs, arg)
}
case "--env", "-env", "-e":
i++
a := strings.SplitN(os.Args[i], "=", 2)
os.Setenv(a[0], a[1])
case "-id":
i++
id = strings.TrimSpace(os.Args[i])
case "-main":
i++
mainFile = strings.TrimSpace(os.Args[i])
startArgs = append(startArgs, "-main", mainFile)
case "-watch", "-w":
isWatch = true
startArgs = append(startArgs, arg)
default:
mainArgs = append(mainArgs, arg)
startArgs = append(startArgs, arg)
}
}
if cmd != "test" {
if !u.FileExists(mainFile) {
log.DefaultLogger.Error("main file not found", "mainFile", mainFile)
return
}
if id == "" {
id = fmt.Sprintf("%s_%s_%s", appName, mainFile, appVersion)
}
id = idFixMatcher.ReplaceAllString(id, "_")
}
homeDir, _ := os.UserHomeDir()
pidFile := filepath.Join(homeDir, ".pids", id+".pid")
pid := u.Int(u.ReadFileN(pidFile))
switch cmd {
case "start":
if pid > 0 && processIsExists(pid) {
log.DefaultLogger.Info("process already started", "pid", pid)
} else {
startProcess(pidFile, startArgs)
}
case "stop":
if pid > 0 {
killProcess(pid, mainFile)
_ = os.Remove(pidFile)
}
case "reload":
if pid > 0 {
kill(pid, syscall.Signal(0x1e))
}
case "restart":
if pid > 0 {
killProcess(pid, mainFile)
_ = os.Remove(pidFile)
}
startProcess(pidFile, startArgs)
case "test":
testPath := "."
if u.FileExists("tests") {
testPath = "tests"
}
if isWatch {
var wr *gojs.WatchRunner
wr, _ = gojs.Watch([]string{testPath}, []string{"js", "yml", "json"}, func(changes []string) {
if len(changes) == 1 && strings.HasSuffix(changes[0], "_test.js") {
runTest(changes[0], wr)
} else {
runAllTest(testPath, wr)
}
})
} else {
runAllTest(testPath, nil)
}
gojs.WaitAll()
default:
defer os.Remove(pidFile)
if isWatch {
gojs.WatchRun(mainFile, mainArgs...)
} else {
if r, err := gojs.RunFile(mainFile, mainArgs...); err != nil {
log.DefaultLogger.Error("run failed for "+mainFile, "err", err.Error())
} else if r != nil {
log.DefaultLogger.Error("run "+mainFile, "result", r)
}
}
gojs.WaitAll()
}
}
func runAllTest(testPath string, wr *gojs.WatchRunner) {
for _, f := range u.ReadDirN(testPath) {
if !f.IsDir && strings.HasSuffix(f.Name, "_test.js") {
runTest(f.Name, wr)
}
}
}
func runTest(filename string, wr *gojs.WatchRunner) {
vm := gojs.New()
if wr != nil {
wr.SetModuleLoader(vm)
}
if _, err := vm.RunFile(filename); err != nil {
fmt.Println(u.BRed("run "+filename), u.Red(err.Error()))
return
}
code := u.ReadFileN(filename)
if testCaseOnStartMatcher.MatchString(code) {
if _, err := vm.RunCode(`onStart()`); err != nil {
fmt.Println(u.Red(filename), u.BRed("onStart"), u.Red(err.Error()))
return
} else {
fmt.Println(u.Green(filename), u.BGreen("onStart"), u.Green("success"))
}
}
for _, m := range testCaseMatcher.FindAllStringSubmatch(code, 1000) {
testCaseName := m[1]
if r, err := vm.RunCode(testCaseName + "()"); err != nil {
fmt.Println(u.Red(filename), u.BRed(testCaseName), u.Red(err.Error()))
} else if r == true {
fmt.Println(u.Green(filename), u.BGreen(testCaseName), u.Green("pass"))
} else {
fmt.Println(u.Red(filename), u.BRed(testCaseName), u.Red(u.StringP(r)))
}
}
if testCaseOnStopMatcher.MatchString(code) {
if _, err := vm.RunCode(`onStop()`); err != nil {
fmt.Println(u.Red(filename), u.BRed("onStop"), u.Red(err.Error()))
} else {
fmt.Println(u.Green(filename), u.BGreen("onStop"), u.Green("success"))
}
}
}
func startProcess(pidFile string, startArgs []string) {
cmd := exec.Command(os.Args[0], startArgs...)
// cmd.Stdout = os.Stdout
// cmd.Stderr = os.Stderr
if err := cmd.Start(); err == nil {
u.WriteFile(pidFile, u.String(cmd.Process.Pid))
log.DefaultLogger.Info("started", "appName", appName, "appVersion", appVersion, "startArgs", startArgs, "pid", cmd.Process.Pid)
} else {
log.DefaultLogger.Error("start failed", "appName", appName, "appVersion", appVersion, "startArgs", startArgs, "err", err)
}
os.Exit(0)
}
func kill(pid int, sig os.Signal) error {
if proc, err := os.FindProcess(pid); err == nil {
return proc.Signal(sig)
} else {
return err
}
}
func killProcess(pid int, mainFile string) {
if err := kill(pid, syscall.SIGTERM); err == nil {
log.DefaultLogger.Info("killing", "appName", appName, "appVersion", appVersion, "mainFile", mainFile, "pid", pid)
t1 := time.Now().UnixMilli()
for i := 0; i < 100; i++ {
if !processIsExists(pid) {
log.DefaultLogger.Info("killed", "appName", appName, "appVersion", appVersion, "mainFile", mainFile, "pid", pid, "usedTime", (time.Duration(time.Now().UnixMilli()-t1) * time.Millisecond).String())
return
}
time.Sleep(100 * time.Millisecond)
}
err := kill(pid, syscall.SIGKILL)
if processIsExists(pid) {
log.DefaultLogger.Error("fource kill failed", "appName", appName, "appVersion", appVersion, "mainFile", mainFile, "pid", pid, "err", err)
} else {
log.DefaultLogger.Info("fource killed", "appName", appName, "appVersion", appVersion, "mainFile", mainFile, "pid", pid)
}
}
}
func processIsExists(pid int) bool {
if proc, err := os.FindProcess(pid); err == nil {
if err2 := proc.Signal(syscall.Signal(0)); err2 == nil {
return true
}
}
return false
}
func printUsage() {
fmt.Println(appName, appVersion)
fmt.Println("Usage:")
fmt.Println(" ", appName, "[-main mainFile] ...", "run script")
fmt.Println(" ", appName, "-version|-v", "show version")
fmt.Println(" ", appName, "-export", "export for development")
fmt.Println(" ", appName, "-env|-e", "set env")
fmt.Println(" ", appName, "start [-id id] [-main mainFile]", "start server")
fmt.Println(" ", appName, "stop [-id id]", "stop server")
fmt.Println(" ", appName, "restart [-id id] [-main mainFile]", "restart server")
fmt.Println(" ", appName, "reload [-id id] [-main mainFile]", "reload config")
fmt.Println(" ", appName, "test", "test *_test.js")
fmt.Println(" ", appName, "help", "- show help")
}

30
tests/main.js Normal file
View File

@ -0,0 +1,30 @@
import co from 'console'
import file from 'file'
import util from 'util'
import { tencent } from 'apigo.cc/cloud'
function main() {
// let i = 0
// let bufArr = []
// let r = tencent.tts('小语1+1=2 对吗?', 301029, (data, type) => {
// co.info(i, type, data.length)
// if (type == 'binary') {
// i++
// // file.write(i + '.mp3', new Uint8Array(data))
// bufArr = bufArr.concat(data)
// } else if (type == 'json') {
// co.info(data)
// if (data.final == 1) return true
// } else {
// co.warn(data)
// }
// })
// file.write('out.mp3', new Uint8Array(bufArr))
// util.shell('afplay', 'out.mp3')
let r = tencent.fastAsr('mp3', util.base64(file.readBytes('out.mp3')))
co.info(co.bGreen(r.success), co.bRed(r.failedMessage))
co.info(r)
}