diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa1afa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Project +examples/data +examples/**.yaml + +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Mac +.DS_Store +**/.DS_Store + +# VS Code +.vscode +*.project +*.factorypath + +# IntelliJ IDEA +.idea/* +!.idea/icon.png +*.iws +*.iml +*.ipr + +# Test binary +*.test + +# Output +*.out + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1143ae --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +# Aqi + +Aqi is a Golang Websocket business framework that supports net/http, gin, chi, etc. It integrates underlying third-party libraries such as viper, gorm, gobwa/ws, gjson, zap, asynq, which facilitates the rapid development of Websocket applications. + +### Installation + +`go get -u github.com/wonli/aqi` + +[简体中文](./docs/zh-CN.md) + +### Usage + +On the first run, a `config-dev.yaml` configuration file will be automatically generated in the working directory. You can configure the application start port, database, and other settings. + +After the service starts, use [wscat](https://github.com/websockets/wscat) to establish a websocket connection with the server. Here is a screenshot of the operation. + + + +![img](./docs/assets/img.png) + + + +### Interaction Protocol + +Input and output uniformly use `JSON`. Here, `Action` is the name registered in the routing, and `Params` are in JSON string format. + +```go + +type Context struct { + ... + Action string + Params string + ... +} +``` + + + +Response Format: + +```go +type Action struct { + Action string `json:"action"` + + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Data any `json:"data,omitempty"` +} +``` + + + +### Quick Start + +In `ahi.Init`, specify the configuration file via `aqi.ConfigFile`, which defaults to `yaml` format. The service name and port are specified in the `yaml` file path through `aqi.HttpServer`. The contents of the entry file are as follows: + +```go +package main + +import ( + "net/http" + "time" + + "github.com/wonli/aqi" + "github.com/wonli/aqi/ws" +) + +func main() { + app := aqi.Init( + aqi.ConfigFile("config.yaml"), + aqi.HttpServer("Aqi", "port"), + ) + + // Create router + mux := http.NewServeMux() + // WebSocket Handler + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + ws.HttpHandler(w, r) + }) + + // Register router + wsr := ws.NewRouter() + wsr.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + app.WithHttpServer(mux) + + // 启动应用 + app.Start() +} +``` + + + +### With Gin + +`aqi` can be very conveniently integrated with other WEB frameworks. You just need to correctly register `handler` and `app.WithHttpServer`. It supports any implementation of `http.Handler`. + + + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/wonli/aqi" + "github.com/wonli/aqi/ws" +) + +func main() { + app := aqi.Init( + aqi.ConfigFile("config.yaml"), + aqi.HttpServer("Aqi", "port"), + ) + + engine := gin.Default() + // Handler + engine.GET("/ws", func(c *gin.Context) { + ws.HttpHandler(c.Writer, c.Request) + }) + + // Router + wsr := ws.NewRouter() + wsr.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + app.WithHttpServer(engine) + app.Start() +} +``` + + + +### Middlewares + +First, define a simple logging middleware that prints the received `action` before processing the request and prints the response content after processing. + +```go +func logMiddleware() func(a *ws.Context) { + return func(a *ws.Context) { + log.Printf("Request action: %s ", a.Action) + a.Next() + log.Printf("Reqponse data: %s ", a.Response.Data) + } +} +``` + +Register the middleware to the router using the `Use` method. + +```go +// 注册WebSocket路由 +wsr := ws.NewRouter() +wsr.Use(logMiddleware()).Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) +}) +``` + +You can also use the form of router groups. + +```go +// Router +wsr := ws.NewRouter() +r1 := wsr.Use(logMiddleware()) +{ + r1.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + r1.Add("say", func(a *ws.Context) { + a.Send(ws.H{ + "say": "hi", + }) + }) +} +``` + +This way, the console will print logs before and after each request. + + + +### Production Mode + +Compiling `Aqi` directly will run in `dev` mode. To run in production mode, pass the following parameters during compilation. For more details, please refer to the `examples/Makefile` file. + + + +```shell +LDFLAGS = "-X '$(FLAGS_PKG).BuildDate=$(BUILD_DATE)' \ + -X '$(FLAGS_PKG).Branch=$(GIT_BRANCH)' \ + -X '$(FLAGS_PKG).CommitVersion=$(GIT_COMMIT)' \ + -X '$(FLAGS_PKG).Revision=$(GIT_REVISION)' \ + -extldflags '-static -s -w'" +``` + + + diff --git a/apic/api.go b/apic/api.go new file mode 100644 index 0000000..ca305d5 --- /dev/null +++ b/apic/api.go @@ -0,0 +1,24 @@ +package apic + +import ( + "context" + "net/url" +) + +// Api api client interface. +type Api interface { + Url() string // Request API full URL. + Path() string // Request path. + Query() url.Values // URL query parameters. + Headers() Params // Headers required for the request. + PostBody() Params // Request parameters. + FormData() Params // Form data as map[string]string. + WWWFormData() Params // Form data as map[string]string. + Setup(api Api, op *Options) (Api, error) // Setup for the API. + HttpMethod() HttpMethod // HTTP method of the request. + Debug() bool // Whether to run in debug mode. + UseContext(ctx context.Context) error // Use context. + OnRequest() error // Handle request data. + OnHttpStatusError(code int, resp []byte) error // Handle HTTP status errors. + OnResponse(resp []byte) (*ResponseData, error) // Process response data. +} diff --git a/apic/api_id.go b/apic/api_id.go new file mode 100644 index 0000000..39ac700 --- /dev/null +++ b/apic/api_id.go @@ -0,0 +1,14 @@ +package apic + +type ApiId struct { + Name string + Client Api + Request *RequestData + Response *ResponseData +} + +// Named registers as a named interface. +func (a *ApiId) Named() *ApiId { + Apis.Named(a) + return a +} diff --git a/apic/api_options.go b/apic/api_options.go new file mode 100644 index 0000000..0f75c87 --- /dev/null +++ b/apic/api_options.go @@ -0,0 +1,11 @@ +package apic + +import "net/url" + +// Options request options +type Options struct { + Query url.Values + PostBody Params + Headers Params + Setup Params +} diff --git a/apic/api_request.go b/apic/api_request.go new file mode 100644 index 0000000..eb3a37a --- /dev/null +++ b/apic/api_request.go @@ -0,0 +1,56 @@ +package apic + +import "net/url" + +type RequestData struct { + Url string `json:"url"` + HttpMethod HttpMethod `json:"httpMethod,omitempty"` + ApiId string `json:"apiId"` + Path string `json:"path,omitempty"` + Query url.Values `json:"query,omitempty"` + Form Params `json:"form,omitempty"` + WWWForm Params `json:"WWWForm,omitempty"` + PostBody Params `json:"post_body,omitempty"` + Header Params `json:"header,omitempty"` + Debug bool `json:"debug"` +} + +func (a *RequestData) InitFromApiClient(api Api) { + if a.Url == "" { + a.Url = api.Url() + } + + if a.Path == "" { + a.Path = api.Path() + } + + if a.HttpMethod == "" { + a.HttpMethod = api.HttpMethod() + } + + if a.Query == nil { + a.Query = api.Query() + } + + if a.PostBody == nil { + a.PostBody = api.PostBody() + } + + if a.Header == nil { + a.Header = api.Headers() + } + + if a.Form == nil { + a.Form = api.FormData() + } + + if a.WWWForm == nil { + a.WWWForm = api.WWWFormData() + } + + a.Debug = api.Debug() +} + +func (a *RequestData) MarshalToString() (string, error) { + return marshal(a) +} diff --git a/apic/api_response.go b/apic/api_response.go new file mode 100644 index 0000000..289d449 --- /dev/null +++ b/apic/api_response.go @@ -0,0 +1,26 @@ +package apic + +import ( + "encoding/json" + "net/http" +) + +type ResponseData struct { + HttpStatus int `json:"http_status"` + Header http.Header `json:"header,omitempty"` + Data []byte `json:"data,omitempty"` + Text string `json:"text,omitempty"` +} + +func (a *ResponseData) MarshalToString() (string, error) { + return marshal(a) +} + +func (a *ResponseData) BindJson(d any) error { + err := json.Unmarshal(a.Data, d) + if err != nil { + return err + } + + return nil +} diff --git a/apic/api_util.go b/apic/api_util.go new file mode 100644 index 0000000..40858a4 --- /dev/null +++ b/apic/api_util.go @@ -0,0 +1,12 @@ +package apic + +import "encoding/json" + +var marshal = func(a any) (string, error) { + data, err := json.Marshal(a) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/apic/apic.go b/apic/apic.go new file mode 100644 index 0000000..60698b1 --- /dev/null +++ b/apic/apic.go @@ -0,0 +1,89 @@ +package apic + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/wonli/aqi/logger" +) + +// Apic is an empty implementation of the Api interface. +// Introducing this in business logic can avoid writing too much boilerplate code. +type Apic struct { + Api +} + +func (a *Apic) Url() string { + return "" +} + +func (a *Apic) Path() string { + return "" +} + +func (a *Apic) Query() url.Values { + return nil +} + +func (a *Apic) Headers() Params { + return nil +} + +func (a *Apic) PostBody() Params { + return nil +} + +func (a *Apic) FormData() Params { + return nil +} + +func (a *Apic) WWWFormData() Params { + return nil +} + +func (a *Apic) Setup(api Api, op *Options) (Api, error) { + return api, nil +} + +func (a *Apic) HttpMethod() HttpMethod { + return POST +} + +func (a *Apic) Debug() bool { + return false +} + +func (a *Apic) UseContext(ctx context.Context) error { + return nil +} + +func (a *Apic) OnRequest() error { + return nil +} + +func (a *Apic) OnResponse(resp []byte) (*ResponseData, error) { + return &ResponseData{Data: resp}, nil +} + +func (a *Apic) OnHttpStatusError(code int, resp []byte) error { + return nil +} + +// AnyToParams converts any type to Params. +func (a *Apic) AnyToParams(d any) Params { + dByte, err := json.Marshal(d) + if err != nil { + logger.SugarLog.Errorf("Failed to convert type to byte %s", err.Error()) + return nil + } + + var p Params + err = json.Unmarshal(dByte, &p) + if err != nil { + logger.SugarLog.Errorf("Failed to convert to Params %s", err.Error()) + return nil + } + + return p +} diff --git a/apic/http_client.go b/apic/http_client.go new file mode 100644 index 0000000..bffb0c6 --- /dev/null +++ b/apic/http_client.go @@ -0,0 +1,230 @@ +package apic + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/guonaihong/gout" + "github.com/guonaihong/gout/dataflow" + "github.com/tidwall/gjson" +) + +var Apis *ApiClients +var once sync.Once + +// ApiClients api clients list +type ApiClients struct { + ctx context.Context + proxy string + named map[string]*ApiId +} + +func Init() *ApiClients { + once.Do(func() { + Apis = &ApiClients{ + ctx: context.Background(), + named: map[string]*ApiId{}, + } + }) + + return Apis +} + +func (a *ApiClients) Named(api *ApiId) { + _, ok := a.named[api.Name] + if ok { + panic("ApiId registered multiple times") + } + + a.named[api.Name] = api +} + +func (a *ApiClients) WithContext(ctx context.Context) *ApiClients { + a.ctx = ctx + return a +} + +func (a *ApiClients) WithProxy(proxy string) *ApiClients { + a.proxy = proxy + return a +} + +func (a *ApiClients) Call(id *ApiId, op *Options) error { + _, err := a.getApiData(id, op) + if err != nil { + return err + } + + return nil +} + +func (a *ApiClients) CallApi(id *ApiId, op *Options) (*ResponseData, error) { + return a.getApiData(id, op) +} + +func (a *ApiClients) CallNamed(name string, op *Options) (*ResponseData, error) { + id, ok := a.named[name] + if !ok { + return nil, fmt.Errorf("named api not registered") + } + + return a.getApiData(id, op) +} + +func (a *ApiClients) CallGJson(id *ApiId, op *Options) (*gjson.Result, error) { + apiData, err := a.getApiData(id, op) + if err != nil { + return nil, err + } + + g := gjson.ParseBytes(apiData.Data) + return &g, nil +} + +func (a *ApiClients) CallBindJson(id *ApiId, resp any, op *Options) error { + _, err := a.getApiData(id, op) + if err != nil { + return err + } + + return id.Response.BindJson(resp) +} + +func (a *ApiClients) CallFunc(id *ApiId, op *Options, callback func(a *Api, data []byte) error) error { + apiData, err := a.getApiData(id, op) + if err != nil { + return err + } + + return callback(&id.Client, apiData.Data) +} + +func (a *ApiClients) getApiData(id *ApiId, op *Options) (*ResponseData, error) { + api := id.Client + if id.Request == nil { + id.Request = &RequestData{} + } + + if op == nil { + op = &Options{} + } + + id.Request.ApiId = id.Name + id.Request.InitFromApiClient(id.Client) + + //setup + api, err := api.Setup(id.Client, op) + if err != nil { + return nil, err + } + + err = api.UseContext(a.ctx) + if err != nil { + return nil, err + } + + // Merge query parameters + if op.Query != nil { + if id.Request.Query == nil { + id.Request.Query = op.Query + } else { + for key, valData := range op.Query { + if len(valData) == 1 { + id.Request.Query.Add(key, valData[0]) + } else { + for _, val := range valData { + id.Request.Query.Add(key, val) + } + } + } + } + } + + //set postBody + if op.PostBody != nil { + if id.Request.PostBody == nil { + id.Request.PostBody = op.PostBody + } else { + for key, val := range op.PostBody { + id.Request.PostBody[key] = val + } + } + } + + //set header + if op.Headers != nil { + if id.Request.Header == nil { + id.Request.Header = op.Headers + } else { + for key, val := range op.Headers { + id.Request.Header[key] = val + } + } + } + + err = api.OnRequest() + if err != nil { + return nil, err + } + + var apiAddress = id.Request.Url + id.Request.Path + var client *dataflow.DataFlow + switch id.Request.HttpMethod { + case POST: + client = gout.POST(apiAddress) + case DELETE: + client = gout.DELETE(apiAddress) + case HEAD: + client = gout.HEAD(apiAddress) + case OPTIONS: + client = gout.OPTIONS(apiAddress) + case PATCH: + client = gout.OPTIONS(apiAddress) + default: + client = gout.GET(apiAddress) + } + + if a.proxy != "" { + client.SetProxy(a.proxy) + } + + client.Debug(id.Request.Debug) + if id.Request.Form != nil { + client.SetForm(id.Request.Form) + } else if id.Request.WWWForm != nil { + client.SetWWWForm(id.Request.WWWForm) + } else if id.Request.PostBody != nil { + client.SetJSON(id.Request.PostBody) + } + + if id.Request.Query != nil { + client.SetQuery(id.Request.Query) + } + + if id.Request.Header != nil { + client.SetHeader(id.Request.Header) + } + + id.Response = &ResponseData{} + err = client.Code(&id.Response.HttpStatus). + BindHeader(&id.Response.Header).BindBody(&id.Response.Data).Do() + if err != nil { + return nil, err + } + + if id.Response.HttpStatus != http.StatusOK { + err = api.OnHttpStatusError(id.Response.HttpStatus, id.Response.Data) + if err != nil { + return id.Response, err + } + } + + responseData, err := api.OnResponse(id.Response.Data) + if err != nil { + return nil, err + } + + return responseData, nil +} diff --git a/apic/http_method.go b/apic/http_method.go new file mode 100644 index 0000000..c78f215 --- /dev/null +++ b/apic/http_method.go @@ -0,0 +1,18 @@ +package apic + +import "net/http" + +// HttpMethod http method name +type HttpMethod string + +const ( + GET HttpMethod = http.MethodGet + HEAD HttpMethod = http.MethodHead + POST HttpMethod = http.MethodPost + PUT HttpMethod = http.MethodPut + PATCH HttpMethod = http.MethodPatch + DELETE HttpMethod = http.MethodDelete + CONNECT HttpMethod = http.MethodConnect + OPTIONS HttpMethod = http.MethodOptions + TRACE HttpMethod = http.MethodTrace +) diff --git a/apic/http_params.go b/apic/http_params.go new file mode 100644 index 0000000..7946f39 --- /dev/null +++ b/apic/http_params.go @@ -0,0 +1,30 @@ +package apic + +import ( + "encoding/json" +) + +// Params map[string]any +type Params map[string]any + +func (p Params) With(key string, val any) Params { + p[key] = val + return p +} + +func (p Params) WithParams(params map[string]any) Params { + for key, val := range params { + p[key] = val + } + + return p +} + +func (p Params) Marshal() []byte { + bytes, err := json.Marshal(p) + if err != nil { + return nil + } + + return bytes +} diff --git a/app.go b/app.go new file mode 100644 index 0000000..30a04ae --- /dev/null +++ b/app.go @@ -0,0 +1,209 @@ +package aqi + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + "github.com/fsnotify/fsnotify" + "github.com/spf13/viper" + + "github.com/wonli/aqi/config" + "github.com/wonli/aqi/logger" + "github.com/wonli/aqi/validate" +) + +type AppConfig struct { + + //运行时数据存储基础路径 + DataPath string + + //应用日志文件配置路径 + LogPathKey string + + //默认语言 + Language string + + //开发模式 + devMode bool + + //服务名称,support.Version + //当指定 HttpServerPortFindPath 时,在配置读取之后从配置路径获取http端口 + Servername []string + ServerPort string + HttpServerPortFindPath string + + ConfigType string //配置文件类型 + ConfigPath string //配置文件路径 + ConfigName string //配置文件名称 + + Configs map[string]any + + HttpServer http.Handler //http server + + RemoteProvider *RemoteProvider //远程配置支持etcd, consul + + WatchHandler func() +} + +var acf *AppConfig + +func Init(options ...Option) *AppConfig { + acf = &AppConfig{ + Language: "zh", + ConfigType: "yaml", + ConfigName: "config", + ServerPort: "1091", + LogPathKey: "log", + DataPath: "data", + } + + for _, opt := range options { + if opt != nil { + err := opt(acf) + if err != nil { + color.Red("error %s", err.Error()) + os.Exit(1) + } + } + } + + if acf.ConfigPath == "" { + workerDir, err := os.Getwd() + if err != nil { + color.Red("Failed to get the configuration file directory: %s", err.Error()) + os.Exit(1) + } + + acf.ConfigPath = workerDir + } + + if CommitVersion == "" { + acf.devMode = true + acf.ConfigName = fmt.Sprintf("%s-dev", acf.ConfigName) + } + + // 设置环境变量的前缀 + // 自动将环境变量绑定到 Viper 配置中 + viper.SetEnvPrefix("") + viper.AutomaticEnv() + + //设置配置文件 + viper.SetConfigName(acf.ConfigName) + viper.SetConfigType(acf.ConfigType) + + viper.AddConfigPath(acf.ConfigPath) + err := viper.ReadInConfig() + if err != nil { + if acf.RemoteProvider == nil { + err = acf.WriteDefaultConfig() + if err != nil { + color.Red("Error gen default config file: %s", err.Error()) + os.Exit(1) + } + + color.Red("failed to read config file: %s", err.Error()) + os.Exit(1) + } + + color.Red("Remote configuration will be used: %s", err.Error()) + } else { + acf.Configs = viper.AllSettings() + } + + if acf.LogPathKey == "" { + color.Red("Please specify LogPathKey") + os.Exit(1) + } + + isSetDevMode := viper.IsSet("devMode") + if isSetDevMode { + setDevModel := viper.GetBool("devMode") + acf.devMode = setDevModel + } + + viper.Set("devMode", acf.devMode) + if acf.RemoteProvider != nil { + _ = viper.AddRemoteProvider(string(acf.RemoteProvider.Name), acf.RemoteProvider.Endpoint, acf.RemoteProvider.Path) + viper.SetConfigType(acf.RemoteProvider.Type) + + err := viper.ReadRemoteConfig() + if err != nil { + color.Red("Failed to read remote config") + os.Exit(1) + } + + go func() { + t := time.NewTicker(time.Minute * 30) + for range t.C { + err2 := viper.WatchRemoteConfig() + if err2 != nil { + logger.SugarLog.Errorf("unable to read remote config: %v", err2) + continue + } + + if acf.WatchHandler != nil { + acf.WatchHandler() + } + } + }() + } + + //处理http服务端口信息 + if acf.HttpServerPortFindPath != "" { + port := viper.GetString(acf.HttpServerPortFindPath) + if port == "" { + port = acf.ServerPort + } + + if strings.Contains(port, ":") { + s := strings.Split(port, ":") + port = s[len(s)-1] + } + + acf.ServerPort = port + acf.Servername = append(acf.Servername, "is now running at http://0.0.0.0:"+port) + } + + //打印系统信息 + if acf.Servername != nil { + AsciiLogo(acf.Servername...) + } + + if CommitVersion == "" { + color.Green("dev mode -- use config %s", acf.ConfigName+"."+acf.ConfigType) + } + + var c config.Logger + err = viper.UnmarshalKey(acf.LogPathKey, &c) + if err != nil { + color.Red("failed to init app log") + os.Exit(1) + } + + if !filepath.IsAbs(c.LogPath) { + c.LogPath = acf.GetDataPath(c.LogPath) + } + + //初始化日志库 + logger.Init(c) + + //validate语言配置 + validate.InitTranslator(acf.Language) + + //配置文件更新回调 + viper.OnConfigChange(func(e fsnotify.Event) { + logger.SugarLog.Infof("config file changed: %s", e.Name) + if acf.WatchHandler != nil { + acf.WatchHandler() + } + }) + + //监听配置 + viper.WatchConfig() + return acf +} diff --git a/app_asciilogo.go b/app_asciilogo.go new file mode 100644 index 0000000..e65ab28 --- /dev/null +++ b/app_asciilogo.go @@ -0,0 +1,35 @@ +package aqi + +import ( + "strings" + "time" + + "github.com/fatih/color" +) + +var ( + Branch string + Revision string + BuildDate string + CommitVersion string +) + +var asciiLogo = ` +%s Started on %s + +██▀▄─██─▄▄▄─█▄─▄█ Branch : %s-%s +██─▀─██─██▀─██─██ Commit : %s +▀▄▄▀▄▄▀───▄▄▀▄▄▄▀ Build at : %s + +` + +func AsciiLogo(serverName ...string) { + color.Cyan(asciiLogo, + strings.TrimSpace(strings.Join(serverName, " ")), + time.Now().Format("2006-01-02 15:04:05"), + Branch, + Revision, + CommitVersion, + BuildDate, + ) +} diff --git a/app_config.go b/app_config.go new file mode 100644 index 0000000..57c811e --- /dev/null +++ b/app_config.go @@ -0,0 +1,40 @@ +package aqi + +import ( + "os" + "path/filepath" + + "github.com/fatih/color" + + "github.com/wonli/aqi/config" +) + +func (a *AppConfig) GetDataPath(dir string) string { + return filepath.Join(a.ConfigPath, a.DataPath, dir) +} + +func (a *AppConfig) IsDevMode() bool { + return a.devMode +} + +func (a *AppConfig) WriteDefaultConfig() error { + workerDir, err := os.Getwd() + if err != nil { + return err + } + + ctx, err := config.GetDefaultConfig() + if err != nil { + return err + } + + filename := filepath.Join(workerDir, a.ConfigName+"."+a.ConfigType) + err = os.WriteFile(filename, []byte(ctx), 0755) + if err != nil { + return err + } + + color.Green("Configuration file has been created: " + filename) + os.Exit(0) + return nil +} diff --git a/app_server.go b/app_server.go new file mode 100644 index 0000000..37bb47b --- /dev/null +++ b/app_server.go @@ -0,0 +1,34 @@ +package aqi + +import ( + "net/http" + "os" + + "github.com/fatih/color" + + "github.com/wonli/aqi/ws" +) + +func (a *AppConfig) WithHttpServer(svr http.Handler) { + a.HttpServer = svr +} + +func (a *AppConfig) Start() { + if a.HttpServer == nil { + color.Red("HttpServer not config") + os.Exit(0) + } + + if a.HttpServer != nil { + server := ws.NewServer(a.HttpServer) + server.SetDataPath(a.DataPath) + server.SetIsDev(a.devMode) + server.Init() + } + + err := http.ListenAndServe(":"+a.ServerPort, a.HttpServer) + if err != nil { + color.Red("Listener error: %s", err.Error()) + os.Exit(0) + } +} diff --git a/app_use.go b/app_use.go new file mode 100644 index 0000000..3914bcd --- /dev/null +++ b/app_use.go @@ -0,0 +1,11 @@ +package aqi + +type Aqi struct { + AppConfig *AppConfig +} + +func Use() *Aqi { + return &Aqi{ + AppConfig: acf, + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..bc561ba --- /dev/null +++ b/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "bytes" + "crypto/rand" + _ "embed" + "encoding/hex" + "html/template" +) + +//go:embed config.yaml +var defaultConfig []byte + +// DefaultConfigTpl store template data. +type DefaultConfigTpl struct { + JwtSecurity string +} + +func generateRandomHex(n int) (string, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func GetDefaultConfig() (string, error) { + jwtSecurity, err := generateRandomHex(16) + if err != nil { + return "", err + } + + config := DefaultConfigTpl{ + JwtSecurity: jwtSecurity, + } + + tmpl, err := template.New("config").Parse(string(defaultConfig)) + if err != nil { + return "", err + } + + var renderedConfig bytes.Buffer + err = tmpl.Execute(&renderedConfig, config) + if err != nil { + return "", err + } + + return renderedConfig.String(), nil +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..74cd765 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,36 @@ +port: 2015 +devMode: true +jwtSecurity: {{.JwtSecurity}} +jwtLifetime: 30d +log: + logFile: error.log + logPath: logs + maxSize: 200 + maxBackups: 3 + maxAge: 30 + compress: true + useCaller: true +redis: + store: + addr: 127.0.0.1:6379 + username: "" + pwd: "" + db: 1 + minIdleConns: 10 + idleTimeout: 5m0s +mysql: + logic: + host: 127.0.0.1 + port: 3306 + user: root + password: 123456 + database: test + prefix: t_ + idle: 10 + idleTime: 1h0m0s + maxLifetime: 1h0m0s + heartBeatTime: 30s + LogLevel: 4 + active: 50 + maxOpen: 20 + enable: 1 \ No newline at end of file diff --git a/config/config_dialog.go b/config/config_dialog.go new file mode 100644 index 0000000..38d4c94 --- /dev/null +++ b/config/config_dialog.go @@ -0,0 +1,11 @@ +package config + +import "time" + +type Dialog struct { + OpInterval time.Duration `yaml:"opInterval" json:"opInterval,omitempty"` // Interval for sending op messages + IdleInterval time.Duration `yaml:"idleInterval" json:"idleInterval,omitempty"` // Interval for inserting system time in the session list + SessionExpire time.Duration `yaml:"sessionExpire" json:"sessionExpire,omitempty"` // Session expiration duration + GuardInterval time.Duration `yaml:"guardInterval" json:"guardInterval,omitempty"` // Scan interval duration + AssignInterval time.Duration `yaml:"assignInterval" json:"assignInterval,omitempty"` // Assignment interval time +} diff --git a/config/config_logger.go b/config/config_logger.go new file mode 100644 index 0000000..b3cb0fa --- /dev/null +++ b/config/config_logger.go @@ -0,0 +1,49 @@ +package config + +import ( + "time" + + "go.uber.org/zap/zapcore" +) + +type Logger struct { + LogFile string + LogPath string `yaml:"logPath"` // Path of the log file + MaxSize int `yaml:"maxSize"` // Maximum log file size in MB + MaxBackups int `yaml:"maxBackups"` // Maximum number of log file backups + MaxAge int `yaml:"maxAge"` // Maximum number of days to retain log files + Compress bool `yaml:"compress"` // Whether to enable gzip compression + UseCaller bool `yaml:"useCaller"` // Whether to enable Zap Caller +} + +// GetEncoder 根据模式获取编码器 +func (config *Logger) GetEncoder(mode string) zapcore.Encoder { + encoderConfig := zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.FullCallerEncoder, + } + + if config.UseCaller { + encoderConfig.CallerKey = "caller" + encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder + } + + encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format("2006-01-02 15:04:05.000")) + } + + if mode == "file" { + return zapcore.NewConsoleEncoder(encoderConfig) + } + + //控制台模式下显示颜色 + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + return zapcore.NewConsoleEncoder(encoderConfig) +} diff --git a/config/config_mysql.go b/config/config_mysql.go new file mode 100644 index 0000000..12e65c1 --- /dev/null +++ b/config/config_mysql.go @@ -0,0 +1,35 @@ +package config + +import ( + "fmt" + "time" +) + +type MySQL struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + User string `yaml:"user" json:"user"` + Password string `yaml:"password" json:"password"` + Database string `yaml:"database" json:"database"` + Prefix string `yaml:"prefix" json:"prefix"` + LogLevel int `yaml:"logLevel"` + Idle int `yaml:"idle" json:"idle"` + IdleTime time.Duration `yaml:"idleTime" json:"idleTime,omitempty"` + MaxLifetime time.Duration `yaml:"maxLifetime" json:"maxLifetime,omitempty"` // Maximum time a connection can be reused + HeartBeatTime time.Duration `yaml:"heartBeatTime" json:"heartBeatTime,omitempty"` // Heartbeat check time for MySQL server connections + Active int `yaml:"active" json:"active"` // Active connections + MaxOpen int `yaml:"maxOpen" json:"maxOpen"` // Maximum open connections + Enable int `yaml:"enable" json:"enable"` // 0, disabled; 1, enabled + + AutoMigrateTables bool // Whether to synchronize table structures +} + +func (dbc *MySQL) GetDsn() string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + dbc.User, + dbc.Password, + dbc.Host, + dbc.Port, + dbc.Database, + ) +} diff --git a/config/config_redis.go b/config/config_redis.go new file mode 100644 index 0000000..0a0df56 --- /dev/null +++ b/config/config_redis.go @@ -0,0 +1,15 @@ +package config + +import ( + "time" +) + +type Redis struct { + Addr string `yaml:"addr" json:"addr"` + Username string `yaml:"username" json:"username"` + Pwd string `yaml:"pwd" json:"pwd"` + Db int `yaml:"db" json:"db"` + LogLevel int `yaml:"logLevel"` + MinIdleConns int `yaml:"minIdleConns" json:"minIdleConns"` // Minimum number of idle connections, useful when establishing new connections is slow. + IdleTimeout time.Duration `yaml:"idleTimeout" json:"idleTimeout,omitempty"` // Time after which idle connections are closed by the client, default is 5 minutes, -1 disables the setting. +} diff --git a/config/config_sqlite.go b/config/config_sqlite.go new file mode 100644 index 0000000..566b748 --- /dev/null +++ b/config/config_sqlite.go @@ -0,0 +1,14 @@ +package config + +import ( + "time" +) + +type Sqlite struct { + Database string `yaml:"database"` // Path to the database file + Prefix string `yaml:"prefix"` // Table prefix + MaxIdleConns int `yaml:"maxIdleConns"` // Maximum number of idle connections in the pool + MaxOpenConns int `yaml:"maxOpenConns"` // Maximum number of open connections to the database + LogLevel int `yaml:"logLevel"` // Log level + ConnMaxLifetime time.Duration `yaml:"connMaxLifetime"` // Maximum lifetime of connections +} diff --git a/config/config_sqlserver.go b/config/config_sqlserver.go new file mode 100644 index 0000000..86b3d1e --- /dev/null +++ b/config/config_sqlserver.go @@ -0,0 +1,35 @@ +package config + +import ( + "fmt" + "net/url" + "time" +) + +type SqlServer struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Pwd string `yaml:"pwd"` + Database string `yaml:"database"` + Prefix string `yaml:"prefix"` + Encrypt string `yaml:"encrypt"` + LogLevel int `yaml:"logLevel"` + TrustServerCertificate string `yaml:"trustServerCertificate"` + Idle int `yaml:"idle"` + IdleTime time.Duration `yaml:"idleTime"` + MaxLifetime time.Duration `yaml:"maxLifetime"` // Maximum time a connection can be reused + MaxOpen int `yaml:"maxOpen"` +} + +func (m *SqlServer) GetDsn() string { + return fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s&encrypt=%s&trustServerCertificate=%s", + m.User, + url.QueryEscape(m.Pwd), + m.Host, + m.Port, + m.Database, + m.Encrypt, + m.TrustServerCertificate, + ) +} diff --git a/docs/assets/img.png b/docs/assets/img.png new file mode 100644 index 0000000..c20d8d7 Binary files /dev/null and b/docs/assets/img.png differ diff --git a/docs/zh-CN.md b/docs/zh-CN.md new file mode 100644 index 0000000..7e51d6f --- /dev/null +++ b/docs/zh-CN.md @@ -0,0 +1,212 @@ +# Aqi + +Aqi是一个Golang Websocket业务框架,支持`net/http`, `gin`, `chi`等,底层整合了`viper`, `gorm`, `gobwa/ws`, `gjson`, `zap`, `asynq`等优秀第三方库,方便快速展开Websocket业务 + +### 安装 + +`go get -u github.com/wonli/aqi` + + + +### 使用 + +第一次运行时会在工作目录下自动生成`config-dev.yaml`配置文件,你可以配置程序启动端口、数据库等信息。 + + + +服务启动后使用 [wscat](https://github.com/websockets/wscat) 与服务器建立websocket链接,运行截图如下。 + + + +![img](./assets/img.png) + + + +### 交互协议 + +输入输出统一使用`JSON`,其中 `Action`为路由中注册的名字, `Params`为JSON格式字符串 + +```go + +type Context struct { + ... + Action string + Params string + ... +} +``` + + + +响应内容格式: + +```go +type Action struct { + Action string `json:"action"` + + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Data any `json:"data,omitempty"` +} +``` + + + +### 快速开始 + +在`ahi.Init`中通过`aqi.ConfigFile`指定配置文件,默认使用`yaml`格式,通过`aqi.HttpServer`指定服务名称和端口在`yaml`文件中的路径, 入口文件内容如下: + +```go +package main + +import ( + "net/http" + "time" + + "github.com/wonli/aqi" + "github.com/wonli/aqi/ws" +) + +func main() { + app := aqi.Init( + aqi.ConfigFile("config.yaml"), + aqi.HttpServer("Aqi", "port"), + ) + + // 创建路由 + mux := http.NewServeMux() + // WebSocket Handler + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + ws.HttpHandler(w, r) + }) + + // 注册路由 + wsr := ws.NewRouter() + wsr.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + app.WithHttpServer(mux) + + // 启动应用 + app.Start() +} +``` + + + +### 与Gin整合 + +`aqi`能非常方便的与其他WEB框架整合,只需要正确注册`handler`和`app.WithHttpServer`,只要实现了 `http.Handler`都支持。 + + + +```go +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/wonli/aqi" + "github.com/wonli/aqi/ws" +) + +func main() { + app := aqi.Init( + aqi.ConfigFile("config.yaml"), + aqi.HttpServer("Aqi", "port"), + ) + + engine := gin.Default() + // 注册handler + engine.GET("/ws", func(c *gin.Context) { + ws.HttpHandler(c.Writer, c.Request) + }) + + // 注册路由 + wsr := ws.NewRouter() + wsr.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + app.WithHttpServer(engine) + app.Start() +} +``` + + + +### 中间件 + +先定义一个简单的日志中间件,在处理请求前先打印当前接收到的`action`,在处理完成后打印响应内容。 + +```go +func logMiddleware() func(a *ws.Context) { + return func(a *ws.Context) { + log.Printf("Request action: %s ", a.Action) + a.Next() + log.Printf("Reqponse data: %s ", a.Response.Data) + } +} +``` + +使用`Use`方法将中间件注册到路由中 + +```go +// 注册WebSocket路由 +wsr := ws.NewRouter() +wsr.Use(logMiddleware()).Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) +}) +``` + +当然也可以用路由组的形式 + +```go +// 注册WebSocket路由 +wsr := ws.NewRouter() +r1 := wsr.Use(logMiddleware()) +{ + r1.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + r1.Add("say", func(a *ws.Context) { + a.Send(ws.H{ + "say": "hi", + }) + }) +} +``` + +这样控制台在每个请求前后都会打印日志 + + + +### 生产模式 + +直接编译`Aqi`会以`dev`模式运行,以生产模式运行请在编译时传入以下参数,详细内容请查看`examples/Makefile`文件 + + + +```shell +LDFLAGS = "-X '$(FLAGS_PKG).BuildDate=$(BUILD_DATE)' \ + -X '$(FLAGS_PKG).Branch=$(GIT_BRANCH)' \ + -X '$(FLAGS_PKG).CommitVersion=$(GIT_COMMIT)' \ + -X '$(FLAGS_PKG).Revision=$(GIT_REVISION)' \ + -extldflags '-static -s -w'" +``` + + + diff --git a/examples/Makefile b/examples/Makefile new file mode 100644 index 0000000..87410d8 --- /dev/null +++ b/examples/Makefile @@ -0,0 +1,46 @@ +APP_NAME = aqi +APP_PATH = ./ + +# build dist +BUILD_PATH := ./dist + +# build at +BUILD_DATE = $(shell date +'%F %T') + +# git versions +GIT_BRANCH = $(shell git rev-parse --abbrev-ref HEAD) +GIT_COMMIT = $(shell git rev-list --count HEAD) +GIT_REVISION = $(shell git rev-parse --short HEAD) +GIT_COMMITAT = $(shell git --no-pager log -1 --format="%at") + +# flags +FLAGS_PKG = github.com/wonli/aqi +LDFLAGS = "-X '$(FLAGS_PKG).BuildDate=$(BUILD_DATE)' \ + -X '$(FLAGS_PKG).Branch=$(GIT_BRANCH)' \ + -X '$(FLAGS_PKG).CommitVersion=$(GIT_COMMIT)' \ + -X '$(FLAGS_PKG).Revision=$(GIT_REVISION)' \ + -extldflags '-static -s -w'" + +# params +GO_FLAGS = -ldflags $(LDFLAGS) -trimpath -tags netgo + +# Go build fn 1-GOOS 2-GOARCH 3-FILE EXT +define go/build + GOOS=$(1) GOARCH=$(2) CGO_ENABLED=0 go build $(GO_FLAGS) -o $(BUILD_PATH)/$(APP_NAME)-$(1)-$(2)-latest$(3) ${APP_PATH} +endef + +# PHONY +.PHONY: darwin linux windows + +darwin: + $(call go/build,darwin,amd64) + $(call go/build,darwin,arm64) + +linux: + $(call go/build,linux,amd64) + +windows: + $(call go/build,windows,amd64,.exe) + +linux64: + $(call go/build,linux,amd64) \ No newline at end of file diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 0000000..6aceeec --- /dev/null +++ b/examples/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/wonli/aqi" + "github.com/wonli/aqi/ws" +) + +func main() { + app := aqi.Init( + aqi.ConfigFile("config.yaml"), + aqi.HttpServer("Aqi", "port"), + ) + + engine := gin.Default() + engine.GET("/", func(c *gin.Context) { + c.String(http.StatusOK, "Hi aqi!") + }) + + // Websocket + engine.GET("/ws", func(c *gin.Context) { + ws.HttpHandler(c.Writer, c.Request) + }) + + // Router + wsr := ws.NewRouter() + wsr.Add("hi", func(a *ws.Context) { + a.Send(ws.H{ + "hi": time.Now(), + }) + }) + + app.WithHttpServer(engine) + app.Start() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..481d89f --- /dev/null +++ b/go.mod @@ -0,0 +1,111 @@ +module github.com/wonli/aqi + +go 1.22.1 + +require ( + github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.6 + github.com/alibabacloud-go/darabonba-stream v0.0.1 + github.com/alibabacloud-go/tea v1.2.2 + github.com/alibabacloud-go/tea-utils/v2 v2.0.5 + github.com/fatih/color v1.17.0 + github.com/fsnotify/fsnotify v1.7.0 + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.20.0 + github.com/gobwas/ws v1.4.0 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/guonaihong/gout v0.3.9 + github.com/hibiken/asynq v0.24.1 + github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b + github.com/mozillazg/go-pinyin v0.20.0 + github.com/redis/go-redis/v9 v9.0.3 + github.com/shirou/gopsutil/v3 v3.24.4 + github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.9.0 + github.com/tidwall/gjson v1.17.1 + go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/datatypes v1.2.0 + gorm.io/driver/mysql v1.5.6 + gorm.io/driver/sqlite v1.4.3 + gorm.io/driver/sqlserver v1.4.1 + gorm.io/gorm v1.25.10 +) + +require ( + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 // indirect + github.com/alibabacloud-go/debug v1.0.0 // indirect + github.com/alibabacloud-go/openapi-util v0.1.0 // indirect + github.com/alibabacloud-go/tea-utils v1.3.1 // indirect + github.com/alibabacloud-go/tea-xml v1.1.3 // indirect + github.com/aliyun/credentials-go v1.3.1 // indirect + github.com/andybalholm/brotli v1.0.4 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/clbanning/mxj/v2 v2.5.5 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/microsoft/go-mssqldb v0.17.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tjfoc/gmsm v1.3.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..18829ab --- /dev/null +++ b/go.sum @@ -0,0 +1,417 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 h1:iC9YFYKDGEy3n/FtqJnOkZsene9olVspKmkX5A2YBEo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.6 h1:y1K+zKhpWcxso8zqI03CcYuwgyZPFwQdwAQOXAeuOVM= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.6/go.mod h1:CzQnh+94WDnJOnKZH5YRyouL+OOcdBnXY5VWAf0McgI= +github.com/alibabacloud-go/darabonba-stream v0.0.1 h1:e80xWsu1QkwHGKkLxHHH3QNP6GLZs3InOkN7UR2Fu6w= +github.com/alibabacloud-go/darabonba-stream v0.0.1/go.mod h1:/RbIC3XJDnXMCneEbwnqc+CNyOOs36tJAJJ3z2kpVdc= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0 h1:3eIEQWfay1fB24PQIEzXAswlVJtdQok8f3EVN5VrBnA= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA= +github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5 h1:EUakYEUAwr6L3wLT0vejIw2rc0IA1RSXDwLnIb3f2vU= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1 h1:uq/0v7kWrxmoLGpqjx7vtQ/s03f0zR//0br/xWDTE28= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/guonaihong/gout v0.3.9 h1:bWaoD9mgAQwzIZhau+QOsLOxgRSZRL+wAIYVqqkwPEw= +github.com/guonaihong/gout v0.3.9/go.mod h1:wDXeuyeZR6MtaHbytO9RLcKW4iCDrWD6/KF1QwDtbRc= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw= +github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= +github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b h1:9hudrgWUhyfR4FRMOfL9KB1uYw48DUdHkkgr9ODOw7Y= +github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b/go.mod h1:zLVdX6Ed2SvCbEamKmve16U0E03UkdJo4ls1TBfmc8Q= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= +github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ= +github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= +github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= +github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tjfoc/gmsm v1.3.2 h1:7JVkAn5bvUJ7HtU08iW6UiD+UTmJTIToHCfeFzkcCxM= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= +gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/logger/zap.go b/logger/zap.go new file mode 100644 index 0000000..20ad417 --- /dev/null +++ b/logger/zap.go @@ -0,0 +1,59 @@ +package logger + +import ( + "os" + "path/filepath" + + "github.com/fatih/color" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" + + "github.com/wonli/aqi/config" +) + +var ZapLog *zap.Logger +var SugarLog *zap.SugaredLogger + +func Init(c config.Logger) { + if c.LogPath == "" { + c.LogPath = "." + } + + if c.LogFile == "" { + c.LogFile = "error.log" + } + + isAbsPath := filepath.IsAbs(c.LogPath) + if !isAbsPath { + path, err := os.Getwd() + if err != nil { + color.Red("Failed to get the runtime directory %s", err.Error()) + os.Exit(0) + } + + c.LogPath = filepath.Join(path, c.LogPath) + err = os.MkdirAll(c.LogPath, 0755) + if err != nil { + color.Red("Failed to create log directory %s", err.Error()) + os.Exit(0) + } + } + + hook := lumberjack.Logger{ + Filename: filepath.Join(c.LogPath, c.LogFile), + MaxSize: c.MaxSize, + MaxBackups: c.MaxBackups, + MaxAge: c.MaxAge, + Compress: c.Compress, + } + + stdLog := zapcore.NewCore(c.GetEncoder(""), zapcore.AddSync(os.Stdout), zap.DebugLevel) + fileLog := zapcore.NewCore(c.GetEncoder("file"), zapcore.AddSync(&hook), zap.DebugLevel) + + //ZapLog + ZapLog = zap.New(zapcore.NewTee(stdLog, fileLog), zap.AddCaller(), zap.Development()) + + //sugar + SugarLog = ZapLog.Sugar() +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..02c1bed --- /dev/null +++ b/options.go @@ -0,0 +1,84 @@ +package aqi + +import ( + "log" + "os" + "path/filepath" + "strings" +) + +type Option func(config *AppConfig) error + +func LogConfig(configKeyPath string) Option { + return func(config *AppConfig) error { + config.LogPathKey = configKeyPath + return nil + } +} + +func DataPath(path string) Option { + return func(config *AppConfig) error { + config.DataPath = path + return nil + } +} + +func devMode(isDev bool) Option { + return func(config *AppConfig) error { + config.devMode = isDev + return nil + } +} + +func ConfigFile(file string) Option { + if !filepath.IsAbs(file) { + workerDir, err := os.Getwd() + if err != nil { + log.Fatalf("获取工作目录失败: %s", err.Error()) + } + + file = filepath.Join(workerDir, file) + } + + return func(config *AppConfig) error { + configPath := filepath.Dir(file) + config.ConfigPath = configPath + + fileType := filepath.Ext(file) + config.ConfigType = fileType[1:] + + filename := filepath.Base(file) + config.ConfigName = strings.TrimSuffix(filename, fileType) + + return nil + } +} + +func Server(name ...string) Option { + return func(config *AppConfig) error { + config.Servername = name + return nil + } +} + +func Language(lng string) Option { + return func(config *AppConfig) error { + config.Language = lng + return nil + } +} + +func HttpServer(name, portFindPath string) Option { + return func(config *AppConfig) error { + config.Servername = append(config.Servername, name) + config.HttpServerPortFindPath = portFindPath + return nil + } +} + +func WatchHandler(handler func()) Option { + return func(config *AppConfig) error { + config.WatchHandler = handler + return nil + } +} diff --git a/remote_provider.go b/remote_provider.go new file mode 100644 index 0000000..7abe943 --- /dev/null +++ b/remote_provider.go @@ -0,0 +1,66 @@ +package aqi + +import ( + "net/url" + "path/filepath" + "strings" + + "github.com/wonli/aqi/logger" +) + +type Provider string + +const ProviderConsul Provider = "consul" +const ProviderEtcd Provider = "etcd" + +type RemoteProvider struct { + Name Provider //服务商名称 + Path string //路径 + Endpoint string //服务器地址 + Type string //json, yaml等 +} + +// ParseRemoteProvider 格式 provider[s]://endpoint/path.type +// 例:consul://localhost:8500/a.yaml +// 表示远程配置中心为consul,服务器地址为http://localhost:8500, path为a, 配置类型是yaml +// scheme加s表示服务器支持ssl +func ParseRemoteProvider(s string) *RemoteProvider { + u, err := url.Parse(s) + if err != nil { + logger.SugarLog.Errorf("Parse remote provider endpoint error: %s", err.Error()) + return nil + } + + if u.Scheme == "" { + return nil + } + + path := strings.TrimLeft(u.Path, "/") + fileType := filepath.Ext(path) + if fileType == "" { + fileType = "yaml" + } else { + path = path[:len(path)-len(fileType)] + fileType = fileType[1:] + } + + p := &RemoteProvider{ + Path: path, + Type: fileType, + } + + switch u.Scheme { + case "consul", "consuls": + p.Name = ProviderConsul + case "etcd", "etcds": + p.Name = ProviderEtcd + } + + if strings.HasSuffix(u.Scheme, "s") { + p.Endpoint = "https://" + u.Host + } else { + p.Endpoint = "http://" + u.Host + } + + return p +} diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 0000000..b30b7af --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,231 @@ +package stats + +import ( + "math" + "os" + "runtime" + "sync" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + snet "github.com/shirou/gopsutil/v3/net" + "github.com/shirou/gopsutil/v3/process" + + "github.com/wonli/aqi/ws" +) + +type NetStats struct { + LastSent uint64 + LastRecv uint64 + LastTime time.Time +} + +// Stats contains statistical data on CPU and memory usage +type Stats struct { + Timestamp time.Time `json:"timestamp"` + SvrCPUUsage float64 `json:"svrCPUUsage"` // Overall CPU usage rate + SvrMemoryPct float64 `json:"svrMemoryPct"` // Total memory + LoadAverage [2]float64 `json:"loadAverage"` // 1, 5 minutes average load + + CPUUsage float64 `json:"CPUUsage"` // Current process's CPU usage rate + MemoryUsage float64 `json:"memoryUsage"` // Current process's memory usage + MemoryUsagePct float64 `json.json:"memoryUsagePct"` // Current process's memory usage percentage + ThreadCount int `json:"threadCount"` // Current process's thread count + Goroutines int `json:"goroutines"` // Number of Go coroutines + HeapAlloc float64 `json:"heapAlloc"` // Memory allocated in the heap currently + HeapSys float64 `json:"heapSys"` // Total heap memory obtained from the system + HeapInuse float64 `json:"heapInuse"` // Heap memory in use + HeapPct float64 `json:"heapPct"` // Percentage of heap memory obtained from the system + + LoginCount int `json:"loginCount"` // Online users + GuestCount int `json.json:"guestCount"` // Visitors + Connections int `json:"connections"` // Current process's network connections + + SentRate float64 `json:"sentRate"` // Sending rate KB/s + RecvRate float64 `json:"recvRate"` // Receiving rate KB/s + + MaxMemoryUsage float64 `json:"maxMemoryUsage"` // Maximum memory + MaxGoroutines int `json:"maxGoroutines"` // Maximum coroutines +} + +// Collector manages the collection and storage of statistical data +type Collector struct { + mu sync.Mutex // mu + stats []Stats // Slice for storing statistical data + netStats NetStats // transmission rate + + capacity int // Maximum capacity of the slice + interval2 time.Duration +} + +var capacity = 30 +var once sync.Once +var collectorInstance *Collector + +func InitStatsCollector() *Collector { + once.Do(func() { + collectorInstance = &Collector{ + stats: make([]Stats, 0, capacity), + capacity: capacity, + } + }) + + return collectorInstance +} + +// Collect starts collecting statistical data +func (sc *Collector) Collect(interval time.Duration) { + go func() { + sc.doCollect(100 * time.Millisecond) + for { + sc.doCollect(0) + time.Sleep(interval) + } + }() +} + +func (sc *Collector) doCollect(interval time.Duration) { + currentStats := Stats{} + + if ws.Hub == nil { + return + } + + // User data + currentStats.LoginCount = ws.Hub.LoginCount + currentStats.GuestCount = ws.Hub.GuestCount + + // Get CPU usage rate + cpuPercentages, err := cpu.Percent(interval, false) + if err == nil && len(cpuPercentages) > 0 { + currentStats.SvrCPUUsage = cpuPercentages[0] + } + + // Get average load + avgLoad, err := load.Avg() + if err == nil { + currentStats.LoadAverage = [2]float64{avgLoad.Load1, avgLoad.Load5} + } + + // Get network interface statistics + netIOCounters, err := snet.IOCounters(true) + if err == nil { + var totalBytesSent, totalBytesRecv uint64 + for _, counter := range netIOCounters { + totalBytesSent += counter.BytesSent + totalBytesRecv += counter.BytesRecv + } + + currentTime := time.Now() + if !sc.netStats.LastTime.IsZero() { + // Calculate time difference (seconds) + duration := currentTime.Sub(sc.netStats.LastTime).Seconds() + + // Calculate byte differences and convert to KB + sentDiff := float64(totalBytesSent-sc.netStats.LastSent) / 1024.0 + recvDiff := float64(totalBytesRecv-sc.netStats.LastRecv) / 1024.0 + + // Calculate rate per second + currentStats.SentRate = sentDiff / duration + currentStats.RecvRate = recvDiff / duration + } + + // Update the last statistics + sc.netStats.LastSent = totalBytesSent + sc.netStats.LastRecv = totalBytesRecv + sc.netStats.LastTime = currentTime + } + + // Get memory statistics + vmem, err := mem.VirtualMemory() + if err == nil { + currentStats.SvrMemoryPct = vmem.UsedPercent + } + + // Get current process ID and process object + pid := os.Getpid() + proc, err := process.NewProcess(int32(pid)) + if err == nil { + // Get current process's CPU usage rate + procPercent, err := proc.Percent(interval) + if err == nil { + currentStats.CPUUsage = procPercent + } + + // Get current process's memory statistics + memInfo, err := proc.MemoryInfo() + if err == nil && memInfo != nil { + currentStats.MemoryUsage = formatMegabytes(bytesToMegabytes(memInfo.RSS)) // RSS is Resident Set Size + vmem, err := mem.VirtualMemory() + if err == nil { + currentStats.MemoryUsagePct = float64(memInfo.RSS) / float64(vmem.Total) * 100 + } + + if currentStats.MemoryUsage > currentStats.MaxMemoryUsage { + currentStats.MaxMemoryUsage = currentStats.MemoryUsage + } + } + + // Get current process's thread count + threads, err := proc.NumThreads() + if err == nil { + currentStats.ThreadCount = int(threads) + } + } + + // Get current process's network connection information + connections, err := snet.ConnectionsPid("all", int32(pid)) + if err == nil { + currentStats.Connections = len(connections) + } + + // Get Go runtime memory statistics + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + currentStats.Goroutines = runtime.NumGoroutine() + currentStats.HeapAlloc = formatMegabytes(bytesToMegabytes(memStats.HeapAlloc)) + currentStats.HeapSys = formatMegabytes(bytesToMegabytes(memStats.HeapSys)) + currentStats.HeapInuse = formatMegabytes(bytesToMegabytes(memStats.HeapInuse)) + if currentStats.Goroutines > currentStats.MaxGoroutines { + currentStats.MaxGoroutines = currentStats.Goroutines + } + + // Get heap memory usage + if currentStats.HeapSys > 0 { + currentStats.HeapPct = currentStats.HeapInuse / currentStats.HeapSys * 100 + } + + // Timestamp when statistics are completed + currentStats.Timestamp = time.Now() + + // Lock and update statistical data + sc.mu.Lock() + sc.stats = append(sc.stats, currentStats) + if len(sc.stats) > sc.capacity { + sc.stats = sc.stats[1:] + } + sc.mu.Unlock() + + // Publish data + ws.Hub.PubSub.Pub("sys:status", currentStats) +} + +// GetStats returns all the collected statistical data +func (sc *Collector) GetStats() []Stats { + sc.mu.Lock() + defer sc.mu.Unlock() + // Return a copy of the slice to avoid external modification + statsCopy := make([]Stats, len(sc.stats)) + copy(statsCopy, sc.stats) + return statsCopy +} + +func bytesToMegabytes(bytes uint64) float64 { + return float64(bytes) / 1024.0 / 1024.0 +} + +func formatMegabytes(mb float64) float64 { + return math.Round(mb*100) / 100 +} diff --git a/store/use.go b/store/use.go new file mode 100644 index 0000000..42d75a8 --- /dev/null +++ b/store/use.go @@ -0,0 +1,17 @@ +package store + +func DB(configKey string) *MySQLStore { + return &MySQLStore{configKey: configKey} +} + +func SQLite(configKey string) *SQLiteStore { + return &SQLiteStore{configKey: configKey} +} + +func Redis(configKey string) *RedisStore { + return &RedisStore{configKey: configKey} +} + +func SqlServer(configKey string) *SqlServerStore { + return &SqlServerStore{configKey: configKey} +} diff --git a/store/use_mysql.go b/store/use_mysql.go new file mode 100644 index 0000000..6e7b4c8 --- /dev/null +++ b/store/use_mysql.go @@ -0,0 +1,65 @@ +package store + +import ( + "github.com/spf13/viper" + "go.uber.org/zap" + "gorm.io/driver/mysql" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" + "gorm.io/gorm/schema" + + "github.com/wonli/aqi/config" + "github.com/wonli/aqi/logger" +) + +type MySQLStore struct { + configKey string +} + +func (m *MySQLStore) Config() *config.MySQL { + var r *config.MySQL + err := viper.UnmarshalKey(m.configKey, &r) + if err != nil { + return nil + } + + return r +} + +func (m *MySQLStore) Use() *gorm.DB { + r := m.Config() + if r == nil { + return nil + } + + if r.Enable == 0 { + return nil + } + + conf := &gorm.Config{ + Logger: gormLogger.Default.LogMode(gormLogger.LogLevel(r.LogLevel)), + NamingStrategy: schema.NamingStrategy{ + TablePrefix: r.Prefix, + }, + } + + db, err := gorm.Open(mysql.Open(r.GetDsn()), conf) + if err != nil { + logger.SugarLog.Error("Failed to connect to MySQL database", zap.String("error", err.Error())) + return nil + } + + sqlDB, err := db.DB() + if err != nil { + logger.SugarLog.Error("Error pinging database", zap.String("error", err.Error())) + return nil + } + + sqlDB.SetMaxIdleConns(r.Idle) + sqlDB.SetConnMaxLifetime(r.MaxLifetime) + if r.MaxOpen > 0 { + sqlDB.SetMaxOpenConns(r.MaxOpen) + } + + return db +} diff --git a/store/use_redis.go b/store/use_redis.go new file mode 100644 index 0000000..4488088 --- /dev/null +++ b/store/use_redis.go @@ -0,0 +1,40 @@ +package store + +import ( + "github.com/redis/go-redis/v9" + "github.com/spf13/viper" + + "github.com/wonli/aqi/config" +) + +type RedisStore struct { + configKey string +} + +func (s *RedisStore) Config() *config.Redis { + var r *config.Redis + err := viper.UnmarshalKey(s.configKey, &r) + if err != nil { + return nil + } + + return r +} + +func (s *RedisStore) Use() *redis.Client { + r := s.Config() + if r == nil { + return nil + } + + client := redis.NewClient(&redis.Options{ + Addr: r.Addr, + Username: r.Username, + Password: r.Pwd, + DB: r.Db, + MinIdleConns: r.MinIdleConns, + ConnMaxIdleTime: r.IdleTimeout, + }) + + return client +} diff --git a/store/use_sqlite.go b/store/use_sqlite.go new file mode 100644 index 0000000..3aa5190 --- /dev/null +++ b/store/use_sqlite.go @@ -0,0 +1,64 @@ +package store + +import ( + "github.com/spf13/viper" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" + "gorm.io/gorm/schema" + + "github.com/wonli/aqi/config" + "github.com/wonli/aqi/logger" +) + +type SQLiteStore struct { + configKey string +} + +func (m *SQLiteStore) Config() *config.Sqlite { + var r *config.Sqlite + err := viper.UnmarshalKey(m.configKey, &r) + if err != nil { + return nil + } + + return r +} + +func (m *SQLiteStore) Use() *gorm.DB { + r := m.Config() + if r == nil { + return nil + } + + conf := &gorm.Config{ + Logger: gormLogger.Default.LogMode(gormLogger.LogLevel(r.LogLevel)), + NamingStrategy: schema.NamingStrategy{ + TablePrefix: r.Prefix, + }, + } + + db, err := gorm.Open(sqlite.Open(r.Database), conf) + if err != nil { + logger.SugarLog.Error("Connect to SQLite error", zap.String("error", err.Error())) + return nil + } + + sqlDB, err := db.DB() + if err != nil { + logger.SugarLog.Error("Ping SQLite error", + zap.String("error", err.Error()), + ) + return nil + } + + // 设置连接池参数 + sqlDB.SetMaxIdleConns(r.MaxIdleConns) + sqlDB.SetConnMaxLifetime(r.ConnMaxLifetime) + if r.MaxOpenConns > 0 { + sqlDB.SetMaxOpenConns(r.MaxOpenConns) + } + + return db +} diff --git a/store/use_sqlserver.go b/store/use_sqlserver.go new file mode 100644 index 0000000..6ebfd2a --- /dev/null +++ b/store/use_sqlserver.go @@ -0,0 +1,67 @@ +package store + +import ( + "github.com/spf13/viper" + "gorm.io/driver/sqlserver" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" + "gorm.io/gorm/schema" + + "github.com/wonli/aqi/config" + "github.com/wonli/aqi/logger" +) + +type SqlServerStore struct { + configKey string +} + +func (m *SqlServerStore) Config() *config.SqlServer { + var r *config.SqlServer + err := viper.UnmarshalKey(m.configKey, &r) + if err != nil { + return nil + } + + return r +} + +func (m *SqlServerStore) Use() *gorm.DB { + r := m.Config() + if r == nil { + return nil + } + + conf := &gorm.Config{ + Logger: gormLogger.Default.LogMode(gormLogger.LogLevel(r.LogLevel)), + NamingStrategy: schema.NamingStrategy{ + TablePrefix: r.Prefix, + }, + } + + conf = &gorm.Config{} + db, err := gorm.Open(sqlserver.Open(r.GetDsn()), conf) + if err != nil { + logger.SugarLog.Errorf("%s (gorm.open)", err.Error()) + return nil + } + + sqlDB, err := db.DB() + if err != nil { + logger.SugarLog.Errorf("%s (ping)", err.Error()) + return nil + } + + if r.Idle > 0 { + sqlDB.SetMaxIdleConns(r.Idle) + } + + if r.MaxLifetime > 0 { + sqlDB.SetConnMaxLifetime(r.MaxLifetime) + } + + if r.MaxOpen > 0 { + sqlDB.SetMaxOpenConns(r.MaxOpen) + } + + return db +} diff --git a/utils/ali_ocr.go b/utils/ali_ocr.go new file mode 100644 index 0000000..e93bbbf --- /dev/null +++ b/utils/ali_ocr.go @@ -0,0 +1,139 @@ +package utils + +import ( + "encoding/json" + "fmt" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + stream "github.com/alibabacloud-go/darabonba-stream/client" + util "github.com/alibabacloud-go/tea-utils/v2/service" + "github.com/alibabacloud-go/tea/tea" + "github.com/tidwall/gjson" +) + +type AliOcr struct { + uploadBody []byte + apiParams *openapi.Params + + client *openapi.Client + initError error + + response any + apiResponse any + + accessKey string + secret string +} + +func NewAliOcr(accessKey, secret string) *AliOcr { + instance := &AliOcr{ + accessKey: accessKey, + secret: secret, + } + + //STS see: + //https://help.aliyun.com/document_detail/378661.html + client, err := instance.createClient(tea.String(accessKey), tea.String(secret)) + if err != nil { + instance.initError = err + } + + instance.client = client + return instance +} + +func (ali *AliOcr) Request() error { + if ali.initError != nil { + return ali.initError + } + + if ali.uploadBody == nil { + return fmt.Errorf("获取上传内容失败") + } + + if ali.client == nil { + return fmt.Errorf("初始化阿里客户端失败") + } + + if ali.apiParams == nil { + return fmt.Errorf("请求参数不能为空") + } + + // runtime options + runtime := &util.RuntimeOptions{} + request := &openapi.OpenApiRequest{ + Stream: stream.ReadFromBytes(ali.uploadBody), + } + + // 复制代码运行请自行打印 API 的返回值 + // 返回值为 Map 类型,可从 Map 中获得三类数据:响应体 body、响应头 headers、HTTP 返回的状态码 statusCode。 + res, err := ali.client.CallApi(ali.apiParams, request, runtime) + if err != nil { + return err + } + + ali.apiResponse = res + apiJson, err := json.Marshal(res) + if err != nil { + return err + } + + statusCode := gjson.Get(string(apiJson), "statusCode").Int() + if statusCode != 200 { + return fmt.Errorf("返回状态码不正确") + } + + if ali.response != nil { + bodyData := gjson.Get(string(apiJson), "body.Data").String() + err = json.Unmarshal([]byte(bodyData), ali.response) + if err != nil { + return err + } + } + + return nil +} + +func (ali *AliOcr) WithResponse(s any) { + ali.response = s +} + +func (ali *AliOcr) WithApiName(apiName string) { + //RecognizeDrivingLicense + ali.apiParams = &openapi.Params{ + // 接口名称 + Action: tea.String(apiName), + // 接口版本 + Version: tea.String("2021-07-07"), + // 接口协议 + Protocol: tea.String("HTTPS"), + // 接口 HTTP 方法 + Method: tea.String("POST"), + AuthType: tea.String("AK"), + Style: tea.String("V3"), + // 接口 PATH + Pathname: tea.String("/"), + // 接口请求体内容格式 + ReqBodyType: tea.String("json"), + // 接口响应体内容格式 + BodyType: tea.String("json"), + } +} + +func (ali *AliOcr) WithBody(body []byte) { + ali.uploadBody = body +} + +func (ali *AliOcr) createClient(accessKeyId *string, accessKeySecret *string) (res *openapi.Client, err error) { + config := &openapi.Config{ + // 必填,您的 AccessKey ID + AccessKeyId: accessKeyId, + // 必填,您的 AccessKey Secret + AccessKeySecret: accessKeySecret, + } + // 访问的域名 + config.Endpoint = tea.String("ocr-api.cn-hangzhou.aliyuncs.com") + res = &openapi.Client{} + res, err = openapi.NewClient(config) + return res, err +} diff --git a/utils/bytefmt/bytefmt.go b/utils/bytefmt/bytefmt.go new file mode 100644 index 0000000..82e5530 --- /dev/null +++ b/utils/bytefmt/bytefmt.go @@ -0,0 +1,123 @@ +// Package bytefmt contains helper methods and constants for converting +// to and from a human-readable byte format. +// +// bytefmt.ByteSize(100.5*bytefmt.MEGABYTE) // "100.5M" +// bytefmt.ByteSize(uint64(1024)) // "1K" +package bytefmt + +import ( + "errors" + "strconv" + "strings" + "unicode" +) + +const ( + BYTE = 1 << (10 * iota) + KILOBYTE + MEGABYTE + GIGABYTE + TERABYTE + PETABYTE + EXABYTE +) + +var invalidByteQuantityError = errors.New("byte quantity must be a positive integer with a unit of measurement like M, MB, MiB, G, GiB, or GB") + +// ByteSize returns a human-readable byte string of the form 10M, 12.5K, and so forth. The following units are available: +// +// E: Exabyte +// P: Petabyte +// T: Terabyte +// G: Gigabyte +// M: Megabyte +// K: Kilobyte +// B: Byte +// +// The unit that results in the smallest number greater than or equal to 1 is always chosen. +func ByteSize(bytes uint64) string { + unit := "" + value := float64(bytes) + + switch { + case bytes >= EXABYTE: + unit = "E" + value = value / EXABYTE + case bytes >= PETABYTE: + unit = "P" + value = value / PETABYTE + case bytes >= TERABYTE: + unit = "T" + value = value / TERABYTE + case bytes >= GIGABYTE: + unit = "G" + value = value / GIGABYTE + case bytes >= MEGABYTE: + unit = "M" + value = value / MEGABYTE + case bytes >= KILOBYTE: + unit = "K" + value = value / KILOBYTE + case bytes >= BYTE: + unit = "B" + case bytes == 0: + return "0B" + } + + result := strconv.FormatFloat(value, 'f', 1, 64) + result = strings.TrimSuffix(result, ".0") + return result + unit +} + +// ToMegabytes parses a string formatted by ByteSize as megabytes. +func ToMegabytes(s string) (uint64, error) { + bytes, err := ToBytes(s) + if err != nil { + return 0, err + } + + return bytes / MEGABYTE, nil +} + +// ToBytes parses a string formatted by ByteSize as bytes. Note binary-prefixed and SI prefixed units both mean a base-2 units +// KB = K = KiB = 1024 +// MB = M = MiB = 1024 * K +// GB = G = GiB = 1024 * M +// TB = T = TiB = 1024 * G +// PB = P = PiB = 1024 * T +// EB = E = EiB = 1024 * P +func ToBytes(s string) (uint64, error) { + s = strings.TrimSpace(s) + s = strings.ToUpper(s) + + i := strings.IndexFunc(s, unicode.IsLetter) + + if i == -1 { + return 0, invalidByteQuantityError + } + + bytesString, multiple := s[:i], s[i:] + bytes, err := strconv.ParseFloat(bytesString, 64) + if err != nil || bytes < 0 { + return 0, invalidByteQuantityError + } + + switch multiple { + case "E", "EB", "EIB": + return uint64(bytes * EXABYTE), nil + case "P", "PB", "PIB": + return uint64(bytes * PETABYTE), nil + case "T", "TB", "TIB": + return uint64(bytes * TERABYTE), nil + case "G", "GB", "GIB": + return uint64(bytes * GIGABYTE), nil + case "M", "MB", "MIB": + return uint64(bytes * MEGABYTE), nil + case "K", "KB", "KIB": + return uint64(bytes * KILOBYTE), nil + case "B": + return uint64(bytes), nil + default: + return 0, invalidByteQuantityError + } +} diff --git a/utils/createfile.go b/utils/createfile.go new file mode 100644 index 0000000..2d50e7c --- /dev/null +++ b/utils/createfile.go @@ -0,0 +1,33 @@ +package utils + +import ( + "os" + "path/filepath" +) + +func CreateFileIfNotExists(filePath string) error { + // Check if the file exists + _, err := os.Stat(filePath) + if err == nil { + // File exists + return nil + } + + // Create the directory + dir := filepath.Dir(filePath) + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + // Failed to create directory + return err + } + + // Create an empty file + f, err := os.Create(filePath) + if err != nil { + // Failed to create file + return err + } + + defer f.Close() + return nil +} diff --git a/utils/encrypt/azdg.go b/utils/encrypt/azdg.go new file mode 100644 index 0000000..092e786 --- /dev/null +++ b/utils/encrypt/azdg.go @@ -0,0 +1,63 @@ +package encrypt + +import ( + "crypto/md5" + "encoding/base64" + "fmt" + + "github.com/wonli/aqi/utils" +) + +type Azdg struct { + cipherHash string +} + +func NewAzdg(key string) *Azdg { + cipherHash := fmt.Sprintf("%x", md5.Sum([]byte(key))) + return &Azdg{cipherHash: cipherHash} +} + +func (a *Azdg) Encrypt(sourceText string) string { + noise := utils.GetRandomString(32) + inputData := []byte(sourceText) + loopCount := len(inputData) + outData := make([]byte, loopCount*2) + + for i, j := 0, 0; i < loopCount; i, j = i+1, j+1 { + outData[j] = noise[i%32] + j++ + outData[j] = inputData[i] ^ noise[i%32] + } + + return base64.RawURLEncoding.EncodeToString([]byte(a.cipherEncode(fmt.Sprintf("%s", outData)))) +} + +func (a *Azdg) Decrypt(sourceText string) string { + buf, err := base64.RawURLEncoding.DecodeString(sourceText) + if err != nil { + fmt.Printf("Decode(%q) failed: %v", sourceText, err) + return "" + } + + inputData := []byte(a.cipherEncode(fmt.Sprintf("%s", buf))) + loopCount := len(inputData) + outData := make([]byte, loopCount) + + var p int + for i, j := 0, 0; i < loopCount; i, j = i+2, j+1 { + p = p + 1 + outData[j] = inputData[i] ^ inputData[i+1] + } + + return fmt.Sprintf("%s", outData[:p]) +} + +func (a *Azdg) cipherEncode(sourceText string) string { + inputData := []byte(sourceText) + loopCount := len(inputData) + outData := make([]byte, loopCount) + for i := 0; i < loopCount; i++ { + outData[i] = inputData[i] ^ a.cipherHash[i%32] + } + return fmt.Sprintf("%s", outData) +} diff --git a/utils/encrypt/azdg_test.go b/utils/encrypt/azdg_test.go new file mode 100644 index 0000000..bab2ed9 --- /dev/null +++ b/utils/encrypt/azdg_test.go @@ -0,0 +1,26 @@ +package encrypt + +import ( + "fmt" + "testing" + "time" +) + +func TestAzdg(t *testing.T) { + t1 := time.Now() + amap := map[string]bool{} + azdg := NewAzdg("123") + for i := 0; i < 10000; i++ { + s := azdg.Encrypt("hello world 我是中文") + _, ok := amap[s] + if !ok { + amap[s] = true + } else { + t.Error("出错了") + } + + _ = azdg.Decrypt(s) + } + + fmt.Println(time.Now().Sub(t1)) +} diff --git a/utils/encrypt/encrypt_aes.go b/utils/encrypt/encrypt_aes.go new file mode 100644 index 0000000..1c31c3f --- /dev/null +++ b/utils/encrypt/encrypt_aes.go @@ -0,0 +1,73 @@ +package encrypt + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/hex" + "errors" + + "github.com/wonli/aqi/logger" +) + +// AesEncrypt AES Encrypt,CBC +func AesEncrypt(origData []byte, key string) ([]byte, error) { + encryptKey := getKey(key) + block, err := aes.NewCipher(encryptKey) + if err != nil { + return nil, err + } + + blockSize := block.BlockSize() + origData = pkcs7Padding(origData, blockSize) + blockMode := cipher.NewCBCEncrypter(block, encryptKey[:blockSize]) + encrypted := make([]byte, len(origData)) + blockMode.CryptBlocks(encrypted, origData) + return encrypted, nil +} + +// AesDecrypt AES Decrypt +func AesDecrypt(encrypted []byte, key string) ([]byte, error) { + encryptKey := getKey(key) + block, err := aes.NewCipher(encryptKey) + if err != nil { + return nil, err + } + + blockSize := block.BlockSize() + blockMode := cipher.NewCBCDecrypter(block, encryptKey[:blockSize]) + origData := make([]byte, len(encrypted)) + blockMode.CryptBlocks(origData, encrypted) + err, data := pkcs7UnPadding(origData) + return data, err +} + +func getKey(key string) []byte { + sha := sha1.New() + _, err := sha.Write([]byte(key)) + if err != nil { + logger.SugarLog.Errorf("Gen key fail %s", err.Error()) + return nil + } + + byteKey := []byte(hex.EncodeToString(sha.Sum(nil))) + return byteKey[:32] +} + +func pkcs7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padText...) +} + +func pkcs7UnPadding(origData []byte) (error, []byte) { + length := len(origData) + unPadding := int(origData[length-1]) + + if length-unPadding < 0 { + return errors.New("PKCS7 fail"), nil + } + + return nil, origData[:(length - unPadding)] +} diff --git a/utils/encrypt/encrypt_aes_test.go b/utils/encrypt/encrypt_aes_test.go new file mode 100644 index 0000000..6704fbc --- /dev/null +++ b/utils/encrypt/encrypt_aes_test.go @@ -0,0 +1,25 @@ +package encrypt + +import ( + "encoding/base64" + "fmt" + "testing" + "time" +) + +func TestAes(t *testing.T) { + t1 := time.Now() + for i := 0; i < 10000; i++ { + s, _ := AesEncrypt([]byte("hello world 我是中文"), "hello") + ss := base64.RawURLEncoding.EncodeToString(s) + //fmt.Println(ss) + + ss1, _ := base64.RawURLEncoding.DecodeString(ss) + _, err := AesDecrypt(ss1, "hello") + if err != nil { + t.Error(err.Error()) + } + } + + fmt.Println(time.Now().Sub(t1)) +} diff --git a/utils/encrypt/md5.go b/utils/encrypt/md5.go new file mode 100644 index 0000000..93fb58b --- /dev/null +++ b/utils/encrypt/md5.go @@ -0,0 +1,12 @@ +package encrypt + +import ( + "crypto/md5" + "encoding/hex" +) + +func MD5(v string) string { + m := md5.New() + m.Write([]byte(v)) + return hex.EncodeToString(m.Sum(nil)) +} diff --git a/utils/encrypt/sha256_rsa.go b/utils/encrypt/sha256_rsa.go new file mode 100644 index 0000000..5137a8b --- /dev/null +++ b/utils/encrypt/sha256_rsa.go @@ -0,0 +1,27 @@ +package encrypt + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "fmt" +) + +// SignSHA256WithRSA generates a signature for a string using the SHA256WithRSA algorithm with a given private key. +func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) { + if privateKey == nil { + return "", fmt.Errorf("private key should not be nil") + } + h := crypto.Hash.New(crypto.SHA256) + _, err = h.Write([]byte(source)) + if err != nil { + return "", nil + } + hashed := h.Sum(nil) + signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signatureByte), nil +} diff --git a/utils/file_exists.go b/utils/file_exists.go new file mode 100644 index 0000000..d2428b5 --- /dev/null +++ b/utils/file_exists.go @@ -0,0 +1,15 @@ +package utils + +import "os" + +func FileExists(filePath string) (bool, error) { + _, err := os.Stat(filePath) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/utils/filter_string.go b/utils/filter_string.go new file mode 100644 index 0000000..44a350d --- /dev/null +++ b/utils/filter_string.go @@ -0,0 +1,35 @@ +package utils + +import ( + "fmt" + "strconv" + "strings" + + "github.com/wonli/aqi/logger" +) + +func FilterStringId(stringIds string, filterId int) string { + var result []int + strArray := strings.Split(stringIds, ",") + for _, strId := range strArray { + strIdIntVal, err := strconv.Atoi(strId) + if err != nil { + logger.SugarLog.Errorf("translate id error %s", err.Error()) + continue + } + + if strIdIntVal != filterId { + result = append(result, strIdIntVal) + } + } + + if len(result) > 0 { + return arrayToString(result, ",") + } + + return "" +} + +func arrayToString(a []int, delim string) string { + return strings.Trim(strings.Replace(fmt.Sprint(a), " ", delim, -1), "[]") +} diff --git a/utils/format/format_byte.go b/utils/format/format_byte.go new file mode 100644 index 0000000..55399a7 --- /dev/null +++ b/utils/format/format_byte.go @@ -0,0 +1,18 @@ +package format + +import ( + "fmt" + "math" +) + +func Bites(size float64) string { + unit := []string{"b", "kb", "mb", "gb", "tb", "pb"} + s := math.Floor(math.Log(size) / math.Log(1024)) + i := int(s) + + if i < len(unit) { + return fmt.Sprintf("%.2f %s", size/math.Pow(1024, s), unit[i]) + } + + return fmt.Sprintf("%f %s", size, unit[0]) +} diff --git a/utils/format/format_byte_test.go b/utils/format/format_byte_test.go new file mode 100644 index 0000000..7eed022 --- /dev/null +++ b/utils/format/format_byte_test.go @@ -0,0 +1,8 @@ +package format + +import "testing" + +func TestBite(t *testing.T) { + bytes := Bites(2624954) + t.Log(bytes) +} diff --git a/utils/format/format_time.go b/utils/format/format_time.go new file mode 100644 index 0000000..bd17cf0 --- /dev/null +++ b/utils/format/format_time.go @@ -0,0 +1,64 @@ +package format + +import ( + "fmt" + "time" +) + +type FriendTime struct { + t int64 + format string + startTime int64 + suffix string +} + +func NewFriendTime(unixTime int64) *FriendTime { + return &FriendTime{ + t: unixTime, + format: "2006-01-02 15:04:05", + suffix: "前", + } +} + +func (f *FriendTime) SetFormat(format string) { + f.format = format +} + +func (f *FriendTime) SetStartTime(startTime int64) { + f.startTime = startTime +} + +func (f *FriendTime) SetSuffix(suffix string) { + f.suffix = suffix +} + +func (f *FriendTime) Format() string { + startTime := f.startTime + if startTime == 0 { + startTime = time.Now().Unix() + } + + delta := startTime - f.t + if delta < 63072000 { + conf := []struct { + Duration int64 + Label string + }{ + {31536000, "年"}, + {2592000, "个月"}, + {604800, "星期"}, + {86400, "天"}, + {3600, "小时"}, + {60, "分钟"}, + {1, "秒"}, + } + + for _, diff := range conf { + if c := delta / diff.Duration; c != 0 { + return fmt.Sprintf("%d%s%s", c, diff.Label, f.suffix) + } + } + } + + return time.Unix(f.t, 0).Format(f.format) +} diff --git a/utils/format/format_time_test.go b/utils/format/format_time_test.go new file mode 100644 index 0000000..7fed183 --- /dev/null +++ b/utils/format/format_time_test.go @@ -0,0 +1,11 @@ +package format + +import ( + "testing" +) + +func TestTime(t *testing.T) { + fTime := NewFriendTime(1691724579) + fTime.SetSuffix("哈哈") + t.Log(fTime.Format()) +} diff --git a/utils/geo/coordinate.go b/utils/geo/coordinate.go new file mode 100644 index 0000000..cb1257d --- /dev/null +++ b/utils/geo/coordinate.go @@ -0,0 +1,39 @@ +package geo + +import ( + "fmt" + "math" + "strconv" +) + +// Coordinate represents a specific location on Earth +type Coordinate struct { + Lat, Lng float64 +} + +// Constants needed for distance calculations +const ( + EarthRadius = 6371 * Kilometer + DoubleEarthRadius = 2 * EarthRadius + PiOver180 = math.Pi / 180 +) + +// DistanceBetween calculates the distance between two coordinates +func DistanceBetween(a, b Coordinate) Distance { + value := 0.5 - math.Cos((b.Lat-a.Lat)*PiOver180)/2 + math.Cos(a.Lat*PiOver180)*math.Cos(b.Lat*PiOver180)*(1-math.Cos((b.Lng-a.Lng)*PiOver180))/2 + return DoubleEarthRadius * Distance(math.Asin(math.Sqrt(value))) +} + +// DistanceTo calculates the distance from this coordinate to another coordinate +func (c Coordinate) DistanceTo(other Coordinate) Distance { + return DistanceBetween(c, other) +} + +// String implements Stringer, returns a string representation of the coordinate +func (c Coordinate) String() string { + return fmt.Sprintf( + "(%s, %s)", + strconv.FormatFloat(c.Lat, 'f', -1, 64), + strconv.FormatFloat(c.Lng, 'f', -1, 64), + ) +} diff --git a/utils/geo/distance.go b/utils/geo/distance.go new file mode 100644 index 0000000..2756eca --- /dev/null +++ b/utils/geo/distance.go @@ -0,0 +1,69 @@ +package geo + +import ( + "math" + "strconv" +) + +// Distance represents a spacial distance. Fundamentally, the underlying float64 represents the raw +// number of meters +type Distance float64 + +// MilesPerKilometer Constants for conversions +const MilesPerKilometer = 0.6213712 + +// Standard length constants +const ( + Millimeter = Distance(0.001) + Centimeter = Distance(0.01) + Meter = Distance(1) + Kilometer = Distance(1000) + Mile = Distance(1 / MilesPerKilometer * 1000) +) + +// Millimeters gets the number of total millimeters represented by the distance +func (d Distance) Millimeters() float64 { + return d.Meters() * 1000 +} + +// Centimeters gets the number of total centimeters represented by the distance +func (d Distance) Centimeters() float64 { + return d.Meters() * 100 +} + +// Meters gets the number of total meters represented by the distance +func (d Distance) Meters() float64 { + return float64(d) +} + +// Kilometers gets the number of total kilometers represented by the distance +func (d Distance) Kilometers() float64 { + return float64(d) / 1000 +} + +// Miles gets the number of total miles represented by the distance +func (d Distance) Miles() float64 { + return d.Kilometers() * MilesPerKilometer +} + +// String implements Stringer and returns a formatted string representation of the distance +func (d Distance) String() string { + if d < 0.01 { + return strconv.FormatFloat(d.Millimeters(), 'f', 2, 64) + "mm" + } + + if d < 1 { + return strconv.FormatFloat(d.Centimeters(), 'f', 2, 64) + "cm" + } + + if d < 100 { + return strconv.FormatFloat(d.Meters(), 'f', 2, 64) + "m" + } + + return strconv.FormatFloat(d.Kilometers(), 'f', 2, 64) + "km" +} + +func (d Distance) Equals(other, tolerance Distance) bool { + difference := math.Abs(float64(d - other)) + return difference <= float64(tolerance) +} diff --git a/utils/helper.go b/utils/helper.go new file mode 100644 index 0000000..6eb717a --- /dev/null +++ b/utils/helper.go @@ -0,0 +1,8 @@ +package utils + +func HidePhoneNumber(phoneNumber string) string { + if len(phoneNumber) != 11 { + return phoneNumber + } + return phoneNumber[:3] + "****" + phoneNumber[7:] +} diff --git a/utils/i18n/gorm.go b/utils/i18n/gorm.go new file mode 100644 index 0000000..bbece1a --- /dev/null +++ b/utils/i18n/gorm.go @@ -0,0 +1,37 @@ +package i18n + +import ( + "database/sql/driver" + "fmt" +) + +type GType struct { + Data any + lng string +} + +func NewGType(data any, lng string) *GType { + return >ype{Data: data, lng: lng} +} + +func (i *GType) GormDataType() string { + return "string" +} + +func (i *GType) Scan(value interface{}) error { + v, ok := value.([]byte) + if ok { + i.Data = string(v) + return nil + } + + return nil +} + +func (i *GType) String() string { + return fmt.Sprintf("%s", i.Data) +} + +func (i *GType) Value() (driver.Value, error) { + return driver.Value(i.Data), nil +} diff --git a/utils/ip/real_ip.go b/utils/ip/real_ip.go new file mode 100644 index 0000000..7655d74 --- /dev/null +++ b/utils/ip/real_ip.go @@ -0,0 +1,103 @@ +package ip + +import ( + "bytes" + "net" + "net/http" + "strings" +) + +type HostAndPort struct { + Host string + Port string +} + +// ipRange - a structure that holds the start and end of a range of ip addresses +type ipRange struct { + start net.IP + end net.IP +} + +// inRange - check to see if a given ip address is within a range given +func inRange(r ipRange, ipAddress net.IP) bool { + // strcmp type byte comparison + if bytes.Compare(ipAddress, r.start) >= 0 && bytes.Compare(ipAddress, r.end) < 0 { + return true + } + return false +} + +var privateRanges = []ipRange{ + { + start: net.ParseIP("10.0.0.0"), + end: net.ParseIP("10.255.255.255"), + }, + { + start: net.ParseIP("100.64.0.0"), + end: net.ParseIP("100.127.255.255"), + }, + { + start: net.ParseIP("172.16.0.0"), + end: net.ParseIP("172.31.255.255"), + }, + { + start: net.ParseIP("192.0.0.0"), + end: net.ParseIP("192.0.0.255"), + }, + { + start: net.ParseIP("192.168.0.0"), + end: net.ParseIP("192.168.255.255"), + }, + { + start: net.ParseIP("198.18.0.0"), + end: net.ParseIP("198.19.255.255"), + }, +} + +// isPrivateSubnet - check to see if this ip is in a private subnet +func isPrivateSubnet(ipAddress net.IP) bool { + // my use case is only concerned with ipv4 atm + if ipCheck := ipAddress.To4(); ipCheck != nil { + // iterate over all our ranges + for _, r := range privateRanges { + // check if this ip is in a private range + if inRange(r, ipAddress) { + return true + } + } + } + return false +} + +func GetIPAddress(r *http.Request) string { + for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} { + addresses := strings.Split(r.Header.Get(h), ",") + // march from right to left until we get a public address + // that will be the address right before our proxy. + for i := len(addresses) - 1; i >= 0; i-- { + ip := strings.TrimSpace(addresses[i]) + // header can contain spaces too, strip those out. + realIP := net.ParseIP(ip) + if !realIP.IsGlobalUnicast() || isPrivateSubnet(realIP) { + // bad address, go to next + continue + } + return ip + } + } + + return r.RemoteAddr +} + +func GetIpAndPort(r *http.Request) (error, *HostAndPort) { + ipAddr := GetIPAddress(r) + host, port, err := net.SplitHostPort(ipAddr) + if err != nil { + return err, nil + } + + return nil, &HostAndPort{ + Host: host, + Port: port, + } +} diff --git a/utils/jwt/jwt.go b/utils/jwt/jwt.go new file mode 100644 index 0000000..73e9636 --- /dev/null +++ b/utils/jwt/jwt.go @@ -0,0 +1,49 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt" + "github.com/spf13/viper" +) + +func GenerateToken(uid, authCode, channel string) (string, error) { + + if viper.GetString("jwtSecurity") == "" { + return "", errors.New("jwt security not configured") + } + + jwtLifetime := viper.GetDuration("jwtLifetime") + if jwtLifetime == 0 { + jwtLifetime = time.Hour * 48 + } + + expire := time.Now().Add(jwtLifetime) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &LoginClaims{ + UID: uid, + Channel: channel, + AuthCode: authCode, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expire.Unix(), + }, + }) + + return token.SignedString([]byte(viper.GetString("jwtSecurity"))) +} + +func ValidToken(t string) (*LoginClaims, error) { + token, err := jwt.ParseWithClaims(t, &LoginClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(viper.GetString("jwtSecurity")), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*LoginClaims); ok && token.Valid { + return claims, nil + } else { + return nil, errors.New("failed to validate token") + } +} diff --git a/utils/jwt/login_claims.go b/utils/jwt/login_claims.go new file mode 100644 index 0000000..bd08266 --- /dev/null +++ b/utils/jwt/login_claims.go @@ -0,0 +1,28 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt" +) + +type LoginClaims struct { + Channel string + UID string + AuthCode string + StandardClaims jwt.StandardClaims +} + +func (l *LoginClaims) Valid() error { + if l.UID == "" || l.Channel == "" { + return errors.New("illegal tokens") + } + + t := time.Now().Unix() + if t > l.StandardClaims.ExpiresAt { + return errors.New("token has expired") + } + + return nil +} diff --git a/utils/path.go b/utils/path.go new file mode 100644 index 0000000..4064162 --- /dev/null +++ b/utils/path.go @@ -0,0 +1,31 @@ +package utils + +import ( + "os" + "strings" + "time" +) + +func GetFilenamePath(prefix string) string { + //gen ymd path + p := time.Now().Format("/2006/01/02/") + if prefix == "" { + return p + } + + return strings.TrimRight(prefix, "/") + p +} + +// PathExists check path +func PathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, err +} diff --git a/utils/pem/pem.go b/utils/pem/pem.go new file mode 100644 index 0000000..b52640c --- /dev/null +++ b/utils/pem/pem.go @@ -0,0 +1,112 @@ +package pem + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "time" +) + +// LoadCertificate loads a certificate from its textual content. +func LoadCertificate(certificateStr string) (certificate *x509.Certificate, err error) { + block, _ := pem.Decode([]byte(certificateStr)) + if block == nil { + return nil, fmt.Errorf("decode certificate err") + } + + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("the kind of PEM should be CERTIFICATE") + } + + certificate, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate err:%s", err.Error()) + } + + return certificate, nil +} + +// LoadPrivateKey loads a private key from its textual content. +func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) { + block, _ := pem.Decode([]byte(privateKeyStr)) + if block == nil { + return nil, fmt.Errorf("decode private key err") + } + if block.Type != "PRIVATE KEY" { + return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key err:%s", err.Error()) + } + privateKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("%s is not rsa private key", privateKeyStr) + } + return privateKey, nil +} + +// LoadPublicKey loads a public key from its textual text content. +func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) { + block, _ := pem.Decode([]byte(publicKeyStr)) + if block == nil { + return nil, errors.New("decode public key error") + } + if block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY") + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse public key err:%s", err.Error()) + } + publicKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr) + } + return publicKey, nil +} + +// LoadCertificateWithPath loads a certificate from a file path. +func LoadCertificateWithPath(path string) (certificate *x509.Certificate, err error) { + certificateBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read certificate pem file err:%s", err.Error()) + } + return LoadCertificate(string(certificateBytes)) +} + +// LoadPrivateKeyWithPath loads a private key from a file path. +func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) { + privateKeyBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read private pem file err:%s", err.Error()) + } + return LoadPrivateKey(string(privateKeyBytes)) +} + +// LoadPublicKeyWithPath load public key +func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) { + publicKeyBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read certificate pem file err:%s", err.Error()) + } + return LoadPublicKey(string(publicKeyBytes)) +} + +// GetCertificateSerialNumber retrieves the serial number from a certificate. +func GetCertificateSerialNumber(certificate x509.Certificate) string { + return fmt.Sprintf("%X", certificate.SerialNumber.Bytes()) +} + +// IsCertificateExpired checks if the certificate is expired at a specific time. +func IsCertificateExpired(certificate x509.Certificate, now time.Time) bool { + return now.After(certificate.NotAfter) +} + +// IsCertificateValid checks if the certificate is valid at a specific time. +func IsCertificateValid(certificate x509.Certificate, now time.Time) bool { + return now.After(certificate.NotBefore) && now.Before(certificate.NotAfter) +} diff --git a/utils/pem/pem_test.go b/utils/pem/pem_test.go new file mode 100644 index 0000000..a85ef09 --- /dev/null +++ b/utils/pem/pem_test.go @@ -0,0 +1,234 @@ +// Copyright 2021 Tencent Inc. All rights reserved. + +package pem + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + testPemUtilCertificateStrWithoutTags = `MIIEhDCCA2ygAwIBAgIUDErvNLiTQIgbsrJSJjk9wuR/CUswDQYJKoZIhvcNAQEF +BQAwRjEbMBkGA1UEAwwSVGVucGF5LmNvbSBVc2VyIENBMRIwEAYDVQQLDAlDQSBD +ZW50ZXIxEzARBgNVBAoMClRlbnBheS5jb20wHhcNMjAwODA0MTAwMTI3WhcNMjUw +ODAzMTAwMTI3WjCBlTEYMBYGA1UEAwwPVGVucGF5LmNvbSBzaWduMSUwIwYJKoZI +hvcNAQkBFhZzdXBwb3J0QHN6aXRydXMuY29tLmNuMR0wGwYDVQQLDBRUZW5wYXku +Y29tIENBIENlbnRlcjETMBEGA1UECgwKVGVucGF5LmNvbTERMA8GA1UEBwwIU2hl +blpoZW4xCzAJBgNVBAYTAkNOMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAxTHf8ijqgucDt1PZEZ+FvGxR5po2fmw2pEzr2WK7KlbZYlNFMzo8OlAa38eU +SIWBL6E70gnfVEnKxdMxMgSLrhf8nwy48X90lpm6NX1PYVJX8i/B5n0rS9hgOB35 +x0EjwpOeMHTyx9tWW+5/JmWcaUfF587eGoUpHlT3kciB6nDV1/yNMHoDw5vB2E9w +LaiuGdWREhERYxsUCPyZZ1mltm5ClKAfrpPHWGSvarKI/G8ooDm3jXcgp2ajHNqB +ErWP9yBTes42IT7mjmG++Ss4WyB5H91eTy7Xdj1FNQYgDHtNMVmnoggwdV6X4OBx +biSJoKvpaghIoIdIlV7yTuDc/QIDAQABo4IBGDCCARQwCQYDVR0TBAIwADALBgNV +HQ8EBAMCBsAwTwYIKwYBBQUHAQEEQzBBMD8GCCsGAQUFBzAChjNvY3NwLGh0dHA6 +Ly9Zb3VyX1NlcnZlcl9OYW1lOlBvcnQvVG9wQ0EvbG9kcF9CYXNlRE4waQYDVR0f +BGIwYDBeoFygWoZYaHR0cDovLzkuMTkuMTYxLjQ6ODA4MC9Ub3BDQS9wdWJsaWMv +aXRydXNjcmw/Q0E9MzlCNDk3QUJDOEFFODg1NzQ1QkY1NjgxRTRGMDNCOEI2NDdG +MjhFQTAfBgNVHSMEGDAWgBROc805tvupF/jOiYapcvSklvPrLjAdBgNVHQ4EFgQU +YqSE0znX254pZnVDEe1rpCzs5u8wDQYJKoZIhvcNAQEFBQADggEBABvRHEHSW9KK +e6Dj5LGFO9Av20SWGMYVUNlwN4uWdoYZAesLl7Nog/znwHSVgyieqRUnKjm12L+h +J2mIKtwvoZhGWlN7KA6zLrlio/w22oZfGbKYvU8GEHAQ/N483HvH3byYltDTvd8R +YbxuS2D1GPYI3drRUXuEr9Qq8lcqHi0qVFvVKYm3VwXU+Rr7BOT9ebSGxH456IU8 +D17FsyucjhF/KRBGbN2pul0l7i1qMGkhNY18RkzrhWE8GB3PpaeWufOqgPgqUUPV +Bii2fY18BZkSIos9s4yYMcPrA4ApHG+Fpb2NgfRNICEvIdXbhnEVMeWEqmW5SD9y +mBlsiHvszAM=` + testPemUtilCertificateStr = `-----BEGIN CERTIFICATE----- +MIIEhDCCA2ygAwIBAgIUDErvNLiTQIgbsrJSJjk9wuR/CUswDQYJKoZIhvcNAQEF +BQAwRjEbMBkGA1UEAwwSVGVucGF5LmNvbSBVc2VyIENBMRIwEAYDVQQLDAlDQSBD +ZW50ZXIxEzARBgNVBAoMClRlbnBheS5jb20wHhcNMjAwODA0MTAwMTI3WhcNMjUw +ODAzMTAwMTI3WjCBlTEYMBYGA1UEAwwPVGVucGF5LmNvbSBzaWduMSUwIwYJKoZI +hvcNAQkBFhZzdXBwb3J0QHN6aXRydXMuY29tLmNuMR0wGwYDVQQLDBRUZW5wYXku +Y29tIENBIENlbnRlcjETMBEGA1UECgwKVGVucGF5LmNvbTERMA8GA1UEBwwIU2hl +blpoZW4xCzAJBgNVBAYTAkNOMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAxTHf8ijqgucDt1PZEZ+FvGxR5po2fmw2pEzr2WK7KlbZYlNFMzo8OlAa38eU +SIWBL6E70gnfVEnKxdMxMgSLrhf8nwy48X90lpm6NX1PYVJX8i/B5n0rS9hgOB35 +x0EjwpOeMHTyx9tWW+5/JmWcaUfF587eGoUpHlT3kciB6nDV1/yNMHoDw5vB2E9w +LaiuGdWREhERYxsUCPyZZ1mltm5ClKAfrpPHWGSvarKI/G8ooDm3jXcgp2ajHNqB +ErWP9yBTes42IT7mjmG++Ss4WyB5H91eTy7Xdj1FNQYgDHtNMVmnoggwdV6X4OBx +biSJoKvpaghIoIdIlV7yTuDc/QIDAQABo4IBGDCCARQwCQYDVR0TBAIwADALBgNV +HQ8EBAMCBsAwTwYIKwYBBQUHAQEEQzBBMD8GCCsGAQUFBzAChjNvY3NwLGh0dHA6 +Ly9Zb3VyX1NlcnZlcl9OYW1lOlBvcnQvVG9wQ0EvbG9kcF9CYXNlRE4waQYDVR0f +BGIwYDBeoFygWoZYaHR0cDovLzkuMTkuMTYxLjQ6ODA4MC9Ub3BDQS9wdWJsaWMv +aXRydXNjcmw/Q0E9MzlCNDk3QUJDOEFFODg1NzQ1QkY1NjgxRTRGMDNCOEI2NDdG +MjhFQTAfBgNVHSMEGDAWgBROc805tvupF/jOiYapcvSklvPrLjAdBgNVHQ4EFgQU +YqSE0znX254pZnVDEe1rpCzs5u8wDQYJKoZIhvcNAQEFBQADggEBABvRHEHSW9KK +e6Dj5LGFO9Av20SWGMYVUNlwN4uWdoYZAesLl7Nog/znwHSVgyieqRUnKjm12L+h +J2mIKtwvoZhGWlN7KA6zLrlio/w22oZfGbKYvU8GEHAQ/N483HvH3byYltDTvd8R +YbxuS2D1GPYI3drRUXuEr9Qq8lcqHi0qVFvVKYm3VwXU+Rr7BOT9ebSGxH456IU8 +D17FsyucjhF/KRBGbN2pul0l7i1qMGkhNY18RkzrhWE8GB3PpaeWufOqgPgqUUPV +Bii2fY18BZkSIos9s4yYMcPrA4ApHG+Fpb2NgfRNICEvIdXbhnEVMeWEqmW5SD9y +mBlsiHvszAM= +-----END CERTIFICATE-----` + testPemUtilCertificateSerial = `0C4AEF34B89340881BB2B25226393DC2E47F094B` + + testPemUtilPrivateKeyStrWithoutTags = `MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd +fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7 +6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1 +FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR +0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW +3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm +LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km +OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4 +C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN +0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF ++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y +gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam +AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi +hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2 +6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ +WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT +D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/ +aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2 +ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V +YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV +BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC +EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h +Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq ++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/ +jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe +BDa+8mDLkWu5nHEhOxy2JJZl` + testPemUtilPrivateKeyStr = `-----BEGIN TESTING KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZUJN33V+dSfvd +fL0Mu+39XrZNXFFMQSy1V15FpncHeV47SmV0TzTqZc7hHB0ddqAdDi8Z5k3TKqb7 +6sOwYr5TcAfuR6PIPaleyE0/0KrljBum2Isa2Nyq7Dgc3ElBQ6YN4l/a+DpvKaz1 +FSKmKrhLNskqokWVSlu4g8OlKlbPXQ9ibII14MZRQrrkTmHYHzfi7GXXM0thAKuR +0HNvyhTHBh4/lrYM3GaMvmWwkwvsMavnOex6+eioZHBOb1/EIZ/LzC6zuHArPpyW +3daGaZ1rtQB1vVzTyERAVVFsXXgBHvfFud3w3ShsJYk8JvMwK2RpJ5/gV0QSARcm +LDRUAlPzAgMBAAECggEBAMc7rDeUaXiWv6bMGbZ3BTXpg1FhdddnWUnYE8HfX/km +OFI7XtBHXcgYFpcjYz4D5787pcsk7ezPidAj58zqenuclmjKnUmT3pfbI5eCA2v4 +C9HnbYDrmUPK1ZcADtka4D6ScDccpNYNa1g2TFHzkIrEa6H+q7S3O2fqxY/DRVtN +0JIXalBb8daaqL5QVzSmM2BMVnHy+YITJWIkP2a3pKs9C0W65JGDsnG0wVrHinHF ++cnhFZIbaPEI//DAFMc9NkrWOKVRTEgcCUxCFaHOZVNxDWZD7A2ZfJB2rK6eg//y +gEiFDR2h6mTaDowMB4YF2n2dsIO4/dCG8vPHI20jn4ECgYEA/ZGu6lEMlO0XZnam +AZGtiNgLcCfM/C2ZERZE7QTRPZH1WdK92Al9ndldsswFw4baJrJLCmghjF/iG4zi +hhBvLnOLksnZUfjdumxoHDWXo2QBWbI5QsWIE7AuTiWgWj1I7X4fCXSQf6i+M/y2 +6TogQ7d0ANpZFyOkTNMn/tiJvLECgYEA22XqlamG/yfAGWery5KNH2DGlTIyd6xJ +WtJ9j3jU99lZ0bCQ5xhiBbU9ImxCi3zgTsoqLWgA/p00HhNFNoUcTl9ofc0G3zwT +D1y0ZzcnVKxGJdZ6ohW52V0hJStAigtjYAsUgjm7//FH7PiQDBDP1Wa6xSRkDQU/ +aSbQxvEE8+MCgYEA3bb8krW7opyM0XL9RHH0oqsFlVO30Oit5lrqebS0oHl3Zsr2 +ZGgoBlWBsEzk3UqUhTFwm/DhJLTSJ/TQPRkxnhQ5/mewNhS9C7yua7wQkzVmWN+V +YeUGTvDGDF6qDz12/vJAgSwDDRym8x4NcXD5tTw7mmNRcwIfL22SkysThIECgYAV +BgccoEoXWS/HP2/u6fQr9ZIR6eV8Ij5FPbZacTG3LlS1Cz5XZra95UgebFFUHHtC +EY1JHJY7z8SWvTH8r3Su7eWNaIAoFBGffzqqSVazfm6aYZsOvRY6BfqPHT3p/H1h +Tq6AbBffxrcltgvXnCTORjHPglU0CjSxVs7awW3AEQKBgB5WtaC8VLROM7rkfVIq ++RXqE5vtJfa3e3N7W3RqxKp4zHFAPfr82FK5CX2bppEaxY7SEZVvVInKDc5gKdG/ +jWNRBmvvftZhY59PILHO2X5vO4FXh7suEjy6VIh0gsnK36mmRboYIBGsNuDHjXLe +BDa+8mDLkWu5nHEhOxy2JJZl +-----END TESTING KEY-----` + + testPemUtilPublicKeyStrWithoutTags = `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2VCTd91fnUn73Xy9DLvt +/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXagHQ4vGeZN0yqm++rDsGK+ +U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOmDeJf2vg6byms9RUipiq4 +SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B834uxl1zNLYQCrkdBzb8oU +xwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGfy8wus7hwKz6clt3Whmmd +a7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtkaSef4FdEEgEXJiw0VAJT +8wIDAQAB` + testPemUtilPublicKeyStr = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2VCTd91fnUn73Xy9DLvt +/V62TVxRTEEstVdeRaZ3B3leO0pldE806mXO4RwdHXagHQ4vGeZN0yqm++rDsGK+ +U3AH7kejyD2pXshNP9Cq5YwbptiLGtjcquw4HNxJQUOmDeJf2vg6byms9RUipiq4 +SzbJKqJFlUpbuIPDpSpWz10PYmyCNeDGUUK65E5h2B834uxl1zNLYQCrkdBzb8oU +xwYeP5a2DNxmjL5lsJML7DGr5znsevnoqGRwTm9fxCGfy8wus7hwKz6clt3Whmmd +a7UAdb1c08hEQFVRbF14AR73xbnd8N0obCWJPCbzMCtkaSef4FdEEgEXJiw0VAJT +8wIDAQAB +-----END PUBLIC KEY-----` +) + +func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +func TestLoadCertificate(t *testing.T) { + type args struct { + certificateStr string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "load certificate from str success", + args: args{certificateStr: testPemUtilCertificateStr}, + wantErr: false, + }, + { + name: "error loading certificate without tags", + args: args{certificateStr: testPemUtilCertificateStrWithoutTags}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := LoadCertificate(tt.args.certificateStr) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestLoadPrivateKey(t *testing.T) { + type args struct { + privateKeyStr string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "load private key from str success", + args: args{privateKeyStr: testingKey(testPemUtilPrivateKeyStr)}, + wantErr: false, + }, + { + name: "error loading private key without tags", + args: args{privateKeyStr: testPemUtilPrivateKeyStrWithoutTags}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := LoadPrivateKey(tt.args.privateKeyStr) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestLoadPublicKey(t *testing.T) { + type args struct { + publicKeyStr string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "load public key from str success", + args: args{publicKeyStr: testPemUtilPublicKeyStr}, + wantErr: false, + }, + { + name: "error loading public key without tags", + args: args{publicKeyStr: testPemUtilPublicKeyStrWithoutTags}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := LoadPublicKey(tt.args.publicKeyStr) + assert.Equal(t, tt.wantErr, err != nil) + }) + } +} + +func TestGetCertificateSerialNumber(t *testing.T) { + certificate, err := LoadCertificate(testPemUtilCertificateStr) + require.NoError(t, err) + + serial := GetCertificateSerialNumber(*certificate) + + assert.Equal(t, testPemUtilCertificateSerial, serial) +} diff --git a/utils/random.go b/utils/random.go new file mode 100644 index 0000000..d48ae16 --- /dev/null +++ b/utils/random.go @@ -0,0 +1,16 @@ +package utils + +import ( + "crypto/rand" + "fmt" +) + +func GetRandomString(n int) string { + randBytes := make([]byte, n/2) + _, err := rand.Read(randBytes) + if err != nil { + return "" + } + + return fmt.Sprintf("%x", randBytes) +} diff --git a/utils/random_nickname.go b/utils/random_nickname.go new file mode 100644 index 0000000..2084c7a --- /dev/null +++ b/utils/random_nickname.go @@ -0,0 +1,38 @@ +package utils + +import ( + "math/rand" +) + +func GetRandomNickname() string { + nicknames := []string{ + "小兔", "蛋黄酥", "奶糖", "糯米团子", "芒果冰", "奶茶", + "小甜心", "棉花糖", "蓝莓布丁", "糖豆", + "蜜桃仙子", "小可爱", "蜜汁糖葫芦", "草莓蛋糕", + "奇异果冰沙", "樱花泡芙", "抹茶红豆", "小橘子", "香蕉酥", "水蜜桃", + "珍珠奶茶", "软糯甜筒", "杏仁曲奇", "蜜糖柚子", + "草莓巧克力", "牛奶小饼干", "蜜桃凉粉", "巧克力酥球", "椰子树", + "柠檬蛋挞", "蜜汁菠萝", "蓝莓芝士", "奇奇怪怪果冻", "绿色小葡萄", + "香草冰淇淋", "奶黄包", "芝士薯片", "小鲸鱼", + "榴莲糖", "红豆沙", "黑森林蛋糕", "脆皮炸鸡", "哈密瓜冰茶", + "蔓越莓曲奇", "奶香葡萄干", "草莓玛奇朵", "爆米花", + "蛋黄派", "牛奶软糖", "紫薯布丁", "可乐果冻", "葡萄柚汁", + "小丸子", "网红", "阿飞", "小喵咪", "文艺青年", "格调", "暖阳", "吐槽王", + "小野猫", "混迹者", "闲云野鹤", "懒癌晚期", "老干部", "奶油小生", "微笑天使", + "快乐侠", "黑桃Ace", "初心者", + "缘来如此", "小清新", "游戏玩家", "思维者", + "匠人", "森林之子", "锐气", "眼镜男", "摄影师", + "时尚达人", "自然主义", "慢活族", "独立思考", "不二神探", + "小资", "创作者", "云游者", "美食家", "旅行达人", + "品酒师", "画家", "宅舞者", "健身达人", "跳蚤市场", "剧情党", + "音乐人", "手作达人", "文艺小清新", "设计师", + "老司机", "资深玩家", "心灵鸡汤", "智者", "小贝壳", "沉稳", "生活行家", "老狼", "爱好者", + "积极向上", "精神领袖", "滴水穿石", "构思家", "厨艺大师", "亲和者", "风度翩翩", "经验分享", + "心灵导师", "文艺清新", + "知识分子", + } + + // random return nickname + nickname := nicknames[rand.Intn(len(nicknames))] + return nickname +} diff --git a/utils/random_nickname_test.go b/utils/random_nickname_test.go new file mode 100644 index 0000000..8f2ab91 --- /dev/null +++ b/utils/random_nickname_test.go @@ -0,0 +1,9 @@ +package utils + +import "testing" + +func TestGetRandomNickname(t *testing.T) { + for i := 0; i <= 100; i++ { + println(GetRandomNickname()) + } +} diff --git a/utils/regx/id.go b/utils/regx/id.go new file mode 100644 index 0000000..e35dd97 --- /dev/null +++ b/utils/regx/id.go @@ -0,0 +1,85 @@ +package regx + +import ( + "strconv" + "strings" + "time" +) + +func CheckID(idCard string, justCheckLength bool) bool { + // verify length + var lengthValidate bool + if len(idCard) == 18 { + lengthValidate = true + } else if len(idCard) == 15 { + lengthValidate = true + } else { + lengthValidate = false + } + + if justCheckLength { + return lengthValidate + } + + if !lengthValidate { + return false + } + + cityCode := map[string]bool{ + "11": true, "12": true, "13": true, "14": true, "15": true, + "21": true, "22": true, "23": true, + "31": true, "32": true, "33": true, "34": true, "35": true, "36": true, "37": true, + "41": true, "42": true, "43": true, "44": true, "45": true, "46": true, + "50": true, "51": true, "52": true, "53": true, "54": true, + "61": true, "62": true, "63": true, "64": true, "65": true, + "71": true, + "81": true, "82": true, + "91": true, + } + + // verify area + if _, ok := cityCode[idCard[0:2]]; !ok { + return false + } + + factor := []int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2} + verifyNumberList := []string{"1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"} + + makeVerifyBit := func(idCard string) string { + if len(idCard) != 17 { + return "" + } + + checksum := 0 + for i := 0; i < 17; i++ { + b, _ := strconv.Atoi(string(idCard[i])) + checksum += b * factor[i] + } + + mod := checksum % 11 + return verifyNumberList[mod] + } + + if len(idCard) == 15 { + if idCard[12:15] == "996" || idCard[12:15] == "997" || idCard[12:15] == "998" || idCard[12:15] == "999" { + idCard = idCard[0:6] + "18" + idCard[6:9] + } else { + idCard = idCard[0:6] + "19" + idCard[6:9] + } + + idCard += makeVerifyBit(idCard) + } else { + if strings.ToUpper(idCard[17:]) != strings.ToUpper(makeVerifyBit(idCard[0:17])) { + return false + } + } + + // verify birthday + birthDay := idCard[6:14] + d, err := time.Parse("20060102", birthDay) + if err != nil || d.Year() > time.Now().Year() || int(d.Month()) > 12 || d.Day() > 31 { + return false + } + + return true +} diff --git a/utils/regx/regx_date.go b/utils/regx/regx_date.go new file mode 100644 index 0000000..d4c72d7 --- /dev/null +++ b/utils/regx/regx_date.go @@ -0,0 +1,29 @@ +package regx + +import ( + "fmt" + "time" +) + +func ValidateDates(dates []string) error { + if dates == nil || len(dates) != 2 { + return fmt.Errorf("please provide a start date and an end date") + } + + startDate, err1 := time.Parse("2006-01-02", dates[0]) + endDate, err2 := time.Parse("2006-01-02", dates[1]) + + if err1 != nil { + return fmt.Errorf("invalid start date format: %v", err1) + } + + if err2 != nil { + return fmt.Errorf("onvalid end date format: %v", err2) + } + + if startDate.After(endDate) { + return fmt.Errorf("start date cannot be later than end date") + } + + return nil +} diff --git a/utils/regx/regx_phone.go b/utils/regx/regx_phone.go new file mode 100644 index 0000000..76327be --- /dev/null +++ b/utils/regx/regx_phone.go @@ -0,0 +1,23 @@ +package regx + +import ( + "fmt" + "regexp" +) + +func VerifyPhoneNumber(phoneNumber string) error { + if phoneNumber == "" { + return fmt.Errorf("phone number cannot be empty") + } + + matched, err := regexp.MatchString(`^1[3456789]\d{9}$`, phoneNumber) + if err != nil { + return err + } + + if !matched { + return fmt.Errorf("invalid phone number") + } + + return nil +} diff --git a/utils/regx/regx_time.go b/utils/regx/regx_time.go new file mode 100644 index 0000000..931c6d0 --- /dev/null +++ b/utils/regx/regx_time.go @@ -0,0 +1,30 @@ +package regx + +import ( + "fmt" + "time" +) + +func ValidateTimes(times []string) error { + if times == nil || len(times) != 2 { + return fmt.Errorf("incorrect input time format") + } + + startTime, err1 := time.Parse("15:04", times[0]) + endTime, err2 := time.Parse("15:04", times[1]) + + if err1 != nil { + return fmt.Errorf("invalid start time format: %v", err1) + } + + if err2 != nil { + return fmt.Errorf("invalid end time format: %v", err2) + } + + // Check if start time is after end time + if startTime.After(endTime) { + return fmt.Errorf("start time cannot be later than end time") + } + + return nil +} diff --git a/utils/rmb/yuan.go b/utils/rmb/yuan.go new file mode 100644 index 0000000..3008214 --- /dev/null +++ b/utils/rmb/yuan.go @@ -0,0 +1,58 @@ +package rmb + +import ( + "fmt" + "strconv" + "strings" +) + +func YuanToFen(yuan string, decimalPlaces float64) (int64, error) { + parts := strings.Split(yuan, ".") + integerPart, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, err + } + + fen := integerPart * 100 + + // If there is a decimal part + if len(parts) > 1 { + // Only get the first two decimal places, the rest will be ignored + decimalPart := parts[1] + if len(decimalPart) > 2 { + decimalPart = decimalPart[:2] + } + + decimalValue, err := strconv.ParseInt(decimalPart, 10, 64) + if err != nil { + return 0, err + } + + // Decide whether to add 10 or 100 based on the number of decimal places + if len(decimalPart) == 1 { + fen += decimalValue * 10 + } else { + fen += decimalValue + } + } + + // Calculate the percentage of the price + fen = int64(float64(fen) * decimalPlaces) + + return fen, nil +} + +func FloatYuanToFen(yuan float64, decimalPlaces int) (int64, error) { + formatted := fmt.Sprintf("%.*f", decimalPlaces, yuan) + + // Remove the decimal point + formatted = strings.Replace(formatted, ".", "", -1) + + // Convert the formatted string to int64 + fen, err := strconv.ParseInt(formatted, 10, 64) + if err != nil { + return 0, err + } + + return fen, nil +} diff --git a/utils/sensitive/README.md b/utils/sensitive/README.md new file mode 100644 index 0000000..a7d11b2 --- /dev/null +++ b/utils/sensitive/README.md @@ -0,0 +1,5 @@ +## 词库使用说明 + +Please place the word lists in the "words" directory, with the file format being TXT. Each line should contain one word, and you can create multiple word lists. + +### 请将词库放在words目录下,文件格式为txt,一行一个,支持创建多个词库 diff --git a/utils/sensitive/embed.go b/utils/sensitive/embed.go new file mode 100644 index 0000000..592a561 --- /dev/null +++ b/utils/sensitive/embed.go @@ -0,0 +1,6 @@ +package sensitive + +import "embed" + +//go:embed words +var Words embed.FS diff --git a/utils/sensitive/sensitive_init.go b/utils/sensitive/sensitive_init.go new file mode 100644 index 0000000..76d1d73 --- /dev/null +++ b/utils/sensitive/sensitive_init.go @@ -0,0 +1,33 @@ +package sensitive + +import ( + "strings" + "sync" + + "github.com/importcjj/sensitive" +) + +var Entity *sensitive.Filter +var sensitiveOnce sync.Once + +func Init() { + sensitiveOnce.Do(func() { + Entity = sensitive.New() + dd, err := Words.ReadDir("words") + if err != nil { + return + } + for _, v := range dd { + data, err := Words.ReadFile("words/" + v.Name()) + if err != nil { + continue + } + + Entity.AddWord(strings.Split(string(data), "\n")...) + } + }) +} + +func Replace(msg string) string { + return Entity.Replace(msg, '*') +} diff --git a/utils/sensitive/words/dict.txt b/utils/sensitive/words/dict.txt new file mode 100644 index 0000000..e69de29 diff --git a/utils/slice_compare.go b/utils/slice_compare.go new file mode 100644 index 0000000..38db8cc --- /dev/null +++ b/utils/slice_compare.go @@ -0,0 +1,33 @@ +package utils + +// IdCompare +// Change Increase Decrease +func IdCompare(ids1, ids2 []uint) (change []uint, increase []uint, decrease []uint) { + m := make(map[uint]bool) + for _, item := range ids1 { + m[item] = true + } + + sameMap := make(map[uint]bool) + for _, id := range ids2 { + _, ok := m[id] + if ok { + //changed + change = append(change, id) + sameMap[id] = true + } else { + //add + increase = append(increase, id) + } + } + + //decrease + for _, id := range ids1 { + _, ok := sameMap[id] + if !ok { + decrease = append(decrease, id) + } + } + + return change, increase, decrease +} diff --git a/utils/slice_find.go b/utils/slice_find.go new file mode 100644 index 0000000..7ed5d43 --- /dev/null +++ b/utils/slice_find.go @@ -0,0 +1,13 @@ +package utils + +func SliceFindIndex[T comparable](inputSlice []T, a T) int { + findIndex := -1 + for index, element := range inputSlice { + if element == a { + findIndex = index + break + } + } + + return findIndex +} diff --git a/utils/slice_unique.go b/utils/slice_unique.go new file mode 100644 index 0000000..4782762 --- /dev/null +++ b/utils/slice_unique.go @@ -0,0 +1,13 @@ +package utils + +func UniqueSlice[T comparable](inputSlice []T) []T { + uniqueSlice := make([]T, 0, len(inputSlice)) + seen := make(map[T]bool, len(inputSlice)) + for _, element := range inputSlice { + if !seen[element] { + uniqueSlice = append(uniqueSlice, element) + seen[element] = true + } + } + return uniqueSlice +} diff --git a/utils/string2id.go b/utils/string2id.go new file mode 100644 index 0000000..05ebb49 --- /dev/null +++ b/utils/string2id.go @@ -0,0 +1,48 @@ +package utils + +import ( + "strconv" + "strings" +) + +func StringToIntSlice(str string) []uint { + parts := strings.Split(str, ",") + seen := make(map[uint]bool) + nums := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + num, err := strconv.Atoi(part) + if err != nil { + continue + } + + uNum := uint(num) + if !seen[uNum] { + nums = append(nums, uNum) + seen[uNum] = true + } + } + + return nums +} + +func IntSliceToString(nums ...uint) string { + var strBuilder strings.Builder + seen := make(map[uint]bool) + for i, num := range nums { + if seen[num] { + continue + } + + if i > 0 { + strBuilder.WriteString(",") + } + strBuilder.WriteString(strconv.FormatUint(uint64(num), 10)) + seen[num] = true + } + return strBuilder.String() +} diff --git a/utils/string2slice.go b/utils/string2slice.go new file mode 100644 index 0000000..e07dcb2 --- /dev/null +++ b/utils/string2slice.go @@ -0,0 +1,24 @@ +package utils + +import ( + "strconv" + "strings" +) + +func String2Slice(s string) []string { + slc := strings.Split(s, ",") + for i := range slc { + slc[i] = strings.TrimSpace(slc[i]) + } + + return slc +} + +func UintsToString(numbers ...uint) string { + str := make([]string, len(numbers)) + for i, num := range numbers { + str[i] = strconv.FormatUint(uint64(num), 10) + } + + return strings.Join(str, ",") +} diff --git a/utils/string_limit.go b/utils/string_limit.go new file mode 100644 index 0000000..cdb638c --- /dev/null +++ b/utils/string_limit.go @@ -0,0 +1,14 @@ +package utils + +func LimitString(str string, limit int) string { + // Convert the string to a rune slice to properly handle Unicode characters + runes := []rune(str) + + if len(runes) > limit { + // If the length exceeds the limit + // truncate the slice to the specified length + runes = runes[:limit] + } + + return string(runes) +} diff --git a/utils/struct2map.go b/utils/struct2map.go new file mode 100644 index 0000000..8996971 --- /dev/null +++ b/utils/struct2map.go @@ -0,0 +1,30 @@ +package utils + +import ( + "reflect" +) + +func StructToMap(input any) map[string]any { + out := make(map[string]any) + v := reflect.ValueOf(input) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + // we only accept structs + if v.Kind() != reflect.Struct { + return nil + } + + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + fi := typ.Field(i) + // skip unexported fields + if fi.PkgPath != "" { + continue + } + + out[fi.Name] = v.Field(i).Interface() + } + return out +} diff --git a/utils/struct2map_json.go b/utils/struct2map_json.go new file mode 100644 index 0000000..1f7e422 --- /dev/null +++ b/utils/struct2map_json.go @@ -0,0 +1,42 @@ +package utils + +import ( + "encoding/json" + "reflect" +) + +type JsonMap struct { +} + +func (j *JsonMap) MarshalBinary() (data []byte, err error) { + return json.Marshal(data) +} + +func (j *JsonMap) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, j) +} + +func (j *JsonMap) StructToMap(input any) map[string]any { + out := make(map[string]any) + v := reflect.ValueOf(input) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + // we only accept structs + if v.Kind() != reflect.Struct { + return nil + } + + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + fi := typ.Field(i) + // skip unexported fields + if fi.PkgPath != "" { + continue + } + + out[fi.Name] = v.Field(i).Interface() + } + return out +} diff --git a/utils/t.go b/utils/t.go new file mode 100644 index 0000000..de621bb --- /dev/null +++ b/utils/t.go @@ -0,0 +1,138 @@ +package utils + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Seconds-based time units +const ( + Day = 24 * time.Hour + Week = 7 * Day + Month = 30 * Day + Year = 12 * Month + LongTime = 37 * Year +) + +// Time formats a time into a relative string. +// +// Time(someT) -> "3 weeks ago" +func Time(then time.Time) string { + //return RelTime(then, time.Now(), "ago", "from now") + return RelTime(then, time.Now(), "前", "刚刚") +} + +// A RelTimeMagnitude struct contains a relative time point at which +// the relative format of time will switch to a new format string. A +// slice of these in ascending order by their "D" field is passed to +// CustomRelTime to format durations. +// +// The Format field is a string that may contain a "%s" which will be +// replaced with the appropriate signed label (e.g. "ago" or "from +// now") and a "%d" that will be replaced by the quantity. +// +// The DivBy field is the amount of time the time difference must be +// divided by in order to display correctly. +// +// e.g. if D is 2*time.Minute and you want to display "%d minutes %s" +// DivBy should be time.Minute so whatever the duration is will be +// expressed in minutes. +type RelTimeMagnitude struct { + D time.Duration + Format string + DivBy time.Duration +} + +//var defaultMagnitudes = []RelTimeMagnitude{ +// {time.Second, "now", time.Second}, +// {2 * time.Second, "1 second %s", 1}, +// {time.Minute, "%d seconds %s", time.Second}, +// {2 * time.Minute, "1 minute %s", 1}, +// {time.Hour, "%d minutes %s", time.Minute}, +// {2 * time.Hour, "1 hour %s", 1}, +// {Day, "%d hours %s", time.Hour}, +// {2 * Day, "1 day %s", 1}, +// {Week, "%d days %s", Day}, +// {2 * Week, "1 week %s", 1}, +// {Month, "%d weeks %s", Week}, +// {2 * Month, "1 month %s", 1}, +// {Year, "%d months %s", Month}, +// {18 * Month, "1 year %s", 1}, +// {2 * Year, "2 years %s", 1}, +// {LongTime, "%d years %s", Year}, +// {math.MaxInt64, "a long while %s", 1}, +//} + +var defaultMagnitudes = []RelTimeMagnitude{ + {time.Second, "刚刚", time.Second}, + {2 * time.Second, "1 秒 %s", 1}, + {time.Minute, "%d 秒 %s", time.Second}, + {2 * time.Minute, "1 分钟 %s", 1}, + {time.Hour, "%d 分钟 %s", time.Minute}, + {2 * time.Hour, "1 小时 %s", 1}, + {Day, "%d 小时 %s", time.Hour}, + {2 * Day, "1 天 %s", 1}, + {Week, "%d 天 %s", Day}, + {2 * Week, "1 周 %s", 1}, + {Month, "%d 周 %s", Week}, + {2 * Month, "1 月 %s", 1}, + {Year, "%d 月 %s", Month}, + {18 * Month, "1 年 %s", 1}, + {2 * Year, "2 年 %s", 1}, + {LongTime, "%d years %s", Year}, + {math.MaxInt64, "%s", 1}, +} + +// RelTime formats a time into a relative string. +// +// It takes two times and two labels. In addition to the generic time +// delta string (e.g. 5 minutes), the labels are used applied so that +// the label corresponding to the smaller time is applied. +// +// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier" +func RelTime(a, b time.Time, albl, blbl string) string { + return CustomRelTime(a, b, albl, blbl, defaultMagnitudes) +} + +// CustomRelTime formats a time into a relative string. +// +// It takes two times two labels and a table of relative time formats. +// In addition to the generic time delta string (e.g. 5 minutes), the +// labels are used applied so that the label corresponding to the +// smaller time is applied. +func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string { + lbl := albl + diff := b.Sub(a) + + if a.After(b) { + lbl = blbl + diff = a.Sub(b) + } + + n := sort.Search(len(magnitudes), func(i int) bool { + return magnitudes[i].D > diff + }) + + if n >= len(magnitudes) { + n = len(magnitudes) - 1 + } + mag := magnitudes[n] + var args []interface{} + escaped := false + for _, ch := range mag.Format { + if escaped { + switch ch { + case 's': + args = append(args, lbl) + case 'd': + args = append(args, diff/mag.DivBy) + } + escaped = false + } else { + escaped = ch == '%' + } + } + return fmt.Sprintf(mag.Format, args...) +} diff --git a/utils/time.go b/utils/time.go new file mode 100644 index 0000000..447b009 --- /dev/null +++ b/utils/time.go @@ -0,0 +1,18 @@ +package utils + +import ( + "time" +) + +func FriendlyTime(t time.Time) string { + return Time(t) +} + +func TodayStartAndEndTime() (beginTime, endTime uint) { + timeStr := time.Now().Format("2006-01-02") + t, _ := time.ParseInLocation("2006-01-02", timeStr, time.Local) + beginTime = uint(t.Unix()) + endTime = beginTime + 86400 + + return beginTime, endTime +} diff --git a/utils/tree/trie_tree.go b/utils/tree/trie_tree.go new file mode 100644 index 0000000..a574d38 --- /dev/null +++ b/utils/tree/trie_tree.go @@ -0,0 +1,131 @@ +package tree + +type Node struct { + Val rune // The value of the node + Depth int // The depth of the node in the tree + Count int // Counts the number of branches + Payload interface{} // The payload associated with the node + Child map[rune]*Node // Children of the node, mapped by rune + IsWord bool // Flag indicating if this node marks the end of a complete string +} + +// NewNode new node +func NewNode() *Node { + return &Node{Child: make(map[rune]*Node)} +} + +type Trie struct { + Root *Node +} + +func NewTrie() *Trie { + return &Trie{Root: NewNode()} +} + +// Insert node +func (t *Trie) Insert(str string, p any) { + if len(str) == 0 { + return + } + + bt := []rune(str) + node := t.Root + for _, val := range bt { + child, ok := node.Child[val] + if !ok { + child = NewNode() + child.Val = val + node.Child[val] = child + node.Count += 1 + child.Depth = node.Depth + 1 + } + node = child + } + + node.Payload = p + node.IsWord = true +} + +func (t *Trie) Find(str string) (bool, interface{}) { + bt := []rune(str) + node := t.Root + + for _, val := range bt { + child, ok := node.Child[val] + if !ok { + return false, nil + } + node = child + } + return node.IsWord, node.Payload +} + +// FindAll finds all strings that start with the given prefix and returns their payloads. +func (t *Trie) FindAll(prefix string) []any { + bt := []rune(prefix) + node := t.Root + + for _, val := range bt { + child, ok := node.Child[val] + if !ok { + return nil + } + + node = child + } + + return t.collect(node) +} + +// collect Recursively collects all strings' payloads in the subtree rooted at the given node. +func (t *Trie) collect(node *Node) (payloads []any) { + if node.IsWord { + payloads = append(payloads, node.Payload) + } + + for _, childNode := range node.Child { + payloads = append(payloads, t.collect(childNode)...) + } + + return payloads +} + +// Del deletion of a node has the following cases: +// 1. Prefix deletion: Check if Count is greater than 0, then set IsWord to false. +// 3. String deletion: +// a. If there is no branching, delete the entire string. +// b. If there is branching, only delete the part that is not a common prefix. +func (t *Trie) Del(str string) { + bt := []rune(str) + if len(str) == 0 { + return + } + + node := t.Root + var lastBranch *Node + var delVal rune + + for index, val := range bt { + child, ok := node.Child[val] + if ok { + if child.Count > 1 { + lastBranch = child + delVal = bt[index+1] + } + } + node = child + } + + if node.Count > 0 { + // del prefix + node.IsWord = false + } else { + if lastBranch == nil { + // del charset + lastBranch = t.Root + delVal = bt[0] + } + delete(lastBranch.Child, delVal) + lastBranch.Count -= 1 + } +} diff --git a/utils/trie_sensitive.go b/utils/trie_sensitive.go new file mode 100644 index 0000000..60842c4 --- /dev/null +++ b/utils/trie_sensitive.go @@ -0,0 +1,172 @@ +package utils + +import ( + "regexp" + "strings" + + "github.com/mozillazg/go-pinyin" +) + +// SensitiveTrie 敏感词前缀树 +type SensitiveTrie struct { + replaceChar rune // 敏感词替换的字符 + root *TrieNode +} + +// NewSensitiveTrie 构造敏感词前缀树实例 +func NewSensitiveTrie() *SensitiveTrie { + return &SensitiveTrie{ + replaceChar: '*', + root: &TrieNode{End: false}, + } +} + +// AddWords 批量添加敏感词 +func (s *SensitiveTrie) AddWords(sensitiveWords ...string) { + a := pinyin.NewArgs() + for _, sensitiveWord := range sensitiveWords { + chnReg := regexp.MustCompile("[\u4e00-\u9fa5]") + if chnReg.Match([]byte(sensitiveWord)) { + // 只有中文才转 + lazyPy := pinyin.LazyPinyin(sensitiveWord, a) + if lazyPy != nil { + sFirstWords := "" + for _, p := range lazyPy { + sFirstWords += p[0:1] + } + + s.addWord(sFirstWords) + s.addWord(strings.Join(lazyPy, "")) + } + } + + s.addWord(sensitiveWord) + } +} + +// Match 查找替换发现的敏感词 +func (s *SensitiveTrie) Match(text string) (sensitiveWords []string, replaceText string) { + if s.root == nil { + return nil, text + } + + // 过滤特殊字符 + filteredText := s.filterSpecialChar(text) + sensitiveMap := make(map[string]*struct{}) // 利用map把相同的敏感词去重 + textChars := []rune(filteredText) + textCharsCopy := make([]rune, len(textChars)) + copy(textCharsCopy, textChars) + for i, textLen := 0, len(textChars); i < textLen; i++ { + trieNode := s.root.findChild(textChars[i]) + if trieNode == nil { + continue + } + + // 匹配到了敏感词的前缀,从后一个位置继续 + j := i + 1 + for ; j < textLen && trieNode != nil; j++ { + if trieNode.End { + // 完整匹配到了敏感词 + if _, ok := sensitiveMap[trieNode.Data]; !ok { + sensitiveWords = append(sensitiveWords, trieNode.Data) + } + sensitiveMap[trieNode.Data] = nil + + // 将匹配的文本的敏感词替换成 * + s.replaceRune(textCharsCopy, i, j) + } + trieNode = trieNode.findChild(textChars[j]) + } + + // 文本尾部命中敏感词情况 + if j == textLen && trieNode != nil && trieNode.End { + if _, ok := sensitiveMap[trieNode.Data]; !ok { + sensitiveWords = append(sensitiveWords, trieNode.Data) + } + sensitiveMap[trieNode.Data] = nil + s.replaceRune(textCharsCopy, i, textLen) + } + } + + if len(sensitiveWords) > 0 { + // 有敏感词 + replaceText = string(textCharsCopy) + } else { + // 没有则返回原来的文本 + replaceText = text + } + + return sensitiveWords, replaceText +} + +// AddWord 添加敏感词 +func (s *SensitiveTrie) addWord(sensitiveWord string) { + // 添加前先过滤一遍 + sensitiveWord = s.filterSpecialChar(sensitiveWord) + + // 将敏感词转换成utf-8编码后的rune类型(int32) + tireNode := s.root + sensitiveChars := []rune(sensitiveWord) + for _, charInt := range sensitiveChars { + // 添加敏感词到前缀树中 + tireNode = tireNode.addChild(charInt) + } + + tireNode.End = true + tireNode.Data = sensitiveWord +} + +// replaceRune 字符替换 +func (s *SensitiveTrie) replaceRune(chars []rune, begin int, end int) { + for i := begin; i < end; i++ { + chars[i] = s.replaceChar + } +} + +// filterSpecialChar 过滤特殊字符 +func (s *SensitiveTrie) filterSpecialChar(text string) string { + text = strings.ToLower(text) + text = strings.Replace(text, " ", "", -1) // 去除空格 + return text +} + +// TrieNode 敏感词前缀树节点 +type TrieNode struct { + childMap map[rune]*TrieNode // 本节点下的所有子节点 + Data string // 在最后一个节点保存完整的一个内容 + End bool // 标识是否最后一个节点 +} + +// addChild 前缀树添加字节点 +func (n *TrieNode) addChild(c rune) *TrieNode { + + if n.childMap == nil { + n.childMap = make(map[rune]*TrieNode) + } + + if trieNode, ok := n.childMap[c]; ok { + // 存在不添加了 + return trieNode + } else { + // 不存在 + n.childMap[c] = &TrieNode{ + childMap: nil, + End: false, + } + + return n.childMap[c] + } +} + +// findChild 前缀树查找字节点 +func (n *TrieNode) findChild(c rune) *TrieNode { + if n.childMap == nil { + return nil + } + + if trieNode, ok := n.childMap[c]; ok { + return trieNode + } + + return nil +} diff --git a/utils/trie_sensitive_test.go b/utils/trie_sensitive_test.go new file mode 100644 index 0000000..cd11d3d --- /dev/null +++ b/utils/trie_sensitive_test.go @@ -0,0 +1,21 @@ +package utils + +import ( + "log" + "testing" +) + +func TestSensitive(t *testing.T) { + sensitiveWords := []string{ + "牛大大", "撒比", + } + + trie := NewSensitiveTrie() + trie.AddWords(sensitiveWords...) + + content := "今天,牛大大挑战sb灰大大" + matchSensitiveWords, replaceText := trie.Match(content) + + log.Printf("%v", trie) + log.Println(matchSensitiveWords, replaceText) +} diff --git a/utils/wxc/wxbizmsgcrypt.go b/utils/wxc/wxbizmsgcrypt.go new file mode 100644 index 0000000..a36903b --- /dev/null +++ b/utils/wxc/wxbizmsgcrypt.go @@ -0,0 +1,315 @@ +package wxc + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/xml" + "fmt" + "math/rand" + "sort" + "strings" +) + +const letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +const ( + ValidateSignatureError int = -40001 + ParseXmlError int = -40002 + ComputeSignatureError int = -40003 + IllegalAesKey int = -40004 + ValidateCorpidError int = -40005 + EncryptAESError int = -40006 + DecryptAESError int = -40007 + IllegalBuffer int = -40008 + EncodeBase64Error int = -40009 + DecodeBase64Error int = -40010 + GenXmlError int = -40010 + ParseJsonError int = -40012 + GenJsonError int = -40013 + IllegalProtocolType int = -40014 +) + +type ProtocolType int + +const ( + XmlType ProtocolType = 1 +) + +type CryptError struct { + ErrCode int + ErrMsg string +} + +func NewCryptError(err_code int, err_msg string) *CryptError { + return &CryptError{ErrCode: err_code, ErrMsg: err_msg} +} + +type WXBizMsg4Recv struct { + Tousername string `xml:"ToUserName"` + Encrypt string `xml:"Encrypt"` + Agentid string `xml:"AgentID"` +} + +type CDATA struct { + Value string `xml:",cdata"` +} + +type WXBizMsg4Send struct { + XMLName xml.Name `xml:"xml"` + Encrypt CDATA `xml:"Encrypt"` + Signature CDATA `xml:"MsgSignature"` + Timestamp string `xml:"TimeStamp"` + Nonce CDATA `xml:"Nonce"` +} + +func NewWXBizMsg4Send(encrypt, signature, timestamp, nonce string) *WXBizMsg4Send { + return &WXBizMsg4Send{Encrypt: CDATA{Value: encrypt}, Signature: CDATA{Value: signature}, Timestamp: timestamp, Nonce: CDATA{Value: nonce}} +} + +type ProtocolProcessor interface { + parse(src_data []byte) (*WXBizMsg4Recv, *CryptError) + serialize(msg_send *WXBizMsg4Send) ([]byte, *CryptError) +} + +type WXBizMsgCrypt struct { + token string + encoding_aeskey string + receiver_id string + protocol_processor ProtocolProcessor +} + +type XmlProcessor struct { +} + +func (self *XmlProcessor) parse(src_data []byte) (*WXBizMsg4Recv, *CryptError) { + var msg4_recv WXBizMsg4Recv + err := xml.Unmarshal(src_data, &msg4_recv) + if nil != err { + return nil, NewCryptError(ParseXmlError, "xml to msg fail") + } + return &msg4_recv, nil +} + +func (self *XmlProcessor) serialize(msg4_send *WXBizMsg4Send) ([]byte, *CryptError) { + xml_msg, err := xml.Marshal(msg4_send) + if nil != err { + return nil, NewCryptError(GenXmlError, err.Error()) + } + return xml_msg, nil +} + +func NewWXBizMsgCrypt(token, encoding_aeskey, receiver_id string, protocol_type ProtocolType) *WXBizMsgCrypt { + var protocol_processor ProtocolProcessor + if protocol_type != XmlType { + panic("unsupport protocal") + } else { + protocol_processor = new(XmlProcessor) + } + + return &WXBizMsgCrypt{token: token, encoding_aeskey: (encoding_aeskey + "="), receiver_id: receiver_id, protocol_processor: protocol_processor} +} + +func (m *WXBizMsgCrypt) randString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} + +func (m *WXBizMsgCrypt) pKCS7Padding(plaintext string, block_size int) []byte { + padding := block_size - (len(plaintext) % block_size) + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + var buffer bytes.Buffer + buffer.WriteString(plaintext) + buffer.Write(padtext) + return buffer.Bytes() +} + +func (m *WXBizMsgCrypt) pKCS7Unpadding(plaintext []byte, block_size int) ([]byte, *CryptError) { + plaintext_len := len(plaintext) + if nil == plaintext || plaintext_len == 0 { + return nil, NewCryptError(DecryptAESError, "pKCS7Unpadding error nil or zero") + } + if plaintext_len%block_size != 0 { + return nil, NewCryptError(DecryptAESError, "pKCS7Unpadding text not a multiple of the block size") + } + padding_len := int(plaintext[plaintext_len-1]) + return plaintext[:plaintext_len-padding_len], nil +} + +func (m *WXBizMsgCrypt) cbcEncrypter(plaintext string) ([]byte, *CryptError) { + aeskey, err := base64.StdEncoding.DecodeString(m.encoding_aeskey) + if nil != err { + return nil, NewCryptError(DecodeBase64Error, err.Error()) + } + const block_size = 32 + pad_msg := m.pKCS7Padding(plaintext, block_size) + + block, err := aes.NewCipher(aeskey) + if err != nil { + return nil, NewCryptError(EncryptAESError, err.Error()) + } + + ciphertext := make([]byte, len(pad_msg)) + iv := aeskey[:aes.BlockSize] + + mode := cipher.NewCBCEncrypter(block, iv) + + mode.CryptBlocks(ciphertext, pad_msg) + base64_msg := make([]byte, base64.StdEncoding.EncodedLen(len(ciphertext))) + base64.StdEncoding.Encode(base64_msg, ciphertext) + + return base64_msg, nil +} + +func (m *WXBizMsgCrypt) cbcDecrypter(base64_encrypt_msg string) ([]byte, *CryptError) { + aeskey, err := base64.StdEncoding.DecodeString(m.encoding_aeskey) + if nil != err { + return nil, NewCryptError(DecodeBase64Error, err.Error()) + } + + encrypt_msg, err := base64.StdEncoding.DecodeString(base64_encrypt_msg) + if nil != err { + return nil, NewCryptError(DecodeBase64Error, err.Error()) + } + + block, err := aes.NewCipher(aeskey) + if err != nil { + return nil, NewCryptError(DecryptAESError, err.Error()) + } + + if len(encrypt_msg) < aes.BlockSize { + return nil, NewCryptError(DecryptAESError, "encrypt_msg size is not valid") + } + + iv := aeskey[:aes.BlockSize] + + if len(encrypt_msg)%aes.BlockSize != 0 { + return nil, NewCryptError(DecryptAESError, "encrypt_msg not a multiple of the block size") + } + + mode := cipher.NewCBCDecrypter(block, iv) + + mode.CryptBlocks(encrypt_msg, encrypt_msg) + + return encrypt_msg, nil +} + +func (m *WXBizMsgCrypt) calSignature(timestamp, nonce, data string) string { + sort_arr := []string{m.token, timestamp, nonce, data} + sort.Strings(sort_arr) + var buffer bytes.Buffer + for _, value := range sort_arr { + buffer.WriteString(value) + } + + sha := sha1.New() + sha.Write(buffer.Bytes()) + signature := fmt.Sprintf("%x", sha.Sum(nil)) + return string(signature) +} + +func (m *WXBizMsgCrypt) ParsePlainText(plaintext []byte) ([]byte, uint32, []byte, []byte, *CryptError) { + const block_size = 32 + plaintext, err := m.pKCS7Unpadding(plaintext, block_size) + if nil != err { + return nil, 0, nil, nil, err + } + + text_len := uint32(len(plaintext)) + if text_len < 20 { + return nil, 0, nil, nil, NewCryptError(IllegalBuffer, "plain is to small 1") + } + random := plaintext[:16] + msg_len := binary.BigEndian.Uint32(plaintext[16:20]) + if text_len < (20 + msg_len) { + return nil, 0, nil, nil, NewCryptError(IllegalBuffer, "plain is to small 2") + } + + msg := plaintext[20 : 20+msg_len] + receiver_id := plaintext[20+msg_len:] + + return random, msg_len, msg, receiver_id, nil +} + +func (m *WXBizMsgCrypt) VerifyURL(msg_signature, timestamp, nonce, echostr string) ([]byte, *CryptError) { + signature := m.calSignature(timestamp, nonce, echostr) + + if strings.Compare(signature, msg_signature) != 0 { + return nil, NewCryptError(ValidateSignatureError, "signature not equal") + } + + plaintext, err := m.cbcDecrypter(echostr) + if nil != err { + return nil, err + } + + _, _, msg, receiver_id, err := m.ParsePlainText(plaintext) + if nil != err { + return nil, err + } + + if len(m.receiver_id) > 0 && strings.Compare(string(receiver_id), m.receiver_id) != 0 { + fmt.Println(string(receiver_id), m.receiver_id, len(receiver_id), len(m.receiver_id)) + return nil, NewCryptError(ValidateCorpidError, "receiver_id is not equil") + } + + return msg, nil +} + +func (m *WXBizMsgCrypt) EncryptMsg(reply_msg, timestamp, nonce string) ([]byte, *CryptError) { + rand_str := m.randString(16) + var buffer bytes.Buffer + buffer.WriteString(rand_str) + + msg_len_buf := make([]byte, 4) + binary.BigEndian.PutUint32(msg_len_buf, uint32(len(reply_msg))) + buffer.Write(msg_len_buf) + buffer.WriteString(reply_msg) + buffer.WriteString(m.receiver_id) + + tmp_ciphertext, err := m.cbcEncrypter(buffer.String()) + if nil != err { + return nil, err + } + ciphertext := string(tmp_ciphertext) + + signature := m.calSignature(timestamp, nonce, ciphertext) + + msg4_send := NewWXBizMsg4Send(ciphertext, signature, timestamp, nonce) + return m.protocol_processor.serialize(msg4_send) +} + +func (m *WXBizMsgCrypt) DecryptMsg(msg_signature, timestamp, nonce string, post_data []byte) ([]byte, *CryptError) { + msg4_recv, crypt_err := m.protocol_processor.parse(post_data) + if nil != crypt_err { + return nil, crypt_err + } + + signature := m.calSignature(timestamp, nonce, msg4_recv.Encrypt) + + if strings.Compare(signature, msg_signature) != 0 { + return nil, NewCryptError(ValidateSignatureError, "signature not equal") + } + + plaintext, crypt_err := m.cbcDecrypter(msg4_recv.Encrypt) + if nil != crypt_err { + return nil, crypt_err + } + + _, _, msg, receiver_id, crypt_err := m.ParsePlainText(plaintext) + if nil != crypt_err { + return nil, crypt_err + } + + if len(m.receiver_id) > 0 && strings.Compare(string(receiver_id), m.receiver_id) != 0 { + return nil, NewCryptError(ValidateCorpidError, "receiver_id is not equil") + } + + return msg, nil +} diff --git a/utils/zip.go b/utils/zip.go new file mode 100644 index 0000000..5e79ee8 --- /dev/null +++ b/utils/zip.go @@ -0,0 +1,73 @@ +package utils + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func Unzip(src, dest string) ([]string, error) { + r, err := zip.OpenReader(src) + if err != nil { + return nil, err + } + + var unzipFiles []string + defer func() { + _ = r.Close() + }() + + _ = os.MkdirAll(dest, 0755) + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + + defer func() { + _ = rc.Close() + }() + + path := filepath.Join(dest, f.Name) + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", path) + } + + if f.FileInfo().IsDir() { + _ = os.MkdirAll(path, f.Mode()) + } else { + _ = os.MkdirAll(filepath.Dir(path), f.Mode()) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + defer func() { + _ = f.Close() + }() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + + unzipFiles = append(unzipFiles, path) + return nil + } + + for _, f := range r.File { + err = extractAndWriteFile(f) + if err != nil { + return nil, err + } + } + + return unzipFiles, nil +} diff --git a/validate/util_gin.go b/validate/util_gin.go new file mode 100644 index 0000000..2579b35 --- /dev/null +++ b/validate/util_gin.go @@ -0,0 +1,23 @@ +package validate + +import ( + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" +) + +var GinBinding *Manager + +func GinValidator() error { + // 修改gin框架中的Validator引擎属性 + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + v.RegisterTagNameFunc(vc.tagNameFunc) + GinBinding = &Manager{ + Validator: v, + Trans: vc.getTranslator(), + } + + return vc.registerTrans(v, GinBinding.Trans) + } + + return nil +} diff --git a/validate/util_normal.go b/validate/util_normal.go new file mode 100644 index 0000000..5dbd103 --- /dev/null +++ b/validate/util_normal.go @@ -0,0 +1,32 @@ +package validate + +import ( + "sync" + + "github.com/go-playground/validator/v10" +) + +var once sync.Once +var normalManager *Manager + +func Normal(language string) *Manager { + once.Do(func() { + cc := InitTranslator(language) + + validate := validator.New() + validate.RegisterTagNameFunc(cc.tagNameFunc) + + translator := cc.getTranslator() + err := cc.registerTrans(validate, translator) + if err != nil { + panic(err) + } + + normalManager = &Manager{ + Validator: validate, + Trans: translator, + } + }) + + return normalManager +} diff --git a/validate/validate_init.go b/validate/validate_init.go new file mode 100644 index 0000000..036bcda --- /dev/null +++ b/validate/validate_init.go @@ -0,0 +1,97 @@ +package validate + +import ( + "os" + "reflect" + "strings" + "sync" + + "github.com/fatih/color" + "github.com/go-playground/locales" + "github.com/go-playground/locales/en" + "github.com/go-playground/locales/zh" + "github.com/go-playground/validator/v10" + + ut "github.com/go-playground/universal-translator" + enT "github.com/go-playground/validator/v10/translations/en" + zhT "github.com/go-playground/validator/v10/translations/zh" +) + +var sg sync.Once +var vc *validatorConfig + +type validatorConfig struct { + locale string + zh locales.Translator + en locales.Translator +} + +// InitTranslator validator默认仅支持中英文 +func InitTranslator(locale string) *validatorConfig { + sg.Do(func() { + zhl := zh.New() // 中文翻译器 + enl := en.New() // 英文翻译器 + + //赋值给valid + vc = &validatorConfig{ + locale: locale, + zh: zhl, + en: enl, + } + }) + + return vc +} + +// 处理字段名称 +// 中文使用label标签,其他语言label+语言名称,没有设置时使用json名称 +func (a *validatorConfig) tagNameFunc(fld reflect.StructField) string { + var name string + switch a.locale { + case "zh": + name = fld.Tag.Get("label") + default: + name = fld.Tag.Get("label_" + a.locale) + if name == "" { + name = strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + } + } + + return name +} + +// translator +func (a *validatorConfig) getTranslator() ut.Translator { + // 第一个参数是备用(fallback)的语言环境 + // 后面的参数是应该支持的语言环境(支持多个) + // uni := ut.New(zhl, zhl) 也是可以的 + uni := ut.New(a.en, a.zh, a.en) + + // locale 通常取决于 http 请求头的 'Accept-Language' + // 也可以使用 uni.FindTranslator(...) 传入多个locale进行查找 + trans, ok := uni.GetTranslator(a.locale) + if !ok { + color.Red("uni.GetTranslator(%s) failed", a.locale) + os.Exit(0) + } + + return trans +} + +// registerTrans +func (a *validatorConfig) registerTrans(v *validator.Validate, trans ut.Translator) error { + var err error + switch a.locale { + case "en": + err = enT.RegisterDefaultTranslations(v, trans) + case "zh": + err = zhT.RegisterDefaultTranslations(v, trans) + default: + err = enT.RegisterDefaultTranslations(v, trans) + } + + return err +} diff --git a/validate/validate_manager.go b/validate/validate_manager.go new file mode 100644 index 0000000..1ec6054 --- /dev/null +++ b/validate/validate_manager.go @@ -0,0 +1,79 @@ +package validate + +import ( + "fmt" + + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" +) + +type Manager struct { + //Trans + Trans ut.Translator + + //允许外部自定义验证方法 + Validator *validator.Validate +} + +// RegisterValidator 自定义简单验证方法 +func (g *Manager) RegisterValidator(tag, errMsg string, fn validator.Func) error { + err := g.Validator.RegisterValidation(tag, fn) + if err != nil { + return err + } + + return g.Validator.RegisterTranslation(tag, g.Trans, + func(ut ut.Translator) error { + return ut.Add(tag, errMsg, true) + }, + + func(ut ut.Translator, fe validator.FieldError) string { + t, _ := ut.T(tag, fe.Tag()) + return t + }, + ) +} + +// RegisterValidatorFunc 自定义方法封装 +func (g *Manager) RegisterValidatorFunc(tag string, + fn validator.Func, rFn validator.RegisterTranslationsFunc, tFn validator.TranslationFunc) error { + err := g.Validator.RegisterValidation(tag, fn) + if err != nil { + return err + } + + return g.Validator.RegisterTranslation(tag, g.Trans, rFn, tFn) +} + +// Translator 语言翻译 +func (g *Manager) Translator(e error) error { + errs, ok := e.(validator.ValidationErrors) + if !ok { + return e + } + + if g.Trans == nil { + return errs + } + + errorsTranslations := errs.Translate(g.Trans) + for _, err := range errs { + namespace := err.Namespace() + if s, ok := errorsTranslations[namespace]; ok { + return fmt.Errorf(s) + } + } + + return errs +} + +// Validate 执行验证并翻译配置指定的语言 +func (g *Manager) Validate(dataStruct any) error { + //处理数据 + err := g.Validator.Struct(dataStruct) + if err != nil { + return g.Translator(err) + } + + return nil +} diff --git a/worker/task.go b/worker/task.go new file mode 100644 index 0000000..ad6ec89 --- /dev/null +++ b/worker/task.go @@ -0,0 +1,12 @@ +package worker + +import ( + "context" + + "github.com/hibiken/asynq" +) + +type Task interface { + GetName() string + ProcessTask(ctx context.Context, task *asynq.Task) error +} diff --git a/worker/worker.go b/worker/worker.go new file mode 100644 index 0000000..45980f7 --- /dev/null +++ b/worker/worker.go @@ -0,0 +1,82 @@ +package worker + +import ( + "fmt" + "os" + + "github.com/hibiken/asynq" + + "github.com/wonli/aqi/logger" +) + +var Engine *EngineClient + +type EngineClient struct { + Running bool + Router map[string]Task + + Opt *asynq.RedisClientOpt + Server *asynq.Server +} + +func InitEngine(rds *asynq.RedisClientOpt, config asynq.Config) *EngineClient { + server := asynq.NewServer(rds, config) + Engine = &EngineClient{ + Opt: rds, + Server: server, + Router: map[string]Task{}, + } + + return Engine +} + +func (e *EngineClient) Register(t Task) { + if e.Running { + logger.SugarLog.Errorf("please register in router") + return + } + + name := t.GetName() + if name == "" { + logger.SugarLog.Errorf("failed to register, name is empty") + return + } + + e.Router[name] = t +} + +func (e *EngineClient) Add(task *asynq.Task) error { + t := task.Type() + if t == "" { + return fmt.Errorf("task type is undefined") + } + + _, ok := e.Router[t] + if !ok { + return fmt.Errorf("task not registered") + } + + client := asynq.NewClient(e.Opt) + defer client.Close() + + _, err := client.Enqueue(task) + if err != nil { + return err + } + + return nil +} + +func (e *EngineClient) Run() { + s := asynq.NewServeMux() + for name, handler := range e.Router { + s.Handle(name, handler) + } + + e.Running = true + err := e.Server.Run(s) + if err != nil { + logger.SugarLog.Errorf("failed to start asynq service: :%s", err.Error()) + os.Exit(0) + } +} diff --git a/ws/client.go b/ws/client.go new file mode 100644 index 0000000..6c2fcfd --- /dev/null +++ b/ws/client.go @@ -0,0 +1,188 @@ +package ws + +import ( + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" + + "github.com/wonli/aqi/logger" +) + +type Client struct { + Hub *Hubc `json:"-"` + Conn net.Conn `json:"-"` + Send chan []byte `json:"-"` + Endpoint string `json:"-"` //入口地址 + OnceId string `json:"-"` //临时ID,扫码登录等场景作为客户端唯一标识 + Disconnecting bool `json:"-"` //已被设置为断开状态(消息发送完之后断开连接) + SyncMsg bool `json:"-"` //是否接收消息 + LastMsgId int `json:"-"` //最后一条消息ID + RequiredValid bool `json:"-"` //人机验证标识 + Validated bool `json:"-"` //是否已验证 + ValidExpiry time.Time `json:"-"` //验证有效期 + ValidCacheData any `json:"-"` //验证相关缓存数据 + AuthCode string `json:"-"` //用于校验JWT中的code,如果相等识别为同一个用户的网络地址变更 + ErrorCount int `json:"-"` //错误次数 + Closed bool `json:"-"` //是否已经关闭 + + User *User `json:"user,omitempty"` //关联用户 + Scope string `json:"scope"` //登录jwt scope, 用于判断用户从哪里登录的 + AppId string `json:"appId"` //登录应用Id + StoreId uint `json:"storeId"` //店铺ID + MerchantId uint `json:"merchantId"` //商户ID + TenantId uint `json:"tenantId"` //租户ID + Platform string `json:"platform"` //登录平台 + GroupId string `json:"groupId"` //用户分组Id + IsLogin bool `json:"isLogin"` //是否已登录 + LoginAction string `json:"loginAction"` //登录动作 + ForceDialogId string `json:"forceDialogId"` //打开聊天界面的会话ID + IpAddress string `json:"ipAddress"` //IP地址 + IpLocation string `json:"ipLocation"` //通过IP转换获得的地理位置 + IpConnAddr string `json:"IpConnAddr"` //conn连接IP地址 + ConnectionTime time.Time `json:"connectionTime"` + LastRequestTime time.Time `json:"lastRequestTime"` + LastHeartbeatTime time.Time `json:"lastHeartbeatTime"` + + mu sync.RWMutex + Keys map[string]any +} + +// Reader 读取 +func (c *Client) Reader() { + defer func() { + c.Hub.Disconnect <- c + }() + + for { + request, op, err := wsutil.ReadClientData(c.Conn) + if err != nil { + c.Log("xx", "Error reading data", err.Error()) + return + } + + if op == ws.OpText { + req := string(request) + c.Log("<-", req) + go Dispatcher(c, req) + } else if op == ws.OpPing { + err = wsutil.WriteServerMessage(c.Conn, ws.OpPong, nil) + if err != nil { + c.Log("xx", "Reply pong", err.Error()) + } + } else { + c.Log("xx", "Unrecognized action") + } + } +} + +// Write 发送 +func (c *Client) Write() { + timer := time.NewTicker(5 * time.Second) + defer func() { + timer.Stop() + c.Hub.Disconnect <- c + }() + + for { + select { + case msg, ok := <-c.Send: + if !ok { + return + } + + err := wsutil.WriteServerMessage(c.Conn, ws.OpText, msg) + if err != nil { + c.Log("xx", "Send msg error", err.Error()) + return + } + + //如果设置为断开状态 + //在消息发送完成后将断开与服务器的连接 + if c.Disconnecting { + return + } + + c.Log("->", string(msg)) + case <-timer.C: + err := wsutil.WriteServerMessage(c.Conn, ws.OpPing, nil) + if err != nil { + c.Log("xx", "Error actively pinging the client", err.Error()) + return + } + + c.LastHeartbeatTime = time.Now() + if c.User != nil { + c.User.LastHeartbeatTime = c.LastHeartbeatTime + } + } + } +} + +// Log websocket日志 +func (c *Client) Log(symbol string, msg ...string) { + s := strings.Join(msg, ", ") + if c.IsLogin { + s = fmt.Sprintf("%s %s [%s-%s] %s", c.Conn.RemoteAddr(), symbol, c.User.Suid, c.AppId, s) + } else { + s = fmt.Sprintf("%s %s %s", c.Conn.RemoteAddr(), symbol, s) + } + + if len(s) > 300 { + logger.SugarLog.Info(s[:300] + "...") + } else { + logger.SugarLog.Info(s) + } +} + +// SendMsg 把消息加入发送队列 +func (c *Client) SendMsg(msg []byte) { + defer func() { + if err := recover(); err != nil { + c.Hub.Disconnect <- c + logger.SugarLog.Errorf("SendMsg recover error(%s): %s", c.IpConnAddr, err) + } + }() + + c.Send <- msg +} + +// SendRawMsg 构造消息再发送 +func (c *Client) SendRawMsg(code int, action, msg string, data any) { + a := &Action{ + Action: action, + Code: code, + Msg: msg, + Data: data, + } + + c.SendMsg(a.Encode()) +} + +// Close 关闭客户端 +func (c *Client) Close() { + defer func() { + if err := recover(); err != nil { + c.Log("xx", "recover!! -> ", fmt.Sprintf("%v", err)) + return + } + }() + + if !c.Closed { + //防止重复关闭 + c.Closed = true + + //关闭通道 + close(c.Send) + + //关闭网络连接 + _ = c.Conn.Close() + + //打印日志 + c.Log("xx", fmt.Sprintf("Close client -> %s", c.IpConnAddr)) + } +} diff --git a/ws/client_keyval.go b/ws/client_keyval.go new file mode 100644 index 0000000..1d62dab --- /dev/null +++ b/ws/client_keyval.go @@ -0,0 +1,22 @@ +package ws + +func (c *Client) SetKey(key string, value any) { + c.mu.Lock() + defer c.mu.Unlock() + if c.Keys == nil { + c.Keys = make(map[string]any) + } + + c.Keys[key] = value +} + +func (c *Client) GetKey(key string) *Value { + c.mu.RLock() + defer c.mu.RUnlock() + value, exists := c.Keys[key] + if !exists { + return &Value{} + } + + return &Value{data: value} +} diff --git a/ws/client_stack.go b/ws/client_stack.go new file mode 100644 index 0000000..a9e4d6f --- /dev/null +++ b/ws/client_stack.go @@ -0,0 +1,51 @@ +package ws + +import "sync" + +type ( + Stack struct { + top *node + length int + lock *sync.RWMutex + } + + node struct { + value any + prev *node + } +) + +func NewStack() *Stack { + return &Stack{ + top: nil, + length: 0, + lock: &sync.RWMutex{}, + } +} + +func (s *Stack) Len() int { + return s.length +} + +func (s *Stack) Pop() any { + s.lock.Lock() + defer s.lock.Unlock() + + if s.length == 0 { + return nil + } + + n := s.top + s.top = n.prev + s.length = s.length - 1 + return n.value +} + +func (s *Stack) Push(value any) { + s.lock.Lock() + defer s.lock.Unlock() + + n := &node{value: value, prev: s.top} + s.top = n + s.length = s.length + 1 +} diff --git a/ws/context.go b/ws/context.go new file mode 100644 index 0000000..4317f3f --- /dev/null +++ b/ws/context.go @@ -0,0 +1,34 @@ +package ws + +import ( + "math" +) + +type Context struct { + Client *Client + Action string + Params string + Response *Action + Server *Server + + index int8 + handlers HandlersChain + + language string + defaultLng string +} + +const abortIndex int8 = math.MaxInt8 / 2 + +func (c *Context) Next() { + c.index++ + for c.index < int8(len(c.handlers)) { + c.handlers[c.index](c) + c.index++ + } +} + +// Abort 放弃调用后续方法 +func (c *Context) Abort() { + c.index = abortIndex +} diff --git a/ws/context_binding.go b/ws/context_binding.go new file mode 100644 index 0000000..92106d9 --- /dev/null +++ b/ws/context_binding.go @@ -0,0 +1,40 @@ +package ws + +import ( + "encoding/json" + + "github.com/wonli/aqi/validate" +) + +func (c *Context) BindingJson(s any) error { + err := json.Unmarshal([]byte(c.Params), s) + if err != nil { + return err + } + + return nil +} + +func (c *Context) BindingJsonPath(s any, path string) error { + data := c.Get(path) + err := json.Unmarshal([]byte(data), s) + if err != nil { + return err + } + + return nil +} + +func (c *Context) BindingValidateJson(s any) error { + err := json.Unmarshal([]byte(c.Params), s) + if err != nil { + return err + } + + err = validate.Normal(c.language).Validate(s) + if err != nil { + return err + } + + return nil +} diff --git a/ws/context_i18n.go b/ws/context_i18n.go new file mode 100644 index 0000000..3790f8a --- /dev/null +++ b/ws/context_i18n.go @@ -0,0 +1,9 @@ +package ws + +func (c *Context) i18nLoad(code int, msg string) string { + return languageInit(c).load(code, msg) +} + +func (c *Context) i18nSet(code int, msg string) { + languageInit(c).set(code, msg) +} diff --git a/ws/context_lang.go b/ws/context_lang.go new file mode 100644 index 0000000..f482345 --- /dev/null +++ b/ws/context_lang.go @@ -0,0 +1,109 @@ +package ws + +import ( + "fmt" + "hash/fnv" + "os" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/wonli/aqi/utils" + + "github.com/wonli/aqi/logger" +) + +var langMap sync.Map + +type langInfo struct { + ctx *Context + + langData map[string]string + filePath string +} + +func languageInit(ctx *Context) *langInfo { + lang := ctx.language + value, ok := langMap.Load(lang) + if ok { + return value.(*langInfo) + } + + languageFile := fmt.Sprintf("%s/i18n/%s.yaml", ctx.Server.dataPath, lang) + err := utils.CreateFileIfNotExists(languageFile) + if err != nil { + return nil + } + + file, err := os.ReadFile(languageFile) + if err != nil { + logger.SugarLog.Errorf("Failed to load language file:%s", err.Error()) + return nil + } + + res := &langInfo{ + ctx: ctx, + langData: make(map[string]string), + filePath: languageFile, + } + + if len(file) == 0 { + return res + } + + err = yaml.Unmarshal(file, &res.langData) + if err != nil { + logger.SugarLog.Errorf("Failed to unmarshal language file:%s", err.Error()) + return nil + } + + langMap.Store(lang, res) + return res +} + +func (info *langInfo) set(code int, msg string) { + mHash := info.getMsgHashKey(msg) + cacheKey := fmt.Sprintf("%s.%d.%s", info.ctx.Action, code, mHash) + _, ok := info.langData[cacheKey] + if !ok { + info.langData[cacheKey] = msg + + // 将更新后的语言包数据写入文件 + if err := writeLangFile(info.filePath, info.langData); err != nil { + logger.SugarLog.Errorf("Failed to update language file: %s", err.Error()) + } + } +} + +func (info *langInfo) load(code int, msg string) string { + mHash := info.getMsgHashKey(msg) + cacheKey := fmt.Sprintf("%s.%d.%s", info.ctx.Action, code, mHash) + s, ok := info.langData[cacheKey] + if ok { + return s + } + + return msg +} + +func (info *langInfo) getMsgHashKey(msg string) string { + h := fnv.New32a() + _, err := h.Write([]byte(msg)) + if err != nil { + logger.SugarLog.Errorf("Failed to retrieve hint information hash data:%s", err.Error()) + return "1" + } + + return fmt.Sprintf("%04d", h.Sum32()%10000) +} + +func writeLangFile(filePath string, data map[string]string) error { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + encoder := yaml.NewEncoder(file) + return encoder.Encode(data) +} diff --git a/ws/context_lang_i18n_hash_test.go b/ws/context_lang_i18n_hash_test.go new file mode 100644 index 0000000..c2b1be9 --- /dev/null +++ b/ws/context_lang_i18n_hash_test.go @@ -0,0 +1,41 @@ +package ws + +import ( + "fmt" + "hash/fnv" + "log" + "testing" +) + +func TestHash(t *testing.T) { + + msgList := []string{ + "1", + "2", + "a", + "aa", + "123", + "12", + "hello", + "hella", + "用户注册失败", + "用户注册失败1", + "用户注册没失败", + "注册失败", + "失败", + "用户失败", + } + + for _, msg := range msgList { + h := fnv.New32a() + _, err := h.Write([]byte(msg)) + if err != nil { + return + } + + mHash := fmt.Sprintf("%03d", h.Sum32()%1000) + mHash1 := fmt.Sprintf("%04d", h.Sum32()%10000) + mHash2 := fmt.Sprintf("%03x", h.Sum32()%4096) + log.Printf("%s -> (%s, %s, %d) -> (%s,%d) \n", msg, mHash, mHash1, h.Sum32(), mHash2, h.Sum32()%4096) + } +} diff --git a/ws/context_params.go b/ws/context_params.go new file mode 100644 index 0000000..e4b2051 --- /dev/null +++ b/ws/context_params.go @@ -0,0 +1,93 @@ +package ws + +import ( + "encoding/json" + + "github.com/tidwall/gjson" +) + +func (c *Context) Get(key string) string { + return gjson.Get(c.Params, key).String() +} + +func (c *Context) GetInt(key string) int { + v := gjson.Get(c.Params, key).Int() + return int(v) +} + +func (c *Context) GetJson(s any) error { + return json.Unmarshal([]byte(c.Params), s) +} + +func (c *Context) GetSliceVal(key string, options ...string) string { + find := false + v := gjson.Get(c.Params, key).String() + for _, val := range options { + if v == val { + find = true + break + } + } + + if find { + return v + } + + return "" +} + +func (c *Context) GetPagination() *Pagination { + p := &Page{} + page := gjson.Get(c.Params, "page").String() + if page != "" { + _ = json.Unmarshal([]byte(page), &p) + } + + return InitPagination(p, 100) +} + +func (c *Context) GetSizePagination(pageSize int) *Pagination { + p := &Page{} + page := gjson.Get(c.Params, "page").String() + if page != "" { + _ = json.Unmarshal([]byte(page), &p) + } + + p.PageSize = pageSize + return InitPagination(p, 0) +} + +func (c *Context) GetMinInt(key string, min int) int { + d := c.GetInt(key) + if d < min { + return min + } + + return d +} + +func (c *Context) GetRangeInt(key string, min, max int) int { + d := c.GetInt(key) + if d < min { + return min + } + + if d > max { + return max + } + + return d +} + +func (c *Context) GetBool(key string) bool { + return gjson.Get(c.Params, key).Bool() +} + +func (c *Context) GetId(key string) uint { + v := gjson.Get(c.Params, key).Int() + if v > 0 { + return uint(v) + } + + return 0 +} diff --git a/ws/context_send.go b/ws/context_send.go new file mode 100644 index 0000000..f0592df --- /dev/null +++ b/ws/context_send.go @@ -0,0 +1,105 @@ +package ws + +// Send 发送数据给用户 +func (c *Context) Send(data any) { + msg := New(c.Action).WithData(data) + + c.Response = msg + c.Client.SendMsg(msg.Encode()) +} + +// SendOk 发送成功消息 +func (c *Context) SendOk() { + msg := New(c.Action) + + c.Response = msg + c.Client.SendMsg(msg.Encode()) +} + +// SendCode 发送状态消息 +func (c *Context) SendCode(code int, msg string) { + //开发模式下更新默认语言文件 + if c.Server.isDev && c.language == c.defaultLng { + c.i18nSet(code, msg) + } + + if c.language != c.defaultLng { + translate := c.i18nLoad(code, msg) + if translate != "" { + msg = translate + } + } + + m := New(c.Action).WithCode(code).WithMsg(msg) + + c.Response = m + c.Client.SendMsg(m.Encode()) +} + +// SendMsg 发送消息给当前用户 +func (c *Context) SendMsg(msg string) { + m := New(c.Action).WithMsg(msg) + + c.Response = m + c.Client.SendMsg(m.Encode()) +} + +// SendActionData 发送数据给当前用户 +func (c *Context) SendActionData(action string, data any) { + m := New(action).WithData(data) + + c.Response = m + c.Client.SendMsg(m.Encode()) +} + +// SendActionMsg 发送消息给当前用户 +func (c *Context) SendActionMsg(action, msg string) { + m := New(action).WithMsg(msg) + + c.Response = m + c.Client.SendMsg(m.Encode()) +} + +// SendTo 发送给指定用户 +func (c *Context) SendTo(uid, action string, data any) { + m := New(action).WithData(data) + c.Response = m + + user := c.Client.Hub.User(uid) + if user != nil { + user.SendMsg(m.Encode()) + } +} + +// SendToApp 发送消息给指定的app +func (c *Context) SendToApp(appId string, msg *Action) { + c.Response = msg + if c.Client.User != nil { + c.Client.User.SendMsgToApp(appId, msg.Encode()) + } +} + +// SendToApps 发送RAW消息给当前用户所有客户端 +func (c *Context) SendToApps(msg *Action) { + c.Response = msg + if c.Client.User != nil { + c.Client.User.SendMsg(msg.Encode()) + } else { + c.Client.SendMsg(msg.Encode()) + } +} + +// SendRawTo 发送RAW消息给指定用户 +func (c *Context) SendRawTo(uid string, msg *Action) { + c.Response = msg + user := c.Client.Hub.User(uid) + if user != nil { + user.SendMsg(msg.Encode()) + } +} + +// Broadcast 发送广播 +func (c *Context) Broadcast(msg *Action) { + c.Response = msg + c.Client.Hub.Broadcast(msg.Encode()) +} diff --git a/ws/context_value.go b/ws/context_value.go new file mode 100644 index 0000000..154dc79 --- /dev/null +++ b/ws/context_value.go @@ -0,0 +1,160 @@ +package ws + +import "time" + +type Value struct { + data any +} + +func (v *Value) Raw() any { + return v.data +} + +func (v *Value) String() string { + if s, ok := v.data.(string); ok { + return s + } + return "" +} + +func (v *Value) Bool() bool { + if b, ok := v.data.(bool); ok { + return b + } + return false +} + +func (v *Value) Int() int { + if i, ok := v.data.(int); ok { + return i + } + return 0 +} + +func (v *Value) Int8() int8 { + if i, ok := v.data.(int8); ok { + return i + } + return 0 +} + +func (v *Value) Int16() int16 { + if i, ok := v.data.(int16); ok { + return i + } + return 0 +} + +func (v *Value) Int32() int32 { + if i, ok := v.data.(int32); ok { + return i + } + return 0 +} + +func (v *Value) Int64() int64 { + if i, ok := v.data.(int64); ok { + return i + } + return 0 +} + +func (v *Value) Uint() uint { + if i, ok := v.data.(uint); ok { + return i + } + return 0 +} + +func (v *Value) Uint8() uint8 { + if i, ok := v.data.(uint8); ok { + return i + } + return 0 +} + +func (v *Value) Uint16() uint16 { + if i, ok := v.data.(uint16); ok { + return i + } + return 0 +} + +func (v *Value) Uint32() uint32 { + if i, ok := v.data.(uint32); ok { + return i + } + return 0 +} + +func (v *Value) Uint64() uint64 { + if i, ok := v.data.(uint64); ok { + return i + } + return 0 +} + +func (v *Value) Float32() float32 { + if f, ok := v.data.(float32); ok { + return f + } + return 0.0 +} + +func (v *Value) Float64() float64 { + if f, ok := v.data.(float64); ok { + return f + } + return 0.0 +} + +func (v *Value) Time() time.Time { + if t, ok := v.data.(time.Time); ok { + return t + } + + return time.Time{} +} + +func (v *Value) Duration() time.Duration { + if t, ok := v.data.(time.Duration); ok { + return t + } + + return 0 +} + +func (v *Value) Slice() []any { + if s, ok := v.data.([]any); ok { + return s + } + return nil +} + +func (v *Value) SliceString() []string { + if s, ok := v.data.([]string); ok { + return s + } + return nil +} + +func (v *Value) SliceInt() []int { + if s, ok := v.data.([]int); ok { + return s + } + return nil +} + +func (v *Value) Map() map[string]any { + if m, ok := v.data.(map[string]any); ok { + return m + } + return nil +} + +func (v *Value) StringMap() map[string]string { + if m, ok := v.data.(map[string]string); ok { + return m + } + return nil +} diff --git a/ws/data_api.go b/ws/data_api.go new file mode 100644 index 0000000..745cf7c --- /dev/null +++ b/ws/data_api.go @@ -0,0 +1,9 @@ +package ws + +type ApiData struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data,omitempty"` + + HttpStatus int +} diff --git a/ws/data_appid.go b/ws/data_appid.go new file mode 100644 index 0000000..de6a4d5 --- /dev/null +++ b/ws/data_appid.go @@ -0,0 +1,17 @@ +package ws + +type Appid int + +// 系统保留<200 +var sys Appid = 0 + +var ( + //appid范围200~999 + minAppid = 200 + maxAppid = 999 + + //code范围100~999 + minCode = 0 + maxCode = 999 + base = 1000 +) diff --git a/ws/data_error.go b/ws/data_error.go new file mode 100644 index 0000000..d315524 --- /dev/null +++ b/ws/data_error.go @@ -0,0 +1,64 @@ +package ws + +import ( + "fmt" +) + +var ( + ErrServerError = NewError(sys, 1100, "Server under maintenance") + ErrUncertified = NewError(sys, 1120, "Please log in first") + ErrCertificationExpired = NewError(sys, 1121, "Login has expired") + ErrJwtBLOCKED = NewError(sys, 1123, "Login has expired") + ErrParamsInvalid = NewError(sys, 1131, "Invalid parameters") + ErrAuthentic = NewError(sys, 1141, "Please login first") +) + +type Error ApiData + +var codes = map[int]string{} + +func NewError(appId Appid, code int, msg string) *Error { + if appId != sys { + a := int(appId) + if a < minAppid || a > maxAppid { + panic(fmt.Sprintf("error AppId %d", appId)) + } + + if code < minCode || code > maxCode { + panic(fmt.Sprintf("error code %d", code)) + } + + code = a*base + code + } + + if _, ok := codes[code]; ok { + panic(fmt.Sprintf("Error code %d already exists, please choose another one", code)) + } + + codes[code] = msg + return &Error{Code: code, Msg: msg} +} + +// WithMsg 覆盖业务错误提示内容 +func (e *Error) WithMsg(msg string) *Error { + e.Msg = msg + return e +} + +// WithError 兼容Binding错误码及多语言翻译 +// 使用前需要调用 validate.GinValidator() 初始化 +// 字段中文名称使用 `label:"名称"` 指定 +func (e *Error) WithError(err error) *Error { + if err == nil { + return e + } + + e.Msg = BindingErrors(err).Error() + return e +} + +// WithHttpStatus 处理HTTP状态码 +func (e *Error) WithHttpStatus(status int) *Error { + e.HttpStatus = status + return e +} diff --git a/ws/data_h.go b/ws/data_h.go new file mode 100644 index 0000000..56ce146 --- /dev/null +++ b/ws/data_h.go @@ -0,0 +1,62 @@ +package ws + +import ( + "encoding/json" + + "go.uber.org/zap" + + "github.com/wonli/aqi/logger" +) + +// H 类似gin.H +type H map[string]any + +func (h *H) Get(key string) (any, bool) { + if *h == nil { + return nil, false + } + + val, ok := (*h)[key] + return val, ok +} + +func (h *H) Set(key string, val any) { + if *h == nil { + *h = make(map[string]any) + } + + (*h)[key] = val +} + +func (h *H) Unmarshal(v any) error { + d, err := json.Marshal(h) + if err != nil { + logger.SugarLog.Error("构造参数失败", + zap.String("error", err.Error()), + ) + return err + } + + err = json.Unmarshal(d, v) + if err != nil { + logger.SugarLog.Error("解析参数失败", + zap.String("error", err.Error()), + ) + return err + } + + return nil +} + +func (h *H) Marshal() []byte { + d, err := json.Marshal(h) + if err != nil { + logger.SugarLog.Error("构造参数失败", + zap.String("error", err.Error()), + ) + + return d + } + + return d +} diff --git a/ws/data_paginate.go b/ws/data_paginate.go new file mode 100644 index 0000000..860be67 --- /dev/null +++ b/ws/data_paginate.go @@ -0,0 +1,45 @@ +package ws + +type Page struct { + Current int `json:"current"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` +} + +type Pagination struct { + *Page `json:"page"` + + Rows any `json:"rows,omitempty"` + ExData H `json:"exData,omitempty"` + + Limit int `json:"-"` //PageSize alias + Offset int `json:"-"` //避免每次计算offset +} + +func InitPagination(p *Page, maxSize int) *Pagination { + if p.Current <= 1 { + p.Current = 1 + } + + if p.PageSize <= 0 { + p.PageSize = 10 + } + + if maxSize > 0 && p.PageSize > maxSize { + p.PageSize = maxSize + } + + return &Pagination{ + Page: p, + Offset: (p.Current - 1) * p.PageSize, + Limit: p.PageSize, + } +} + +func (p *Pagination) AddExData(key string, val any) { + if p.ExData == nil { + p.ExData = H{} + } + + p.ExData[key] = val +} diff --git a/ws/dispatcher.go b/ws/dispatcher.go new file mode 100644 index 0000000..0212516 --- /dev/null +++ b/ws/dispatcher.go @@ -0,0 +1,63 @@ +package ws + +import ( + "time" + + "github.com/tidwall/gjson" +) + +func Dispatcher(c *Client, request string) { + t := time.Now() + //ping直接回应 + action := gjson.Get(request, "action").String() + if action == "ping" { + c.LastHeartbeatTime = t + c.SendRawMsg(0, "ping", "pong", nil) + return + } + + //是否被禁言 + if c.User != nil { + isBanned, bandTime := c.User.IsBanned() + if isBanned { + c.SendRawMsg(21, "sys.ban", "You have been ban", bandTime) + return + } + } + + //请求频率限制5毫秒 + if t.Sub(c.LastRequestTime).Microseconds() <= 5 { + c.SendRawMsg(23, "sys.requestLimit", "Your requests are too frequent", nil) + return + } else { + //更新最后请求时间 + c.LastRequestTime = t + + //如果心跳时间为0,设置为当前时间 + //防止在连接瞬间被哨兵扫描而断开 + if c.LastHeartbeatTime.IsZero() { + c.LastHeartbeatTime = t + } + } + + handlers := InitManager().Handlers(action) + if handlers == nil || len(handlers) == 0 { + c.SendRawMsg(25, action, "Request not supported", nil) + return + } + + ctx := &Context{ + Client: c, + Action: action, + Params: gjson.Get(request, "params").String(), + Server: wss, + + handlers: handlers, + + language: "zh", + defaultLng: "zh", + } + + ctx.handlers[0](ctx) + ctx.Next() +} diff --git a/ws/hubc.go b/ws/hubc.go new file mode 100644 index 0000000..c763d16 --- /dev/null +++ b/ws/hubc.go @@ -0,0 +1,151 @@ +package ws + +import ( + "sync" + "time" + + "golang.org/x/exp/slices" +) + +var Hub *Hubc + +type Hubc struct { + //访客列表 + Guests []*Client + + //已登录用户 map[string]*User + Users *sync.Map + + //用户数统计 + LoginCount int + GuestCount int + + //发布订阅 + PubSub *PubSub + + //登录和断开通道 + Connection chan *Client + Disconnect chan *Client +} + +func NewHubc() *Hubc { + Hub = &Hubc{ + PubSub: NewPubSub(), + Guests: []*Client{}, + Users: new(sync.Map), + Connection: make(chan *Client), + Disconnect: make(chan *Client), + } + + return Hub +} + +func (h *Hubc) Run() { + go h.PubSub.Start() + go h.guard() + + for { + select { + case c := <-h.Connection: + h.Guests = append(h.Guests, c) + h.PubSub.Pub("connect", c) + c.Log("--", "connection") + + case c := <-h.Disconnect: + if c.User != nil { + err := c.User.appLogout(c.AppId, c) + if err != nil { + c.Log("--", "user disconnect") + } + } else { + c.Close() + h.removeFromGuests(c) + } + } + } +} + +func (h *Hubc) guard() { + timer := time.NewTicker(30 * time.Second) + for range timer.C { + userCount := 0 + guestCount := len(h.Guests) + h.Users.Range(func(key, value any) bool { + userCount++ + return true + }) + + //登录用户数 + h.LoginCount = userCount + h.GuestCount = guestCount + + //发布订阅消息 + h.PubSub.Pub("userCount", userCount) + h.PubSub.Pub("guestsCount", guestCount) + } +} + +// Broadcast 发送广播消息 +func (h *Hubc) Broadcast(msg []byte) { + for _, g := range h.Guests { + g.SendMsg(msg) + } + + if h.Users != nil { + h.Users.Range(func(key, value any) bool { + user, ok := value.(*User) + if ok && user != nil { + user.SendMsg(msg) + } + + return true + }) + } +} + +// User 获取用户信息 +func (h *Hubc) User(uid string) *User { + user, ok := h.Users.Load(uid) + if ok { + return user.(*User) + } + + return nil +} + +// UserClient 获取用户客户端信息 +func (h *Hubc) UserClient(uid, appId string) *Client { + user := h.User(uid) + if user != nil { + return user.AppClient(appId) + } + + return nil +} + +// UserLogin 用户登录 +func (h *Hubc) UserLogin(uid, appId string, client *Client) error { + user := h.User(uid) + if user == nil { + user = NewUser(uid) + } + + //app登录 + err := user.appLogin(appId, client) + if err != nil { + return err + } + + //保存用户 + h.Users.Store(uid, user) + h.removeFromGuests(client) + return nil +} + +// 从访客列表中删除 +func (h *Hubc) removeFromGuests(client *Client) { + index := slices.Index(h.Guests, client) + if index > -1 { + h.Guests = slices.Delete(h.Guests, index, index+1) + } +} diff --git a/ws/manager.go b/ws/manager.go new file mode 100644 index 0000000..4a26738 --- /dev/null +++ b/ws/manager.go @@ -0,0 +1,38 @@ +package ws + +import ( + "sync" +) + +type ActionManager struct { + handlerMap map[string]HandlersChain +} + +var msy sync.Once +var manager *ActionManager + +func InitManager() *ActionManager { + msy.Do(func() { + manager = &ActionManager{ + handlerMap: map[string]HandlersChain{}, + } + + //处理websocket + go NewHubc().Run() + }) + + return manager +} + +func (m *ActionManager) Add(name string, router HandlersChain) { + m.handlerMap[name] = router +} + +func (m *ActionManager) Has(name string) bool { + _, ok := m.handlerMap[name] + return ok +} + +func (m *ActionManager) Handlers(name string) HandlersChain { + return m.handlerMap[name] +} diff --git a/ws/pubsub.go b/ws/pubsub.go new file mode 100644 index 0000000..dadab06 --- /dev/null +++ b/ws/pubsub.go @@ -0,0 +1,90 @@ +package ws + +import ( + "sync" + + "github.com/wonli/aqi/logger" +) + +type PubSub struct { + Topics *sync.Map //Topics map[string]*Topic //主题名称和Top对应map + TopicMsgQueue chan *TopicMsg //主题消息队列 +} + +func NewPubSub() *PubSub { + return &PubSub{ + Topics: new(sync.Map), + TopicMsgQueue: make(chan *TopicMsg, 128), + } +} + +func (a *PubSub) initTopic(topicId string) *Topic { + //主题不存在时先创建主题 + topic, ok := a.Topics.Load(topicId) + if !ok { + t := &Topic{ + Id: topicId, + PubSub: a, + SubUsers: sync.Map{}, + SubHandlers: sync.Map{}, + } + + a.Topics.Store(topicId, t) + return t + } + + return topic.(*Topic) +} + +// Pub 发布主题 +func (a *PubSub) Pub(topicId string, data any) { + msg := Action{ + Action: "subscriber", + Data: H{ + "topicId": topicId, + "message": data, + }, + } + + //主题不存在时先创建主题 + a.initTopic(topicId) + a.TopicMsgQueue <- &TopicMsg{ + Ori: data, + TopicId: topicId, + Msg: msg.Encode(), + } +} + +// Sub 订阅主题 +func (a *PubSub) Sub(topicId string, user *User) { + a.initTopic(topicId).AddSubUser(user) +} + +// SubFunc 以函数方式订阅 +func (a *PubSub) SubFunc(topicId string, f func(msg *TopicMsg)) { + a.initTopic(topicId).AddSubHandle(f) +} + +func (a *PubSub) Start() { + for { + select { + case msg, ok := <-a.TopicMsgQueue: + if !ok { + logger.SugarLog.Info("从订阅主题队列取数据失败") + continue + } + + t, hasTopic := a.Topics.Load(msg.TopicId) + if !hasTopic { + logger.SugarLog.Info("未发布订阅主题收到消息") + continue + } + + //订阅消息的函数处理 + t.(*Topic).ApplyFunc(msg) + + //订阅消息的用户处理 + t.(*Topic).SendToSubUser(msg.Msg) + } + } +} diff --git a/ws/pubsub_topic.go b/ws/pubsub_topic.go new file mode 100644 index 0000000..ce7f016 --- /dev/null +++ b/ws/pubsub_topic.go @@ -0,0 +1,42 @@ +package ws + +import ( + "sync" + "time" +) + +type Topic struct { + Id string //订阅主题ID + PubSub *PubSub //关联PubSub + SubUsers sync.Map //SubUsers map[string]*time.Time //订阅用户uniqueId和订阅时间 + SubHandlers sync.Map //SubHandlers map[string]func(msg *TopicMsg) //内部组件间通知 +} + +func (a *Topic) AddSubUser(user *User) { + user.AddSubTopic(a) + a.SubUsers.LoadOrStore(user.Suid, time.Now()) +} + +func (a *Topic) AddSubHandle(f func(msg *TopicMsg)) { + a.SubHandlers.LoadOrStore(a.Id, f) +} + +func (a *Topic) SendToSubUser(msg []byte) { + a.SubUsers.Range(func(key, value any) bool { + uniqueId := key.(string) + user := Hub.User(uniqueId) + if user != nil { + user.SendMsg(msg) + } + + return true + }) +} + +func (a *Topic) ApplyFunc(msg *TopicMsg) { + a.SubHandlers.Range(func(key, value any) bool { + f := value.(func(msg *TopicMsg)) + f(msg) + return true + }) +} diff --git a/ws/pubsub_topic_msg.go b/ws/pubsub_topic_msg.go new file mode 100644 index 0000000..f0112b6 --- /dev/null +++ b/ws/pubsub_topic_msg.go @@ -0,0 +1,7 @@ +package ws + +type TopicMsg struct { + Ori any //原始数据方便订阅主题的函数处理 + TopicId string //话题ID + Msg []byte //消息内容,方便客户端处理 +} diff --git a/ws/router.go b/ws/router.go new file mode 100644 index 0000000..2fdf35e --- /dev/null +++ b/ws/router.go @@ -0,0 +1,52 @@ +package ws + +import ( + "strings" +) + +type HandlerFunc func(a *Context) +type HandlersChain []HandlerFunc + +type IRouter interface { + Use(middleware ...HandlerFunc) IRouter + Group(name string) IRouter + Add(name string, fn ...HandlerFunc) +} + +type Routers struct { + manager *ActionManager + handlerMembers HandlersChain + groups []string +} + +func NewRouter() Routers { + return Routers{ + manager: InitManager(), + } +} + +func (r Routers) Add(name string, fn ...HandlerFunc) { + if r.groups != nil { + name = strings.Join(r.groups, ".") + "." + name + } + + has := r.manager.Has(name) + if has { + panic("Duplicate route: " + name) + } + + chains := make(HandlersChain, len(r.handlerMembers), len(r.handlerMembers)+len(fn)) + copy(chains, r.handlerMembers) + + r.manager.Add(name, append(chains, fn...)) +} + +func (r Routers) Use(middleware ...HandlerFunc) IRouter { + r.handlerMembers = append(r.handlerMembers, middleware...) + return r +} + +func (r Routers) Group(name string) IRouter { + r.groups = append(r.groups, name) + return r +} diff --git a/ws/server.go b/ws/server.go new file mode 100644 index 0000000..8eb02da --- /dev/null +++ b/ws/server.go @@ -0,0 +1,61 @@ +package ws + +import ( + "net/http" + "sync" +) + +type Server struct { + engine http.Handler + + fn http.HandlerFunc + + port string + isDev bool + dataPath string +} + +var ( + wss *Server + once sync.Once +) + +func NewServer(engine http.Handler) *Server { + once.Do(func() { + InitManager() + wss = &Server{ + engine: engine, + port: ":3322", + fn: HttpHandler, + } + }) + + return wss +} + +func (s *Server) Handler(fn http.HandlerFunc) { + s.fn = fn +} + +func (s *Server) SetPort(p string) { + s.port = p +} + +func (s *Server) SetDataPath(p string) { + s.dataPath = p +} + +func (s *Server) SetIsDev(dev bool) { + s.isDev = dev +} + +func (s *Server) Init() { + +} + +func (s *Server) Run() { + err := http.ListenAndServe(s.port, s.engine) + if err != nil { + panic(err) + } +} diff --git a/ws/server_handler_http.go b/ws/server_handler_http.go new file mode 100644 index 0000000..7993ba8 --- /dev/null +++ b/ws/server_handler_http.go @@ -0,0 +1,82 @@ +package ws + +import ( + "net" + "net/http" + "strings" + "time" + + "github.com/gobwas/ws" + "go.uber.org/zap" + + "github.com/wonli/aqi/logger" +) + +func getRealIP(r *http.Request) string { + // Check if behind a proxy + xForwardedFor := r.Header.Get("X-Forwarded-For") + if xForwardedFor != "" { + // This header can contain multiple IPs separated by comma + // The first one is the original IP + parts := strings.Split(xForwardedFor, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + return parts[0] // Return the first IP which is the client's real IP + } + + // If the X-Real-IP header is set, then use it + xRealIP := r.Header.Get("X-Real-IP") + if xRealIP != "" { + return xRealIP + } + + // Fallback to using RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr // In case there was an issue parsing, just return the whole thing + } + + return ip +} + +func HttpHandler(w http.ResponseWriter, r *http.Request) { + u := ws.HTTPUpgrader{ + Protocol: func(s string) bool { + return true + }, + } + + conn, _, h, err := u.Upgrade(r, w) + if err != nil { + logger.SugarLog.Error("UpgradeHTTP", + zap.String("error", err.Error()), + ) + return + } + + if h.Protocol != "" { + r.Header.Set("Sec-Websocket-Protocol", h.Protocol) + } + + addr, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + logger.SugarLog.Errorf("获取IP地址错误") + return + } + + c := &Client{ + Hub: Hub, + Conn: conn, + Send: make(chan []byte, 32), + IpAddress: getRealIP(r), + IpConnAddr: addr.String(), + ConnectionTime: time.Now(), + Endpoint: r.RequestURI, + } + + c.Hub.Connection <- c + go c.Reader() + go c.Write() + +} diff --git a/ws/user.go b/ws/user.go new file mode 100644 index 0000000..45f32ce --- /dev/null +++ b/ws/user.go @@ -0,0 +1,181 @@ +package ws + +import ( + "sync" + "time" + + "golang.org/x/exp/slices" +) + +type User struct { + //公共基础信息 + Uid uint `json:"uid"` //整型唯一ID + Suid string `json:"suid"` //字符唯一ID + GroupId string `json:"groupId"` //分组ID + SuperAdmin bool `json:"superAdmin"` //是否超管 + RoleId []uint `json:"roleId,omitempty"` //用户角色 + Nickname string `json:"nickname"` //昵称 + Avatar *Resource `json:"avatar"` //用户头像 + OnlineStatus UserOnlineStatus `json:"onlineStatus"` //在线状态 + Location *Location `json:"location,omitempty"` //地理位置 + + CurrentWindowId string //当前的窗口ID + + //禁言时间 + Ban *time.Time `json:"ban,omitempty"` + + //最后心跳时间 + LastHeartbeatTime time.Time + + //用户相关数据 + Hub *Hubc `json:"-"` + AppClients []*Client `json:"-"` //appId对应客户端 + + SubTopics map[string]*Topic `json:"-"` //topicId订阅的主题名称及信息 + sync.RWMutex +} + +func NewUser(uid string) *User { + user := &User{ + Suid: uid, + Hub: Hub, + AppClients: []*Client{}, + + SubTopics: make(map[string]*Topic), + } + + return user +} + +func (u *User) AddSubTopic(topic *Topic) int { + u.Lock() + defer u.Unlock() + + u.SubTopics[topic.Id] = topic + return len(u.SubTopics) +} + +func (u *User) UnsubTopic(topicId string) int { + u.Lock() + defer u.Unlock() + + _, ok := u.SubTopics[topicId] + if ok { + delete(u.SubTopics, topicId) + } + + return len(u.SubTopics) +} + +// AppLogin 用户APP客户端登录 +func (u *User) appLogin(appId string, client *Client) error { + var index int + var appClient *Client + for i, app := range u.AppClients { + if app.AppId == appId { + index = i + appClient = app + break + } + } + + client.User = u + client.AppId = appId + client.IsLogin = true + if appClient != nil { + if appClient.Conn != client.Conn { + u.AppClients = slices.Delete(u.AppClients, index, index+1) + u.AppClients = append(u.AppClients, client) + + //已登录连接下线 + u.Hub.Disconnect <- appClient + } + } else { + u.AppClients = append(u.AppClients, client) + } + + return nil +} + +// app退出 +func (u *User) appLogout(appId string, logoutClient *Client) error { + removeIndex := -1 + for appIndex, appClient := range u.AppClients { + if appClient.AppId == appId && logoutClient.Conn == appClient.Conn { + removeIndex = appIndex + break + } + } + + if removeIndex > -1 { + //从客户端中移除 + u.AppClients = slices.Delete(u.AppClients, removeIndex, removeIndex+1) + + //关闭客户端 + logoutClient.Close() + } + + return nil +} + +// AppClient 获取APP客户端 +func (u *User) AppClient(appId string) *Client { + for _, app := range u.AppClients { + cc := app + if cc.AppId == appId { + return cc + } + } + + return nil +} + +// IsBanned 是否被封禁 +func (u *User) IsBanned() (bool, *time.Time) { + if u.Ban == nil || u.Ban.IsZero() { + return false, nil + } + + return true, u.Ban +} + +// Banned 禁言用户 +func (u *User) Banned(t time.Duration) *time.Time { + banTime := time.Now().Add(t) + u.Ban = &banTime + return u.Ban +} + +// Unban 禁言解除 +func (u *User) Unban() *time.Time { + u.Ban = nil + return u.Ban +} + +// IsOnline 用户是否在线 +func (u *User) IsOnline() bool { + if u == nil || u.AppClients == nil { + return false + } + + return len(u.AppClients) > 0 +} + +// SendMsg 发送消息 +func (u *User) SendMsg(msg []byte) { + if u == nil { + return + } + + for _, client := range u.AppClients { + client.SendMsg(msg) + } +} + +// SendMsgToApp 发送消息到指定客户端 +func (u *User) SendMsgToApp(appId string, msg []byte) { + client := u.AppClient(appId) + if client != nil { + client.SendMsg(msg) + } +} diff --git a/ws/user_location.go b/ws/user_location.go new file mode 100644 index 0000000..44d5af6 --- /dev/null +++ b/ws/user_location.go @@ -0,0 +1,16 @@ +package ws + +import "time" + +type Location struct { + Latitude float64 `json:"latitude" validate:"required"` + Longitude float64 `json:"longitude" validate:"required"` + AdInfo []string `json:"adInfo" validate:"required"` + RegionId string `json:"regionId" validate:"required"` + CityCode string `json:"cityCode" validate:"required"` + Address string `json:"address" validate:"required"` + + SelectCityCode string `json:"selectCityCode"` //选择的城市编码 + SelectCityName string `json:"selectCityName"` //选择的城市名称 + LatestUpdateAt time.Time +} diff --git a/ws/user_resource.go b/ws/user_resource.go new file mode 100644 index 0000000..3a1c465 --- /dev/null +++ b/ws/user_resource.go @@ -0,0 +1,11 @@ +package ws + +import "gorm.io/datatypes" + +type Resource struct { + Path string `json:"path,omitempty"` // 文件路径 + CdnUrl string `json:"cdnUrl,omitempty"` // cdn地址 + Scenes string `json:"scenes"` // 使用场景 + MimeType string `json:"mimeType,omitempty"` // 文件类型 + ExtData datatypes.JSON `json:"extData,omitempty"` +} diff --git a/ws/user_status.go b/ws/user_status.go new file mode 100644 index 0000000..c3e0790 --- /dev/null +++ b/ws/user_status.go @@ -0,0 +1,13 @@ +package ws + +type UserOnlineStatus byte + +const ( + UserStatusOnline UserOnlineStatus = iota + UserStatusBusy + UserStatusLeaving +) + +func IsValidStatus(s UserOnlineStatus) bool { + return s <= UserStatusLeaving +} diff --git a/ws/w.go b/ws/w.go new file mode 100755 index 0000000..15433e3 --- /dev/null +++ b/ws/w.go @@ -0,0 +1,34 @@ +package ws + +import ( + "encoding/json" + + "go.uber.org/zap" + + "github.com/wonli/aqi/logger" +) + +// Action Websocket通讯协议 +type Action struct { + Action string `json:"action"` + + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Data any `json:"data,omitempty"` +} + +func (m *Action) Encode() []byte { + return m.json() +} + +// JSON 格式化 +func (m *Action) json() []byte { + r, err := json.Marshal(m) + if err != nil { + logger.SugarLog.Error("JSON格式化失败", + zap.String("error", err.Error()), + ) + } + + return r +} diff --git a/ws/w_response.go b/ws/w_response.go new file mode 100644 index 0000000..45a4abb --- /dev/null +++ b/ws/w_response.go @@ -0,0 +1,42 @@ +package ws + +import ( + "fmt" + + "github.com/gin-gonic/gin" +) + +type Response struct { + Ctx *ApiData + + g *gin.Context + httpCode int +} + +func (r *Response) WithData(data any) *Response { + r.Ctx.Data = data + return r +} + +func (r *Response) WithError(e Error, err error) *Response { + r.Ctx.Code = e.Code + if err != nil { + r.Ctx.Msg = fmt.Sprintf("%s,%s", e.Msg, err.Error()) + } else { + r.Ctx.Msg = e.Msg + } + + return r +} + +func (r *Response) Send() { + r.g.JSON(r.httpCode, r.Ctx) +} + +func NewResponse(g *gin.Context, httpCode int) *Response { + return &Response{ + g: g, + httpCode: httpCode, + Ctx: &ApiData{}, + } +} diff --git a/ws/w_util.go b/ws/w_util.go new file mode 100644 index 0000000..4acbf26 --- /dev/null +++ b/ws/w_util.go @@ -0,0 +1,29 @@ +package ws + +func Msg(action, msg string) []byte { + res := &Action{ + Action: action, + Msg: msg, + } + + return res.json() +} + +func Code(action string, code int, msg string) []byte { + res := &Action{ + Action: action, + Code: code, + Msg: msg, + } + + return res.json() +} + +func Data(action string, data any) []byte { + res := &Action{ + Action: action, + Data: data, + } + + return res.json() +} diff --git a/ws/w_util_binding.go b/ws/w_util_binding.go new file mode 100644 index 0000000..ff518b7 --- /dev/null +++ b/ws/w_util_binding.go @@ -0,0 +1,12 @@ +package ws + +import "github.com/wonli/aqi/validate" + +// BindingErrors 处理错误信息 +func BindingErrors(e error) error { + if validate.GinBinding == nil { + return e + } + + return validate.GinBinding.Translator(e) +} diff --git a/ws/w_util_chain.go b/ws/w_util_chain.go new file mode 100644 index 0000000..6bcb778 --- /dev/null +++ b/ws/w_util_chain.go @@ -0,0 +1,22 @@ +package ws + +func New(action string) *Action { + return &Action{ + Action: action, + } +} + +func (m *Action) WithCode(code int) *Action { + m.Code = code + return m +} + +func (m *Action) WithData(data any) *Action { + m.Data = data + return m +} + +func (m *Action) WithMsg(msg string) *Action { + m.Msg = msg + return m +}