diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..d8a4040 --- /dev/null +++ b/.air.toml @@ -0,0 +1,54 @@ +# https://github.com/cosmtrek/air/blob/master/air_example.toml TOML 格式的配置文件 + +# 工作目录 +# 使用 . 或绝对路径,请注意 `tmp_dir` 目录必须在 `root` 目录下 +root = "." +tmp_dir = "tmp" + +[build] + # 由`cmd`命令得到的二进制文件名 + bin = "./tmp/main" + # 只需要写你平常编译使用的shell命令。你也可以使用 `make` + cmd = "go build -o ./tmp/main ." + # 如果文件更改过于频繁,则没有必要在每次更改时都触发构建。可以设置触发构建的延迟时间 + delay = 1000 + # 忽略这些文件扩展名或目录 + exclude_dir = ["assets", "tmp", "vendor","public/uploads"] + # 忽略以下文件 + exclude_file = [] + # 使用正则表达式进行忽略文件设置 + exclude_regex = [] + # 忽略未变更的慰问金 + exclude_unchanged = false + # 监控系统链接的目录 + follow_symlink = false + # 自定义参数,可以添加额外的编译标识,例如添加 GIN_MODE=release + full_bin = "" + # 监听以下指定目录的文件 + include_dir = [] + # 监听以下文件扩展名的文件. + include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "env"] + # kill 命令延迟 + kill_delay = "0s" + # air的日志文件名,该日志文件放置在你的`tmp_dir`中 + log = "build-errors.log" + # 在 kill 之前发送系统中断信号,windows 不支持此功能 + send_interrupt = false + # error 发生是结束运行 + stop_on_error = true + +[color] + # 自定义每个部分显示的颜色。如果找不到颜色,使用原始的应用程序日志。 + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + # 显示日志时间 + time = false + +[misc] + # 退出时删除tmp目录 + clean_on_exit = false \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7acd38c --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +APP_NAME=Gohub +APP_ENV=local +APP_KEY=zBqYyQrPNaIUsnRhsGtHLivjqiMjBVLS +APP_DEBUG=true +APP_URL=http://localhost:3000 +APP_PORT=3000 + +DB_CONNECTION=sqlite +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=gohub +DB_USERNAME=root +DB_PASSWORD=secret +DB_DEBUG=2 + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_CACHE_DB=0 +REDIS_MAIN_DB=1 + +JWT_EXPIRE_TIME=120 +JWT_EXPIRE_TIME=86400 + +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS=gohub@example.com +MAIL_FROM_NAME=Gohub + +SMS_ALIYUN_ACCESS_ID=XXX +SMS_ALIYUN_ACCESS_SECRET=XXXXX +SMS_ALIYUN_SIGN_NAME= +SMS_ALIYUN_TEMPLATE_CODE= + +VERIFY_CODE_LENGTH=6 +VERIFY_CODE_EXPIRE=15 + +LOG_TYPE=single +LOG_LEVEL=debug \ No newline at end of file diff --git a/app/cmd/cache.go b/app/cmd/cache.go new file mode 100644 index 0000000..aebec69 --- /dev/null +++ b/app/cmd/cache.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "gohub/pkg/cache" + "gohub/pkg/console" + + "github.com/spf13/cobra" +) + +var CmdCache = &cobra.Command{ + Use: "cache", + Short: "Cache management", +} + +var CmdCacheClear = &cobra.Command{ + Use: "clear", + Short: "Clear cache", + Run: runCacheClear, +} + +var CmdCacheForget = &cobra.Command{ + Use: "forget", + Short: "Delete redis key, example: cache forget cache-key", + Run: runCacheForget, +} + +// forget 命令的选项 +var cacheKey string + +func init() { + // 注册 cache 命令的子命令 + CmdCache.AddCommand(CmdCacheClear, CmdCacheForget) + + // 设置 cache forget 命令的选项 + CmdCacheForget.Flags().StringVarP(&cacheKey, "key", "k", "", "KEY of the cache") + CmdCacheForget.MarkFlagRequired("key") +} + +func runCacheClear(cmd *cobra.Command, args []string) { + cache.Flush() + console.Success("Cache cleared.") +} + +func runCacheForget(cmd *cobra.Command, args []string) { + cache.Forget(cacheKey) + console.Success(fmt.Sprintf("Cache key [%s] deleted.", cacheKey)) +} diff --git a/app/cmd/cmd.go b/app/cmd/cmd.go new file mode 100644 index 0000000..c65d3cc --- /dev/null +++ b/app/cmd/cmd.go @@ -0,0 +1,27 @@ +// Package cmd 存放程序的所有子命令 +package cmd + +import ( + "gohub/pkg/helpers" + "os" + + "github.com/spf13/cobra" +) + +// Env 存储全局选项 --env 的值 +var Env string + +// RegisterGlobalFlags 注册全局选项(flag) +func RegisterGlobalFlags(rootCmd *cobra.Command) { + rootCmd.PersistentFlags().StringVarP(&Env, "env", "e", "", "load .env file, example: --env=testing will use .env.testing file") +} + +// RegisterDefaultCmd 注册默认命令 +func RegisterDefaultCmd(rootCmd *cobra.Command, subCmd *cobra.Command) { + cmd, _, err := rootCmd.Find(os.Args[1:]) + firtArg := helpers.FirstElement(os.Args[1:]) + if err == nil && cmd.Use == rootCmd.Use && firtArg != "-h" && firtArg != "--help" { + args := append([]string{subCmd.Use}, os.Args[1:]...) + rootCmd.SetArgs(args) + } +} diff --git a/app/cmd/key.go b/app/cmd/key.go new file mode 100644 index 0000000..8c22060 --- /dev/null +++ b/app/cmd/key.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "gohub/pkg/console" + "gohub/pkg/helpers" + + "github.com/spf13/cobra" +) + +var CmdKey = &cobra.Command{ + Use: "key", + Short: "Generate App Key, will print the generated Key", + Run: runKeyGenerate, + Args: cobra.NoArgs, // 不允许传参 +} + +func runKeyGenerate(cmd *cobra.Command, args []string) { + console.Success("---") + console.Success("App Key:") + console.Success(helpers.RandomString(32)) + console.Success("---") + console.Warning("please go to .env file to change the APP_KEY option") +} diff --git a/app/cmd/make/make.go b/app/cmd/make/make.go new file mode 100644 index 0000000..8be986b --- /dev/null +++ b/app/cmd/make/make.go @@ -0,0 +1,135 @@ +// Package make 命令行的 make 命令 +package make + +import ( + "embed" + "fmt" + "gohub/pkg/console" + "gohub/pkg/file" + "gohub/pkg/str" + "strings" + + "github.com/iancoleman/strcase" + "github.com/spf13/cobra" +) + +// Model 参数解释 +// 单个词,用户命令传参,以 User 模型为例: +// - user +// - User +// - users +// - Users +// 整理好的数据: +// { +// "TableName": "users", +// "StructName": "User", +// "StructNamePlural": "Users" +// "VariableName": "user", +// "VariableNamePlural": "users", +// "PackageName": "user" +// } +// - +// 两个词或者以上,用户命令传参,以 TopicComment 模型为例: +// - topic_comment +// - topic_comments +// - TopicComment +// - TopicComments +// 整理好的数据: +// { +// "TableName": "topic_comments", +// "StructName": "TopicComment", +// "StructNamePlural": "TopicComments" +// "VariableName": "topicComment", +// "VariableNamePlural": "topicComments", +// "PackageName": "topic_comment" +// } +type Model struct { + TableName string + StructName string + StructNamePlural string + VariableName string + VariableNamePlural string + PackageName string +} + +// stubsFS 方便我们后面打包这些 .stub 为后缀名的文件 + +//go:embed stubs +var stubsFS embed.FS + +// CmdMake 说明 cobra 命令 +var CmdMake = &cobra.Command{ + Use: "make", + Short: "Generate file and code", +} + +func init() { + // 注册 make 的子命令 + CmdMake.AddCommand( + CmdMakeCMD, + CmdMakeModel, + CmdMakeAPIController, + CmdMakeRequest, + CmdMakeMigration, + CmdMakeFactory, + CmdMakeSeeder, + CmdMakePolicy, + ) +} + +// makeModelFromString 格式化用户输入的内容 +func makeModelFromString(name string) Model { + model := Model{} + model.StructName = str.Singular(strcase.ToCamel(name)) + model.StructNamePlural = str.Plural(model.StructName) + model.TableName = str.Snake(model.StructNamePlural) + model.VariableName = str.LowerCamel(model.StructName) + model.PackageName = str.Snake(model.StructName) + model.VariableNamePlural = str.LowerCamel(model.StructNamePlural) + return model +} + +// createFileFromStub 读取 stub 文件并进行变量替换 +// 最后一个选项可选,如若传参,应传 map[string]string 类型,作为附加的变量搜索替换 +func createFileFromStub(filePath string, stubName string, model Model, variables ...interface{}) { + + // 实现最后一个参数可选 + replaces := make(map[string]string) + if len(variables) > 0 { + replaces = variables[0].(map[string]string) + } + + // 目标文件已存在 + if file.Exists(filePath) { + console.Exit(filePath + " already exists!") + } + + // 读取 stub 模板文件 + modelData, err := stubsFS.ReadFile("stubs/" + stubName + ".stub") + if err != nil { + console.Exit(err.Error()) + } + modelStub := string(modelData) + + // 添加默认的替换变量 + replaces["{{VariableName}}"] = model.VariableName + replaces["{{VariableNamePlural}}"] = model.VariableNamePlural + replaces["{{StructName}}"] = model.StructName + replaces["{{StructNamePlural}}"] = model.StructNamePlural + replaces["{{PackageName}}"] = model.PackageName + replaces["{{TableName}}"] = model.TableName + + // 对模板内容做变量替换 + for search, replace := range replaces { + modelStub = strings.ReplaceAll(modelStub, search, replace) + } + + // 存储到目标文件中 + err = file.Put([]byte(modelStub), filePath) + if err != nil { + console.Exit(err.Error()) + } + + // 提示成功 + console.Success(fmt.Sprintf("[%s] created.", filePath)) +} diff --git a/app/cmd/make/make_apicontroller.go b/app/cmd/make/make_apicontroller.go new file mode 100644 index 0000000..ea11238 --- /dev/null +++ b/app/cmd/make/make_apicontroller.go @@ -0,0 +1,36 @@ +package make + +import ( + "fmt" + "gohub/pkg/console" + "strings" + + "github.com/spf13/cobra" +) + +var CmdMakeAPIController = &cobra.Command{ + Use: "apicontroller", + Short: "Create api controller,exmaple: make apicontroller v1/user", + Run: runMakeAPIController, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeAPIController(cmd *cobra.Command, args []string) { + + // 处理参数,要求附带 API 版本(v1 或者 v2) + array := strings.Split(args[0], "/") + if len(array) != 2 { + console.Exit("api controller name format: v1/user") + } + + // apiVersion 用来拼接目标路径 + // name 用来生成 cmd.Model 实例 + apiVersion, name := array[0], array[1] + model := makeModelFromString(name) + + // 组建目标目录 + filePath := fmt.Sprintf("app/http/controllers/api/%s/%s_controller.go", apiVersion, model.TableName) + + // 基于模板创建文件(做好变量替换) + createFileFromStub(filePath, "apicontroller", model) +} diff --git a/app/cmd/make/make_cmd.go b/app/cmd/make/make_cmd.go new file mode 100644 index 0000000..aacff8d --- /dev/null +++ b/app/cmd/make/make_cmd.go @@ -0,0 +1,32 @@ +package make + +import ( + "fmt" + "gohub/pkg/console" + + "github.com/spf13/cobra" +) + +var CmdMakeCMD = &cobra.Command{ + Use: "cmd", + Short: "Create a command, should be snake_case, exmaple: make cmd buckup_database", + Run: runMakeCMD, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeCMD(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // 拼接目标文件路径 + filePath := fmt.Sprintf("app/cmd/%s.go", model.PackageName) + + // 从模板中创建文件(做好变量替换) + createFileFromStub(filePath, "cmd", model) + + // 友好提示 + console.Success("command name:" + model.PackageName) + console.Success("command variable name: cmd.Cmd" + model.StructName) + console.Warning("please edit main.go's app.Commands slice to register command") +} diff --git a/app/cmd/make/make_factory.go b/app/cmd/make/make_factory.go new file mode 100644 index 0000000..7257390 --- /dev/null +++ b/app/cmd/make/make_factory.go @@ -0,0 +1,26 @@ +package make + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var CmdMakeFactory = &cobra.Command{ + Use: "factory", + Short: "Create model's factory file, exmaple: make factory user", + Run: runMakeFactory, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeFactory(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // 拼接目标文件路径 + filePath := fmt.Sprintf("database/factories/%s_factory.go", model.PackageName) + + // 基于模板创建文件(做好变量替换) + createFileFromStub(filePath, "factory", model) +} diff --git a/app/cmd/make/make_migration.go b/app/cmd/make/make_migration.go new file mode 100644 index 0000000..c7c1b9a --- /dev/null +++ b/app/cmd/make/make_migration.go @@ -0,0 +1,28 @@ +package make + +import ( + "fmt" + "gohub/pkg/app" + "gohub/pkg/console" + + "github.com/spf13/cobra" +) + +var CmdMakeMigration = &cobra.Command{ + Use: "migration", + Short: "Create a migration file, example: make migration add_users_table", + Run: runMakeMigration, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeMigration(cmd *cobra.Command, args []string) { + + // 日期格式化 + timeStr := app.TimenowInTimezone().Format("2006_01_02_150405") + + model := makeModelFromString(args[0]) + fileName := timeStr + "_" + model.PackageName + filePath := fmt.Sprintf("database/migrations/%s.go", fileName) + createFileFromStub(filePath, "migration", model, map[string]string{"{{FileName}}": fileName}) + console.Success("Migration file created,after modify it, use `migrate up` to migrate database.") +} diff --git a/app/cmd/make/make_model.go b/app/cmd/make/make_model.go new file mode 100644 index 0000000..e69c980 --- /dev/null +++ b/app/cmd/make/make_model.go @@ -0,0 +1,31 @@ +package make + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var CmdMakeModel = &cobra.Command{ + Use: "model", + Short: "Crate model file, example: make model user", + Run: runMakeModel, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeModel(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // 确保模型的目录存在,例如 `app/models/user` + dir := fmt.Sprintf("app/models/%s/", model.PackageName) + // os.MkdirAll 会确保父目录和子目录都会创建,第二个参数是目录权限,使用 0777 + os.MkdirAll(dir, os.ModePerm) + + // 替换变量 + createFileFromStub(dir+model.PackageName+"_model.go", "model/model", model) + createFileFromStub(dir+model.PackageName+"_util.go", "model/model_util", model) + createFileFromStub(dir+model.PackageName+"_hooks.go", "model/model_hooks", model) +} diff --git a/app/cmd/make/make_policy.go b/app/cmd/make/make_policy.go new file mode 100644 index 0000000..7d3bcb8 --- /dev/null +++ b/app/cmd/make/make_policy.go @@ -0,0 +1,30 @@ +package make + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var CmdMakePolicy = &cobra.Command{ + Use: "policy", + Short: "Create policy file, example: make policy user", + Run: runMakePolicy, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakePolicy(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // os.MkdirAll 会确保父目录和子目录都会创建,第二个参数是目录权限,使用 0777 + os.MkdirAll("app/policies", os.ModePerm) + + // 拼接目标文件路径 + filePath := fmt.Sprintf("app/policies/%s_policy.go", model.PackageName) + + // 基于模板创建文件(做好变量替换) + createFileFromStub(filePath, "policy", model) +} diff --git a/app/cmd/make/make_request.go b/app/cmd/make/make_request.go new file mode 100644 index 0000000..42770d7 --- /dev/null +++ b/app/cmd/make/make_request.go @@ -0,0 +1,26 @@ +package make + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var CmdMakeRequest = &cobra.Command{ + Use: "request", + Short: "Create request file, example make request user", + Run: runMakeRequest, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeRequest(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // 拼接目标文件路径 + filePath := fmt.Sprintf("app/requests/%s_request.go", model.PackageName) + + // 基于模板创建文件(做好变量替换) + createFileFromStub(filePath, "request", model) +} diff --git a/app/cmd/make/make_seeder.go b/app/cmd/make/make_seeder.go new file mode 100644 index 0000000..70a12d2 --- /dev/null +++ b/app/cmd/make/make_seeder.go @@ -0,0 +1,26 @@ +package make + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var CmdMakeSeeder = &cobra.Command{ + Use: "seeder", + Short: "Create seeder file, example: make seeder user", + Run: runMakeSeeder, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func runMakeSeeder(cmd *cobra.Command, args []string) { + + // 格式化模型名称,返回一个 Model 对象 + model := makeModelFromString(args[0]) + + // 拼接目标文件路径 + filePath := fmt.Sprintf("database/seeders/%s_seeder.go", model.TableName) + + // 基于模板创建文件(做好变量替换) + createFileFromStub(filePath, "seeder", model) +} diff --git a/app/cmd/make/stubs/apicontroller.stub b/app/cmd/make/stubs/apicontroller.stub new file mode 100644 index 0000000..4051c1f --- /dev/null +++ b/app/cmd/make/stubs/apicontroller.stub @@ -0,0 +1,100 @@ +package v1 + +import ( + "gohub/app/models/{{PackageName}}" + "gohub/app/policies" + "gohub/app/requests" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +type {{StructNamePlural}}Controller struct { + BaseAPIController +} + +func (ctrl *{{StructNamePlural}}Controller) Index(c *gin.Context) { + {{VariableNamePlural}} := {{PackageName}}.All() + response.Data(c, {{VariableNamePlural}}) +} + +func (ctrl *{{StructNamePlural}}Controller) Show(c *gin.Context) { + {{VariableName}}Model := {{PackageName}}.Get(c.Param("id")) + if {{VariableName}}Model.ID == 0 { + response.Abort404(c) + return + } + response.Data(c, {{VariableName}}Model) +} + +func (ctrl *{{StructNamePlural}}Controller) Store(c *gin.Context) { + + request := requests.{{StructName}}Request{} + if ok := requests.Validate(c, &request, requests.{{StructName}}Save); !ok { + return + } + + {{VariableName}}Model := {{PackageName}}.{{StructName}}{ + FieldName: request.FieldName, + } + {{VariableName}}Model.Create() + if {{VariableName}}Model.ID > 0 { + response.Created(c, {{VariableName}}Model) + } else { + response.Abort500(c, "创建失败,请稍后尝试~") + } +} + +func (ctrl *{{StructNamePlural}}Controller) Update(c *gin.Context) { + + {{VariableName}}Model := {{PackageName}}.Get(c.Param("id")) + if {{VariableName}}Model.ID == 0 { + response.Abort404(c) + return + } + + if ok := policies.CanModify{{StructName}}(c, {{VariableName}}Model); !ok { + response.Abort403(c) + return + } + + request := requests.{{StructName}}Request{} + bindOk, errs := requests.Validate(c, &request, requests.{{StructName}}Save) + if !bindOk { + return + } + if len(errs) > 0 { + response.ValidationError(c, 20101, errs) + return + } + + {{VariableName}}Model.FieldName = request.FieldName + rowsAffected := {{VariableName}}Model.Save() + if rowsAffected > 0 { + response.Data(c, {{VariableName}}Model) + } else { + response.Abort500(c, "更新失败,请稍后尝试~") + } +} + +func (ctrl *{{StructNamePlural}}Controller) Delete(c *gin.Context) { + + {{VariableName}}Model := {{PackageName}}.Get(c.Param("id")) + if {{VariableName}}Model.ID == 0 { + response.Abort404(c) + return + } + + if ok := policies.CanModify{{StructName}}(c, {{VariableName}}Model); !ok { + response.Abort403(c) + return + } + + rowsAffected := {{VariableName}}Model.Delete() + if rowsAffected > 0 { + response.Success(c) + return + } + + response.Abort500(c, "删除失败,请稍后尝试~") +} \ No newline at end of file diff --git a/app/cmd/make/stubs/cmd.stub b/app/cmd/make/stubs/cmd.stub new file mode 100644 index 0000000..72a6f90 --- /dev/null +++ b/app/cmd/make/stubs/cmd.stub @@ -0,0 +1,25 @@ +package cmd + +import ( + "errors" + "gohub/pkg/console" + + "github.com/spf13/cobra" +) + +var Cmd{{StructName}} = &cobra.Command{ + Use: "{{PackageName}}", + Short: "HERE PUTS THE COMMAND DESCRIPTION", + Run: run{{StructName}}, + Args: cobra.ExactArgs(1), // 只允许且必须传 1 个参数 +} + +func run{{StructName}}(cmd *cobra.Command, args []string) { + + console.Success("这是一条成功的提示") + console.Warning("这是一条提示") + console.Error("这是一条错误信息") + console.Warning("终端输出最好使用英文,这样兼容性会更好~") + console.Exit("exit 方法可以用来打印消息并中断程序!") + console.ExitIf(errors.New("在 err = nil 的时候打印并退出")) +} \ No newline at end of file diff --git a/app/cmd/make/stubs/factory.stub b/app/cmd/make/stubs/factory.stub new file mode 100644 index 0000000..cd90f36 --- /dev/null +++ b/app/cmd/make/stubs/factory.stub @@ -0,0 +1,25 @@ +package factories + +import ( + "gohub/app/models/{{PackageName}}" + "gohub/pkg/helpers" + + "github.com/bxcodec/faker/v3" +) + +func Make{{StructNamePlural}}(count int) []{{PackageName}}.{{StructName}} { + + var objs []{{PackageName}}.{{StructName}} + + // 设置唯一性,如 {{StructName}} 模型的某个字段需要唯一,即可取消注释 + // faker.SetGenerateUniqueValues(true) + + for i := 0; i < count; i++ { + {{VariableName}}Model := {{PackageName}}.{{StructName}}{ + FIXME() + } + objs = append(objs, {{VariableName}}Model) + } + + return objs +} \ No newline at end of file diff --git a/app/cmd/make/stubs/migration.stub b/app/cmd/make/stubs/migration.stub new file mode 100644 index 0000000..5ddbbe8 --- /dev/null +++ b/app/cmd/make/stubs/migration.stub @@ -0,0 +1,33 @@ +package migrations + +import ( + "database/sql" + "gohub/app/models" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type User struct { + models.BaseModel + + Name string `gorm:"type:varchar(255);not null;index"` + Email string `gorm:"type:varchar(255);index;default:null"` + Phone string `gorm:"type:varchar(20);index;default:null"` + Password string `gorm:"type:varchar(255)"` + + models.CommonTimestampsField + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&User{}) + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropTable(&User{}) + } + + migrate.Add("{{FileName}}", up, down) +} \ No newline at end of file diff --git a/app/cmd/make/stubs/model/model.stub b/app/cmd/make/stubs/model/model.stub new file mode 100644 index 0000000..99e2b35 --- /dev/null +++ b/app/cmd/make/stubs/model/model.stub @@ -0,0 +1,31 @@ +//Package {{PackageName}} 模型 +package {{PackageName}} + +import ( + + "gohub/pkg/logger" + "gohub/pkg/database" +) + +type {{StructName}} struct { + models.BaseModel + + // Put fields in here + FIXME() + + models.CommonTimestampsField +} + +func ({{VariableName}} *{{StructName}}) Create() { + database.DB.Create(&{{VariableName}}) +} + +func ({{VariableName}} *{{StructName}}) Save() (rowsAffected int64) { + result := database.DB.Save(&{{VariableName}}) + return result.RowsAffected +} + +func ({{VariableName}} *{{StructName}}) Delete() (rowsAffected int64) { + result := database.DB.Delete(&{{VariableName}}) + return result.RowsAffected +} \ No newline at end of file diff --git a/app/cmd/make/stubs/model/model_hooks.stub b/app/cmd/make/stubs/model/model_hooks.stub new file mode 100644 index 0000000..cfd81e0 --- /dev/null +++ b/app/cmd/make/stubs/model/model_hooks.stub @@ -0,0 +1,11 @@ +package {{PackageName}} + +// func ({{VariableName}} *{{StructName}}) BeforeSave(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) BeforeCreate(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) AfterCreate(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) BeforeUpdate(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) AfterUpdate(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) AfterSave(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) BeforeDelete(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) AfterDelete(tx *gorm.DB) (err error) {} +// func ({{VariableName}} *{{StructName}}) AfterFind(tx *gorm.DB) (err error) {} \ No newline at end of file diff --git a/app/cmd/make/stubs/model/model_util.stub b/app/cmd/make/stubs/model/model_util.stub new file mode 100644 index 0000000..c12ff19 --- /dev/null +++ b/app/cmd/make/stubs/model/model_util.stub @@ -0,0 +1,41 @@ +package {{PackageName}} + +import ( + "gohub/pkg/app" + "gohub/pkg/database" + "gohub/pkg/paginator" + + "github.com/gin-gonic/gin" +) + +func Get(idstr string) ({{VariableName}} {{StructName}}) { + database.DB.Where("id", idstr).First(&{{VariableName}}) + return +} + +func GetBy(field, value string) ({{VariableName}} {{StructName}}) { + database.DB.Where("? = ?", field, value).First(&{{VariableName}}) + return +} + +func All() ({{VariableNamePlural}} []{{StructName}}) { + database.DB.Find(&{{VariableNamePlural}}) + return +} + +func IsExist(field, value string) bool { + var count int64 + database.DB.Model({{StructName}}{}).Where(" = ?", field, value).Count(&count) + return count > 0 +} + +func Paginate(c *gin.Context, perPage int) ({{VariableNamePlural}} []{{StructName}}, paging paginator.Paging) { + paging = paginator.Paginate( + c, + database.DB.Model({{StructName}}{}), + &{{VariableNamePlural}}, + app.V1URL(database.TableName(&{{StructName}}{})), + perPage, + ) + return +} \ No newline at end of file diff --git a/app/cmd/make/stubs/policy.stub b/app/cmd/make/stubs/policy.stub new file mode 100644 index 0000000..8f918a2 --- /dev/null +++ b/app/cmd/make/stubs/policy.stub @@ -0,0 +1,17 @@ +package policies + +import ( + "gohub/app/models/{{PackageName}}" + "gohub/pkg/auth" + + "github.com/gin-gonic/gin" +) + +func CanModify{{StructName}}(c *gin.Context, {{VariableName}}Model {{PackageName}}.{{StructName}}) bool { + return auth.CurrentUID(c) == {{VariableName}}Model.UserID +} + +// func CanView{{StructName}}(c *gin.Context, {{VariableName}}Model {{PackageName}}.{{StructName}}) bool {} +// func CanCreate{{StructName}}(c *gin.Context, {{VariableName}}Model {{PackageName}}.{{StructName}}) bool {} +// func CanUpdate{{StructName}}(c *gin.Context, {{VariableName}}Model {{PackageName}}.{{StructName}}) bool {} +// func CanDelete{{StructName}}(c *gin.Context, {{VariableName}}Model {{PackageName}}.{{StructName}}) bool {} diff --git a/app/cmd/make/stubs/request.stub b/app/cmd/make/stubs/request.stub new file mode 100644 index 0000000..723dc60 --- /dev/null +++ b/app/cmd/make/stubs/request.stub @@ -0,0 +1,33 @@ +package requests + +import ( + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type {{StructName}}Request struct { + // Name string `valid:"name" json:"name"` + // Description string `valid:"description" json:"description,omitempty"` + FIXME() +} + +func {{StructName}}Save(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + // "name": []string{"required", "min_cn:2", "max_cn:8", "not_exists:{{TableName}},name"}, + // "description": []string{"min_cn:3", "max_cn:255"}, + } + messages := govalidator.MapData{ + // "name": []string{ + // "required:名称为必填项", + // "min_cn:名称长度需至少 2 个字", + // "max_cn:名称长度不能超过 8 个字", + // "not_exists:名称已存在", + // }, + // "description": []string{ + // "min_cn:描述长度需至少 3 个字", + // "max_cn:描述长度不能超过 255 个字", + // }, + } + return validate(data, rules, messages) +} \ No newline at end of file diff --git a/app/cmd/make/stubs/seeder.stub b/app/cmd/make/stubs/seeder.stub new file mode 100644 index 0000000..5e3e9eb --- /dev/null +++ b/app/cmd/make/stubs/seeder.stub @@ -0,0 +1,28 @@ +package seeders + +import ( + "fmt" + "gohub/database/factories" + "gohub/pkg/console" + "gohub/pkg/logger" + "gohub/pkg/seed" + + "gorm.io/gorm" +) + +func init() { + + seed.Add("Seed{{StructNamePlural}}Table", func(db *gorm.DB) { + + {{VariableNamePlural}} := factories.Make{{StructNamePlural}}(10) + + result := db.Table("{{TableName}}").Create(&{{VariableNamePlural}}) + + if err := result.Error; err != nil { + logger.LogIf(err) + return + } + + console.Success(fmt.Sprintf("Table [%v] %v rows seeded", result.Statement.Table, result.RowsAffected)) + }) +} \ No newline at end of file diff --git a/app/cmd/migrate.go b/app/cmd/migrate.go new file mode 100644 index 0000000..80ccfe3 --- /dev/null +++ b/app/cmd/migrate.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "gohub/database/migrations" + "gohub/pkg/migrate" + + "github.com/spf13/cobra" +) + +var CmdMigrate = &cobra.Command{ + Use: "migrate", + Short: "Run database migration", + // 所有 migrate 下的子命令都会执行以下代码 +} + +var CmdMigrateUp = &cobra.Command{ + Use: "up", + Short: "Run unmigrated migrations", + Run: runUp, +} + +func init() { + CmdMigrate.AddCommand( + CmdMigrateUp, + CmdMigrateRollback, + CmdMigrateRefresh, + CmdMigrateReset, + CmdMigrateFresh, + ) +} + +func migrator() *migrate.Migrator { + // 注册 database/migrations 下的所有迁移文件 + migrations.Initialize() + // 初始化 migrator + return migrate.NewMigrator() +} + +func runUp(cmd *cobra.Command, args []string) { + migrator().Up() +} + +var CmdMigrateRollback = &cobra.Command{ + Use: "down", + // 设置别名 migrate down == migrate rollback + Aliases: []string{"rollback"}, + Short: "Reverse the up command", + Run: runDown, +} + +func runDown(cmd *cobra.Command, args []string) { + migrator().Rollback() +} + +var CmdMigrateReset = &cobra.Command{ + Use: "reset", + Short: "Rollback all database migrations", + Run: runReset, +} + +func runReset(cmd *cobra.Command, args []string) { + migrator().Reset() +} + +var CmdMigrateRefresh = &cobra.Command{ + Use: "refresh", + Short: "Reset and re-run all migrations", + Run: runRefresh, +} + +func runRefresh(cmd *cobra.Command, args []string) { + migrator().Refresh() +} + +var CmdMigrateFresh = &cobra.Command{ + Use: "fresh", + Short: "Drop all tables and re-run all migrations", + Run: runFresh, +} + +func runFresh(cmd *cobra.Command, args []string) { + migrator().Fresh() +} diff --git a/app/cmd/play.go b/app/cmd/play.go new file mode 100644 index 0000000..99a461b --- /dev/null +++ b/app/cmd/play.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "gohub/pkg/console" + "gohub/pkg/redis" + "time" + + "github.com/spf13/cobra" +) + +var CmdPlay = &cobra.Command{ + Use: "play", + Short: "Likes the Go Playground, but running at our application context", + Run: runPlay, +} + +// 调试完成后请记得清除测试代码 +func runPlay(cmd *cobra.Command, args []string) { + // 存进去 redis 中 + redis.Redis.Set("hello", "hi from redis", 10*time.Second) + // 从 redis 里取出 + console.Success(redis.Redis.Get("hello")) +} diff --git a/app/cmd/seed.go b/app/cmd/seed.go new file mode 100644 index 0000000..66854b1 --- /dev/null +++ b/app/cmd/seed.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "gohub/database/seeders" + "gohub/pkg/console" + "gohub/pkg/seed" + + "github.com/spf13/cobra" +) + +var CmdDBSeed = &cobra.Command{ + Use: "seed", + Short: "Insert fake data to the database", + Run: runSeeders, + Args: cobra.MaximumNArgs(1), // 只允许 1 个参数 +} + +func runSeeders(cmd *cobra.Command, args []string) { + seeders.Initialize() + if len(args) > 0 { + // 有传参数的情况 + name := args[0] + seeder := seed.GetSeeder(name) + if len(seeder.Name) > 0 { + seed.RunSeeder(name) + } else { + console.Error("Seeder not found: " + name) + } + } else { + // 默认运行全部迁移 + seed.RunAll() + console.Success("Done seeding.") + } +} diff --git a/app/cmd/serve.go b/app/cmd/serve.go new file mode 100644 index 0000000..efe835b --- /dev/null +++ b/app/cmd/serve.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "gohub/bootstrap" + "gohub/pkg/config" + "gohub/pkg/console" + "gohub/pkg/logger" + + "github.com/gin-gonic/gin" + "github.com/spf13/cobra" +) + +// CmdServe represents the available web sub-command. +var CmdServe = &cobra.Command{ + Use: "serve", + Short: "Start web server", + Run: runWeb, + Args: cobra.NoArgs, +} + +func runWeb(cmd *cobra.Command, args []string) { + + // 设置 gin 的运行模式,支持 debug, release, test + // release 会屏蔽调试信息,官方建议生产环境中使用 + // 非 release 模式 gin 终端打印太多信息,干扰到我们程序中的 Log + // 故此设置为 release,有特殊情况手动改为 debug 即可 + gin.SetMode(gin.ReleaseMode) + + // gin 实例 + router := gin.New() + + // 初始化路由绑定 + bootstrap.SetupRoute(router) + + // 运行服务器 + err := router.Run(":" + config.Get("app.port")) + if err != nil { + logger.ErrorString("CMD", "serve", err.Error()) + console.Exit("Unable to start server, error:" + err.Error()) + } +} diff --git a/app/http/controllers/api/v1/auth/login_controller.go b/app/http/controllers/api/v1/auth/login_controller.go new file mode 100644 index 0000000..50f6991 --- /dev/null +++ b/app/http/controllers/api/v1/auth/login_controller.go @@ -0,0 +1,76 @@ +package auth + +import ( + v1 "gohub/app/http/controllers/api/v1" + "gohub/app/requests" + "gohub/pkg/auth" + "gohub/pkg/jwt" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +// LoginController 用户控制器 +type LoginController struct { + v1.BaseAPIController +} + +// LoginByPhone 手机登录 +func (lc *LoginController) LoginByPhone(c *gin.Context) { + + // 1. 验证表单 + request := requests.LoginByPhoneRequest{} + if ok := requests.Validate(c, &request, requests.LoginByPhone); !ok { + return + } + + // 2. 尝试登录 + user, err := auth.LoginByPhone(request.Phone) + if err != nil { + // 失败,显示错误提示 + response.Error(c, err, "账号不存在或密码错误") + } else { + // 登录成功 + token := jwt.NewJWT().IssueToken(user.GetStringID(), user.Name) + + response.JSON(c, gin.H{ + "token": token, + }) + } +} + +// LoginByPassword 多种方法登录,支持手机号、email 和用户名 +func (lc *LoginController) LoginByPassword(c *gin.Context) { + // 1. 验证表单 + request := requests.LoginByPasswordRequest{} + if ok := requests.Validate(c, &request, requests.LoginByPassword); !ok { + return + } + + // 2. 尝试登录 + user, err := auth.Attempt(request.LoginID, request.Password) + if err != nil { + // 失败,显示错误提示 + response.Unauthorized(c, "登录失败") + + } else { + token := jwt.NewJWT().IssueToken(user.GetStringID(), user.Name) + response.JSON(c, gin.H{ + "token": token, + }) + } +} + +// RefreshToken 刷新 Access Token +func (lc *LoginController) RefreshToken(c *gin.Context) { + + token, err := jwt.NewJWT().RefreshToken(c) + + if err != nil { + response.Error(c, err, "令牌刷新失败") + } else { + response.JSON(c, gin.H{ + "token": token, + }) + } +} diff --git a/app/http/controllers/api/v1/auth/password_controller.go b/app/http/controllers/api/v1/auth/password_controller.go new file mode 100644 index 0000000..7749e32 --- /dev/null +++ b/app/http/controllers/api/v1/auth/password_controller.go @@ -0,0 +1,55 @@ +// Package auth 处理用户注册、登录、密码重置 +package auth + +import ( + v1 "gohub/app/http/controllers/api/v1" + "gohub/app/models/user" + "gohub/app/requests" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +// PasswordController 用户控制器 +type PasswordController struct { + v1.BaseAPIController +} + +// ResetByPhone 使用手机和验证码重置密码 +func (pc *PasswordController) ResetByPhone(c *gin.Context) { + // 1. 验证表单 + request := requests.ResetByPhoneRequest{} + if ok := requests.Validate(c, &request, requests.ResetByPhone); !ok { + return + } + + // 2. 更新密码 + userModel := user.GetByPhone(request.Phone) + if userModel.ID == 0 { + response.Abort404(c) + } else { + userModel.Password = request.Password + userModel.Save() + + response.Success(c) + } +} + +// ResetByEmail 使用 Email 和验证码重置密码 +func (pc *PasswordController) ResetByEmail(c *gin.Context) { + // 1. 验证表单 + request := requests.ResetByEmailRequest{} + if ok := requests.Validate(c, &request, requests.ResetByEmail); !ok { + return + } + + // 2. 更新密码 + userModel := user.GetByEmail(request.Email) + if userModel.ID == 0 { + response.Abort404(c) + } else { + userModel.Password = request.Password + userModel.Save() + response.Success(c) + } +} diff --git a/app/http/controllers/api/v1/auth/signup_controller.go b/app/http/controllers/api/v1/auth/signup_controller.go new file mode 100644 index 0000000..89ca4a4 --- /dev/null +++ b/app/http/controllers/api/v1/auth/signup_controller.go @@ -0,0 +1,98 @@ +// Package auth 处理用户身份认证相关逻辑 +package auth + +import ( + v1 "gohub/app/http/controllers/api/v1" + "gohub/app/models/user" + "gohub/app/requests" + "gohub/pkg/jwt" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +// SignupController 注册控制器 +type SignupController struct { + v1.BaseAPIController +} + +// IsPhoneExist 检测手机号是否被注册 +func (sc *SignupController) IsPhoneExist(c *gin.Context) { + // 获取请求参数,并做表单验证 + request := requests.SignupPhoneExistRequest{} + if ok := requests.Validate(c, &request, requests.SignupPhoneExist); !ok { + return + } + + // 检查数据库并返回响应 + response.JSON(c, gin.H{ + "exist": user.IsPhoneExist(request.Phone), + }) +} + +// IsEmailExist 检测邮箱是否已注册 +func (sc *SignupController) IsEmailExist(c *gin.Context) { + request := requests.SignupEmailExistRequest{} + if ok := requests.Validate(c, &request, requests.SignupEmailExist); !ok { + return + } + response.JSON(c, gin.H{ + "exist": user.IsEmailExist(request.Email), + }) +} + +// SignupUsingPhone 使用手机和验证码进行注册 +func (sc *SignupController) SignupUsingPhone(c *gin.Context) { + + // 1. 验证表单 + request := requests.SignupUsingPhoneRequest{} + if ok := requests.Validate(c, &request, requests.SignupUsingPhone); !ok { + return + } + + // 2. 验证成功,创建数据 + userModel := user.User{ + Name: request.Name, + Phone: request.Phone, + Password: request.Password, + } + userModel.Create() + + if userModel.ID > 0 { + token := jwt.NewJWT().IssueToken(userModel.GetStringID(), userModel.Name) + response.CreatedJSON(c, gin.H{ + "token": token, + "data": userModel, + }) + } else { + response.Abort500(c, "创建用户失败,请稍后尝试~") + } +} + +// SignupUsingEmail 使用 Email + 验证码进行注册 +func (sc *SignupController) SignupUsingEmail(c *gin.Context) { + + // 1. 验证表单 + request := requests.SignupUsingEmailRequest{} + if ok := requests.Validate(c, &request, requests.SignupUsingEmail); !ok { + return + } + + // 2. 验证成功,创建数据 + userModel := user.User{ + Name: request.Name, + Email: request.Email, + Password: request.Password, + } + userModel.Create() + + if userModel.ID > 0 { + token := jwt.NewJWT().IssueToken(userModel.GetStringID(), userModel.Name) + response.CreatedJSON(c, gin.H{ + "token": token, + "data": userModel, + }) + } else { + response.Abort500(c, "创建用户失败,请稍后尝试~") + } +} diff --git a/app/http/controllers/api/v1/auth/verify_code_controller.go b/app/http/controllers/api/v1/auth/verify_code_controller.go new file mode 100644 index 0000000..efdc7b6 --- /dev/null +++ b/app/http/controllers/api/v1/auth/verify_code_controller.go @@ -0,0 +1,65 @@ +package auth + +import ( + v1 "gohub/app/http/controllers/api/v1" + "gohub/app/requests" + "gohub/pkg/captcha" + "gohub/pkg/logger" + "gohub/pkg/response" + "gohub/pkg/verifycode" + + "github.com/gin-gonic/gin" +) + +// VerifyCodeController 用户控制器 +type VerifyCodeController struct { + v1.BaseAPIController +} + +// ShowCaptcha 显示图片验证码 +func (vc *VerifyCodeController) ShowCaptcha(c *gin.Context) { + // 生成验证码 + id, b64s, err := captcha.NewCaptcha().GenerateCaptcha() + // 记录错误日志,因为验证码是用户的入口,出错时应该记 error 等级的日志 + logger.LogIf(err) + // 返回给用户 + response.JSON(c, gin.H{ + "captcha_id": id, + "captcha_image": b64s, + }) +} + +// SendUsingPhone 发送手机验证码 +func (vc *VerifyCodeController) SendUsingPhone(c *gin.Context) { + + // 1. 验证表单 + request := requests.VeifyCodePhoneRequest{} + if ok := requests.Validate(c, &request, requests.VeifyCodePhone); !ok { + return + } + + // 2. 发送 SMS + if ok := verifycode.NewVerifyCode().SendSMS(request.Phone); !ok { + response.Abort500(c, "发送短信失败~") + } else { + response.Success(c) + } +} + +// SendUsingEmail 发送 Email 验证码 +func (vc *VerifyCodeController) SendUsingEmail(c *gin.Context) { + + // 1. 验证表单 + request := requests.VeifyCodeEmailRequest{} + if ok := requests.Validate(c, &request, requests.VeifyCodeEmail); !ok { + return + } + + // 2. 发送 SMS + err := verifycode.NewVerifyCode().SendEmail(request.Email) + if err != nil { + response.Abort500(c, "发送 Email 验证码失败~") + } else { + response.Success(c) + } +} diff --git a/app/http/controllers/api/v1/base_api_controller.go b/app/http/controllers/api/v1/base_api_controller.go new file mode 100644 index 0000000..59223e9 --- /dev/null +++ b/app/http/controllers/api/v1/base_api_controller.go @@ -0,0 +1,6 @@ +// Package v1 处理业务逻辑, GoHub 控制器 v1 +package v1 + +// BaseAPIController 基础控制器 +type BaseAPIController struct { +} diff --git a/app/http/controllers/api/v1/categories_controller.go b/app/http/controllers/api/v1/categories_controller.go new file mode 100644 index 0000000..dbd0d65 --- /dev/null +++ b/app/http/controllers/api/v1/categories_controller.go @@ -0,0 +1,89 @@ +package v1 + +import ( + "gohub/app/models/category" + "gohub/app/requests" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +type CategoriesController struct { + BaseAPIController +} + +func (ctrl *CategoriesController) Store(c *gin.Context) { + + request := requests.CategoryRequest{} + if ok := requests.Validate(c, &request, requests.CategorySave); !ok { + return + } + + categoryModel := category.Category{ + Name: request.Name, + Description: request.Description, + } + categoryModel.Create() + if categoryModel.ID > 0 { + response.Created(c, categoryModel) + } else { + response.Abort500(c, "创建失败,请稍后尝试~") + } +} + +func (ctrl *CategoriesController) Update(c *gin.Context) { + + // 验证 url 参数 id 是否正确 + categoryModel := category.Get(c.Param("id")) + if categoryModel.ID == 0 { + response.Abort404(c) + return + } + + // 表单验证 + request := requests.CategoryRequest{} + if ok := requests.Validate(c, &request, requests.CategorySave); !ok { + return + } + + // 保存数据 + categoryModel.Name = request.Name + categoryModel.Description = request.Description + rowsAffected := categoryModel.Save() + + if rowsAffected > 0 { + response.Data(c, categoryModel) + } else { + response.Abort500(c) + } +} + +func (ctrl *CategoriesController) Index(c *gin.Context) { + request := requests.PaginationRequest{} + if ok := requests.Validate(c, &request, requests.Pagination); !ok { + return + } + + data, pager := category.Paginate(c, 10) + response.JSON(c, gin.H{ + "data": data, + "pager": pager, + }) +} + +func (ctrl *CategoriesController) Delete(c *gin.Context) { + + categoryModel := category.Get(c.Param("id")) + if categoryModel.ID == 0 { + response.Abort404(c) + return + } + + rowsAffected := categoryModel.Delete() + if rowsAffected > 0 { + response.Success(c) + return + } + + response.Abort500(c, "删除失败,请稍后尝试~") +} diff --git a/app/http/controllers/api/v1/links_controller.go b/app/http/controllers/api/v1/links_controller.go new file mode 100644 index 0000000..c1367cd --- /dev/null +++ b/app/http/controllers/api/v1/links_controller.go @@ -0,0 +1,16 @@ +package v1 + +import ( + "gohub/app/models/link" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +type LinksController struct { + BaseAPIController +} + +func (ctrl *LinksController) Index(c *gin.Context) { + response.Data(c, link.AllCached()) +} diff --git a/app/http/controllers/api/v1/topics_controller.go b/app/http/controllers/api/v1/topics_controller.go new file mode 100644 index 0000000..19c9534 --- /dev/null +++ b/app/http/controllers/api/v1/topics_controller.go @@ -0,0 +1,109 @@ +package v1 + +import ( + "gohub/app/models/topic" + "gohub/app/policies" + "gohub/app/requests" + "gohub/pkg/auth" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +type TopicsController struct { + BaseAPIController +} + +func (ctrl *TopicsController) Store(c *gin.Context) { + + request := requests.TopicRequest{} + if ok := requests.Validate(c, &request, requests.TopicSave); !ok { + return + } + + topicModel := topic.Topic{ + Title: request.Title, + Body: request.Body, + CategoryID: request.CategoryID, + UserID: auth.CurrentUID(c), + } + topicModel.Create() + if topicModel.ID > 0 { + response.Created(c, topicModel) + } else { + response.Abort500(c, "创建失败,请稍后尝试~") + } +} + +func (ctrl *TopicsController) Update(c *gin.Context) { + + topicModel := topic.Get(c.Param("id")) + if topicModel.ID == 0 { + response.Abort404(c) + return + } + + if ok := policies.CanModifyTopic(c, topicModel); !ok { + response.Abort403(c) + return + } + + request := requests.TopicRequest{} + if ok := requests.Validate(c, &request, requests.TopicSave); !ok { + return + } + + topicModel.Title = request.Title + topicModel.Body = request.Body + topicModel.CategoryID = request.CategoryID + rowsAffected := topicModel.Save() + if rowsAffected > 0 { + response.Data(c, topicModel) + } else { + response.Abort500(c, "更新失败,请稍后尝试~") + } +} + +func (ctrl *TopicsController) Delete(c *gin.Context) { + + topicModel := topic.Get(c.Param("id")) + if topicModel.ID == 0 { + response.Abort404(c) + return + } + + if ok := policies.CanModifyTopic(c, topicModel); !ok { + response.Abort403(c) + return + } + + rowsAffected := topicModel.Delete() + if rowsAffected > 0 { + response.Success(c) + return + } + + response.Abort500(c, "删除失败,请稍后尝试~") +} + +func (ctrl *TopicsController) Index(c *gin.Context) { + request := requests.PaginationRequest{} + if ok := requests.Validate(c, &request, requests.Pagination); !ok { + return + } + + data, pager := topic.Paginate(c, 10) + response.JSON(c, gin.H{ + "data": data, + "pager": pager, + }) +} + +func (ctrl *TopicsController) Show(c *gin.Context) { + topicModel := topic.Get(c.Param("id")) + if topicModel.ID == 0 { + response.Abort404(c) + return + } + response.Data(c, topicModel) +} diff --git a/app/http/controllers/api/v1/users_controller.go b/app/http/controllers/api/v1/users_controller.go new file mode 100644 index 0000000..90a359a --- /dev/null +++ b/app/http/controllers/api/v1/users_controller.go @@ -0,0 +1,134 @@ +package v1 + +import ( + "gohub/app/models/user" + "gohub/app/requests" + "gohub/pkg/auth" + "gohub/pkg/config" + "gohub/pkg/file" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +type UsersController struct { + BaseAPIController +} + +// CurrentUser 当前登录用户信息 +func (ctrl *UsersController) CurrentUser(c *gin.Context) { + userModel := auth.CurrentUser(c) + response.Data(c, userModel) +} + +// Index 所有用户 +func (ctrl *UsersController) Index(c *gin.Context) { + request := requests.PaginationRequest{} + if ok := requests.Validate(c, &request, requests.Pagination); !ok { + return + } + + data, pager := user.Paginate(c, 10) + response.JSON(c, gin.H{ + "data": data, + "pager": pager, + }) +} + +func (ctrl *UsersController) UpdateProfile(c *gin.Context) { + + request := requests.UserUpdateProfileRequest{} + if ok := requests.Validate(c, &request, requests.UserUpdateProfile); !ok { + return + } + + currentUser := auth.CurrentUser(c) + currentUser.Name = request.Name + currentUser.City = request.City + currentUser.Indtroduction = request.Indtroduction + rowsAffected := currentUser.Save() + if rowsAffected > 0 { + response.Data(c, currentUser) + } else { + response.Abort500(c, "更新失败,请稍后尝试~") + } +} + +func (ctrl *UsersController) UpdateEmail(c *gin.Context) { + + request := requests.UserUpdateEmailRequest{} + if ok := requests.Validate(c, &request, requests.UserUpdateEmail); !ok { + return + } + + currentUser := auth.CurrentUser(c) + currentUser.Email = request.Email + rowsAffected := currentUser.Save() + + if rowsAffected > 0 { + response.Success(c) + } else { + // 失败,显示错误提示 + response.Abort500(c, "更新失败,请稍后尝试~") + } +} + +func (ctrl *UsersController) UpdatePhone(c *gin.Context) { + + request := requests.UserUpdatePhoneRequest{} + if ok := requests.Validate(c, &request, requests.UserUpdatePhone); !ok { + return + } + + currentUser := auth.CurrentUser(c) + currentUser.Phone = request.Phone + rowsAffected := currentUser.Save() + + if rowsAffected > 0 { + response.Success(c) + } else { + response.Abort500(c, "更新失败,请稍后尝试~") + } +} + +func (ctrl *UsersController) UpdatePassword(c *gin.Context) { + + request := requests.UserUpdatePasswordRequest{} + if ok := requests.Validate(c, &request, requests.UserUpdatePassword); !ok { + return + } + + currentUser := auth.CurrentUser(c) + // 验证原始密码是否正确 + _, err := auth.Attempt(currentUser.Name, request.Password) + if err != nil { + // 失败,显示错误提示 + response.Unauthorized(c, "原密码不正确") + } else { + // 更新密码为新密码 + currentUser.Password = request.NewPassword + currentUser.Save() + + response.Success(c) + } +} + +func (ctrl *UsersController) UpdateAvatar(c *gin.Context) { + + request := requests.UserUpdateAvatarRequest{} + if ok := requests.Validate(c, &request, requests.UserUpdateAvatar); !ok { + return + } + + avatar, err := file.SaveUploadAvatar(c, request.Avatar) + if err != nil { + response.Abort500(c, "上传头像失败,请稍后尝试~") + return + } + + currentUser := auth.CurrentUser(c) + currentUser.Avatar = config.GetString("app.url") + avatar + currentUser.Save() + + response.Data(c, currentUser) +} diff --git a/app/http/middlewares/auth_jwt.go b/app/http/middlewares/auth_jwt.go new file mode 100644 index 0000000..f577ea7 --- /dev/null +++ b/app/http/middlewares/auth_jwt.go @@ -0,0 +1,40 @@ +// Package middlewares Gin 中间件 +package middlewares + +import ( + "fmt" + "gohub/app/models/user" + "gohub/pkg/config" + "gohub/pkg/jwt" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +func AuthJWT() gin.HandlerFunc { + return func(c *gin.Context) { + + // 从标头 Authorization:Bearer xxxxx 中获取信息,并验证 JWT 的准确性 + claims, err := jwt.NewJWT().ParserToken(c) + + // JWT 解析失败,有错误发生 + if err != nil { + response.Unauthorized(c, fmt.Sprintf("请查看 %v 相关的接口认证文档", config.GetString("app.name"))) + return + } + + // JWT 解析成功,设置用户信息 + userModel := user.Get(claims.UserID) + if userModel.ID == 0 { + response.Unauthorized(c, "找不到对应用户,用户可能已删除") + return + } + + // 将用户信息存入 gin.context 里,后续 auth 包将从这里拿到当前用户数据 + c.Set("current_user_id", userModel.GetStringID()) + c.Set("current_user_name", userModel.Name) + c.Set("current_user", userModel) + + c.Next() + } +} diff --git a/app/http/middlewares/force_ua.go b/app/http/middlewares/force_ua.go new file mode 100644 index 0000000..f384064 --- /dev/null +++ b/app/http/middlewares/force_ua.go @@ -0,0 +1,23 @@ +// Package middlewares Gin 中间件 +package middlewares + +import ( + "errors" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +// ForceUA 中间件,强制请求必须附带 User-Agent 标头 +func ForceUA() gin.HandlerFunc { + return func(c *gin.Context) { + + // 获取 User-Agent 标头信息 + if len(c.Request.Header["User-Agent"]) == 0 { + response.BadRequest(c, errors.New("User-Agent 标头未找到"), "请求必须附带 User-Agent 标头") + return + } + + c.Next() + } +} diff --git a/app/http/middlewares/guest_jwt.go b/app/http/middlewares/guest_jwt.go new file mode 100644 index 0000000..91f55e3 --- /dev/null +++ b/app/http/middlewares/guest_jwt.go @@ -0,0 +1,27 @@ +package middlewares + +import ( + "gohub/pkg/jwt" + "gohub/pkg/response" + + "github.com/gin-gonic/gin" +) + +// GuestJWT 强制使用游客身份访问 +func GuestJWT() gin.HandlerFunc { + return func(c *gin.Context) { + + if len(c.GetHeader("Authorization")) > 0 { + + // 解析 token 成功,说明登录成功了 + _, err := jwt.NewJWT().ParserToken(c) + if err == nil { + response.Unauthorized(c, "请使用游客身份访问") + c.Abort() + return + } + } + + c.Next() + } +} diff --git a/app/http/middlewares/limit.go b/app/http/middlewares/limit.go new file mode 100644 index 0000000..4140114 --- /dev/null +++ b/app/http/middlewares/limit.go @@ -0,0 +1,80 @@ +package middlewares + +import ( + "gohub/pkg/app" + "gohub/pkg/limiter" + "gohub/pkg/logger" + "gohub/pkg/response" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/spf13/cast" +) + +// LimitIP 全局限流中间件,针对 IP 进行限流 +// limit 为格式化字符串,如 "5-S" ,示例: +// +// * 5 reqs/second: "5-S" +// * 10 reqs/minute: "10-M" +// * 1000 reqs/hour: "1000-H" +// * 2000 reqs/day: "2000-D" +// +func LimitIP(limit string) gin.HandlerFunc { + if app.IsTesting() { + limit = "1000000-H" + } + + return func(c *gin.Context) { + // 针对 IP 限流 + key := limiter.GetKeyIP(c) + if ok := limitHandler(c, key, limit); !ok { + return + } + c.Next() + } +} + +// LimitPerRoute 限流中间件,用在单独的路由中 +func LimitPerRoute(limit string) gin.HandlerFunc { + if app.IsTesting() { + limit = "1000000-H" + } + return func(c *gin.Context) { + // 针对 IP + 路由进行限流 + key := limiter.GetKeyRouteWithIP(c) + if ok := limitHandler(c, key, limit); !ok { + return + } + c.Next() + } +} + +func limitHandler(c *gin.Context, key string, limit string) bool { + + // 获取超额的情况 + rate, err := limiter.CheckRate(c, key, limit) + if err != nil { + logger.LogIf(err) + response.Abort500(c) + return false + } + + // ---- 设置标头信息----- + // X-RateLimit-Limit :10000 最大访问次数 + // X-RateLimit-Remaining :9993 剩余的访问次数 + // X-RateLimit-Reset :1513784506 到该时间点,访问次数会重置为 X-RateLimit-Limit + c.Header("X-RateLimit-Limit", cast.ToString(rate.Limit)) + c.Header("X-RateLimit-Remaining", cast.ToString(rate.Remaining)) + c.Header("X-RateLimit-Reset", cast.ToString(rate.Reset)) + + // 超额 + if rate.Reached { + // 提示用户超额了 + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "message": "接口请求太频繁", + }) + return false + } + + return true +} diff --git a/app/http/middlewares/logger.go b/app/http/middlewares/logger.go new file mode 100644 index 0000000..8b38390 --- /dev/null +++ b/app/http/middlewares/logger.go @@ -0,0 +1,69 @@ +// Package middlewares 存放系统中间件 +package middlewares + +import ( + "bytes" + "gohub/pkg/helpers" + "gohub/pkg/logger" + "time" + + "github.com/gin-gonic/gin" + "github.com/spf13/cast" + "go.uber.org/zap" +) + +type responseBodyWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (r responseBodyWriter) Write(b []byte) (int, error) { + r.body.Write(b) + return r.ResponseWriter.Write(b) +} + +// Logger 记录请求日志 +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + + // 获取 response 内容 + w := &responseBodyWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer} + c.Writer = w + + // 设置开始时间 + start := time.Now() + c.Next() + + // 开始记录日志的逻辑 + cost := time.Since(start) + responStatus := c.Writer.Status() + + logFields := []zap.Field{ + zap.Int("status", c.Writer.Status()), + zap.String("request", c.Request.Method+" "+c.Request.URL.String()), + zap.String("query", c.Request.URL.RawQuery), + zap.String("ip", c.ClientIP()), + zap.String("user-agent", c.Request.UserAgent()), + zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()), + zap.String("time", helpers.MicrosecondsStr(cost)), + } + if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "DELETE" { + // 请求的内容 + reqeustBody, _ := c.GetRawData() + logFields = append(logFields, zap.String("Request Body", string(reqeustBody))) + + // 响应的内容 + logFields = append(logFields, zap.String("Response Body", w.body.String())) + } + + if responStatus > 400 && responStatus <= 499 { + // 除了 StatusBadRequest 以外,warning 提示一下,常见的有 403 404,开发时都要注意 + logger.Warn("HTTP Warning "+cast.ToString(responStatus), logFields...) + } else if responStatus >= 500 && responStatus <= 599 { + // 除了内部错误,记录 error + logger.Error("HTTP Error "+cast.ToString(responStatus), logFields...) + } else { + logger.Debug("HTTP Access Log", logFields...) + } + } +} diff --git a/app/http/middlewares/recovery.go b/app/http/middlewares/recovery.go new file mode 100644 index 0000000..fc70be5 --- /dev/null +++ b/app/http/middlewares/recovery.go @@ -0,0 +1,63 @@ +package middlewares + +import ( + "gohub/pkg/logger" + "gohub/pkg/response" + "net" + "net/http/httputil" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Recovery 使用 zap.Error() 来记录 Panic 和 call stack +func Recovery() gin.HandlerFunc { + + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + + // 获取用户的请求信息 + httpRequest, _ := httputil.DumpRequest(c.Request, true) + + // 链接中断,客户端中断连接为正常行为,不需要记录堆栈信息 + var brokenPipe bool + if ne, ok := err.(*net.OpError); ok { + if se, ok := ne.Err.(*os.SyscallError); ok { + errStr := strings.ToLower(se.Error()) + if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") { + brokenPipe = true + } + } + } + // 链接中断的情况 + if brokenPipe { + logger.Error(c.Request.URL.Path, + zap.Time("time", time.Now()), + zap.Any("error", err), + zap.String("request", string(httpRequest)), + ) + c.Error(err.(error)) + c.Abort() + // 链接已断开,无法写状态码 + return + } + + // 如果不是链接中断,就开始记录堆栈信息 + logger.Error("recovery from panic", + zap.Time("time", time.Now()), // 记录时间 + zap.Any("error", err), // 记录错误信息 + zap.String("request", string(httpRequest)), // 请求信息 + zap.Stack("stacktrace"), // 调用堆栈信息 + ) + + // 返回 500 状态码 + response.Abort500(c) + } + }() + c.Next() + } +} diff --git a/app/models/category/category_hooks.go b/app/models/category/category_hooks.go new file mode 100644 index 0000000..783797c --- /dev/null +++ b/app/models/category/category_hooks.go @@ -0,0 +1,11 @@ +package category + +// func (category *Category) BeforeSave(tx *gorm.DB) (err error) {} +// func (category *Category) BeforeCreate(tx *gorm.DB) (err error) {} +// func (category *Category) AfterCreate(tx *gorm.DB) (err error) {} +// func (category *Category) BeforeUpdate(tx *gorm.DB) (err error) {} +// func (category *Category) AfterUpdate(tx *gorm.DB) (err error) {} +// func (category *Category) AfterSave(tx *gorm.DB) (err error) {} +// func (category *Category) BeforeDelete(tx *gorm.DB) (err error) {} +// func (category *Category) AfterDelete(tx *gorm.DB) (err error) {} +// func (category *Category) AfterFind(tx *gorm.DB) (err error) {} \ No newline at end of file diff --git a/app/models/category/category_model.go b/app/models/category/category_model.go new file mode 100644 index 0000000..0aca742 --- /dev/null +++ b/app/models/category/category_model.go @@ -0,0 +1,30 @@ +//Package category 模型 +package category + +import ( + "gohub/app/models" + "gohub/pkg/database" +) + +type Category struct { + models.BaseModel + + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + + models.CommonTimestampsField +} + +func (category *Category) Create() { + database.DB.Create(&category) +} + +func (category *Category) Save() (rowsAffected int64) { + result := database.DB.Save(&category) + return result.RowsAffected +} + +func (category *Category) Delete() (rowsAffected int64) { + result := database.DB.Delete(&category) + return result.RowsAffected +} diff --git a/app/models/category/category_util.go b/app/models/category/category_util.go new file mode 100644 index 0000000..7186de8 --- /dev/null +++ b/app/models/category/category_util.go @@ -0,0 +1,41 @@ +package category + +import ( + "gohub/pkg/app" + "gohub/pkg/database" + "gohub/pkg/paginator" + + "github.com/gin-gonic/gin" +) + +func Get(idstr string) (category Category) { + database.DB.Where("id", idstr).First(&category) + return +} + +func GetBy(field, value string) (category Category) { + database.DB.Where("? = ?", field, value).First(&category) + return +} + +func All() (categories []Category) { + database.DB.Find(&categories) + return +} + +func IsExist(field, value string) bool { + var count int64 + database.DB.Model(Category{}).Where(" = ?", field, value).Count(&count) + return count > 0 +} + +func Paginate(c *gin.Context, perPage int) (categories []Category, paging paginator.Paging) { + paging = paginator.Paginate( + c, + database.DB.Model(Category{}), + &categories, + app.V1URL(database.TableName(&Category{})), + perPage, + ) + return +} diff --git a/app/models/link/link_hooks.go b/app/models/link/link_hooks.go new file mode 100644 index 0000000..b4ae3c5 --- /dev/null +++ b/app/models/link/link_hooks.go @@ -0,0 +1,11 @@ +package link + +// func (link *Link) BeforeSave(tx *gorm.DB) (err error) {} +// func (link *Link) BeforeCreate(tx *gorm.DB) (err error) {} +// func (link *Link) AfterCreate(tx *gorm.DB) (err error) {} +// func (link *Link) BeforeUpdate(tx *gorm.DB) (err error) {} +// func (link *Link) AfterUpdate(tx *gorm.DB) (err error) {} +// func (link *Link) AfterSave(tx *gorm.DB) (err error) {} +// func (link *Link) BeforeDelete(tx *gorm.DB) (err error) {} +// func (link *Link) AfterDelete(tx *gorm.DB) (err error) {} +// func (link *Link) AfterFind(tx *gorm.DB) (err error) {} \ No newline at end of file diff --git a/app/models/link/link_model.go b/app/models/link/link_model.go new file mode 100644 index 0000000..508ae2d --- /dev/null +++ b/app/models/link/link_model.go @@ -0,0 +1,30 @@ +//Package link 模型 +package link + +import ( + "gohub/app/models" + "gohub/pkg/database" +) + +type Link struct { + models.BaseModel + + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + + models.CommonTimestampsField +} + +func (link *Link) Create() { + database.DB.Create(&link) +} + +func (link *Link) Save() (rowsAffected int64) { + result := database.DB.Save(&link) + return result.RowsAffected +} + +func (link *Link) Delete() (rowsAffected int64) { + result := database.DB.Delete(&link) + return result.RowsAffected +} diff --git a/app/models/link/link_util.go b/app/models/link/link_util.go new file mode 100644 index 0000000..b35d233 --- /dev/null +++ b/app/models/link/link_util.go @@ -0,0 +1,65 @@ +package link + +import ( + "gohub/pkg/app" + "gohub/pkg/cache" + "gohub/pkg/database" + "gohub/pkg/helpers" + "gohub/pkg/paginator" + "time" + + "github.com/gin-gonic/gin" +) + +func Get(idstr string) (link Link) { + database.DB.Where("id", idstr).First(&link) + return +} + +func GetBy(field, value string) (link Link) { + database.DB.Where("? = ?", field, value).First(&link) + return +} + +func All() (links []Link) { + database.DB.Find(&links) + return +} + +func IsExist(field, value string) bool { + var count int64 + database.DB.Model(Link{}).Where(" = ?", field, value).Count(&count) + return count > 0 +} + +func Paginate(c *gin.Context, perPage int) (links []Link, paging paginator.Paging) { + paging = paginator.Paginate( + c, + database.DB.Model(Link{}), + &links, + app.V1URL(database.TableName(&Link{})), + perPage, + ) + return +} + +func AllCached() (links []Link) { + // 设置缓存 key + cacheKey := "links:all" + // 设置过期时间 + expireTime := 120 * time.Minute + // 取数据 + cache.GetObject(cacheKey, &links) + + // 如果数据为空 + if helpers.Empty(links) { + // 查询数据库 + links = All() + if helpers.Empty(links) { + return links + } + // 设置缓存 + cache.Set(cacheKey, links, expireTime) + } + return +} diff --git a/app/models/model.go b/app/models/model.go new file mode 100644 index 0000000..83fc541 --- /dev/null +++ b/app/models/model.go @@ -0,0 +1,24 @@ +// Package models 模型通用属性和方法 +package models + +import ( + "time" + + "github.com/spf13/cast" +) + +// BaseModel 模型基类 +type BaseModel struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement;" json:"id,omitempty"` +} + +// CommonTimestampsField 时间戳 +type CommonTimestampsField struct { + CreatedAt time.Time `gorm:"column:created_at;index;" json:"created_at,omitempty"` + UpdatedAt time.Time `gorm:"column:updated_at;index;" json:"updated_at,omitempty"` +} + +// GetStringID 获取 ID 的字符串格式 +func (a BaseModel) GetStringID() string { + return cast.ToString(a.ID) +} diff --git a/app/models/topic/topic_hooks.go b/app/models/topic/topic_hooks.go new file mode 100644 index 0000000..1c28fb1 --- /dev/null +++ b/app/models/topic/topic_hooks.go @@ -0,0 +1,11 @@ +package topic + +// func (topic *Topic) BeforeSave(tx *gorm.DB) (err error) {} +// func (topic *Topic) BeforeCreate(tx *gorm.DB) (err error) {} +// func (topic *Topic) AfterCreate(tx *gorm.DB) (err error) {} +// func (topic *Topic) BeforeUpdate(tx *gorm.DB) (err error) {} +// func (topic *Topic) AfterUpdate(tx *gorm.DB) (err error) {} +// func (topic *Topic) AfterSave(tx *gorm.DB) (err error) {} +// func (topic *Topic) BeforeDelete(tx *gorm.DB) (err error) {} +// func (topic *Topic) AfterDelete(tx *gorm.DB) (err error) {} +// func (topic *Topic) AfterFind(tx *gorm.DB) (err error) {} \ No newline at end of file diff --git a/app/models/topic/topic_model.go b/app/models/topic/topic_model.go new file mode 100644 index 0000000..0398ace --- /dev/null +++ b/app/models/topic/topic_model.go @@ -0,0 +1,40 @@ +//Package topic 模型 +package topic + +import ( + "gohub/app/models" + "gohub/app/models/category" + "gohub/app/models/user" + "gohub/pkg/database" +) + +type Topic struct { + models.BaseModel + + Title string `json:"title,omitempty" ` + Body string `json:"body,omitempty" ` + UserID string `json:"user_id,omitempty"` + CategoryID string `json:"category_id,omitempty"` + + // 通过 user_id 关联用户 + User user.User `json:"user"` + + // 通过 category_id 关联分类 + Category category.Category `json:"category"` + + models.CommonTimestampsField +} + +func (topic *Topic) Create() { + database.DB.Create(&topic) +} + +func (topic *Topic) Save() (rowsAffected int64) { + result := database.DB.Save(&topic) + return result.RowsAffected +} + +func (topic *Topic) Delete() (rowsAffected int64) { + result := database.DB.Delete(&topic) + return result.RowsAffected +} diff --git a/app/models/topic/topic_util.go b/app/models/topic/topic_util.go new file mode 100644 index 0000000..7a6a505 --- /dev/null +++ b/app/models/topic/topic_util.go @@ -0,0 +1,42 @@ +package topic + +import ( + "gohub/pkg/app" + "gohub/pkg/database" + "gohub/pkg/paginator" + + "github.com/gin-gonic/gin" + "gorm.io/gorm/clause" +) + +func Get(idstr string) (topic Topic) { + database.DB.Preload(clause.Associations).Where("id", idstr).First(&topic) + return +} + +func GetBy(field, value string) (topic Topic) { + database.DB.Where("? = ?", field, value).First(&topic) + return +} + +func All() (topics []Topic) { + database.DB.Find(&topics) + return +} + +func IsExist(field, value string) bool { + var count int64 + database.DB.Model(Topic{}).Where(" = ?", field, value).Count(&count) + return count > 0 +} + +func Paginate(c *gin.Context, perPage int) (topics []Topic, paging paginator.Paging) { + paging = paginator.Paginate( + c, + database.DB.Model(Topic{}), + &topics, + app.V1URL(database.TableName(&Topic{})), + perPage, + ) + return +} diff --git a/app/models/user/user_hooks.go b/app/models/user/user_hooks.go new file mode 100644 index 0000000..d09a1e0 --- /dev/null +++ b/app/models/user/user_hooks.go @@ -0,0 +1,16 @@ +package user + +import ( + "gohub/pkg/hash" + + "gorm.io/gorm" +) + +// BeforeSave GORM 的模型钩子,在创建和更新模型前调用 +func (userModel *User) BeforeSave(tx *gorm.DB) (err error) { + + if !hash.BcryptIsHashed(userModel.Password) { + userModel.Password = hash.BcryptHash(userModel.Password) + } + return +} diff --git a/app/models/user/user_model.go b/app/models/user/user_model.go new file mode 100644 index 0000000..8e43c20 --- /dev/null +++ b/app/models/user/user_model.go @@ -0,0 +1,40 @@ +// Package user 存放用户 Model 相关逻辑 +package user + +import ( + "gohub/app/models" + "gohub/pkg/database" + "gohub/pkg/hash" +) + +// User 用户模型 +type User struct { + models.BaseModel + + Name string `json:"name,omitempty"` + + City string `json:"city,omitempty"` + Indtroduction string `json:"indtroduction,omitempty"` + Avatar string `json:"avatar,omitempty"` + + Email string `json:"-"` + Phone string `json:"-"` + Password string `json:"-"` + + models.CommonTimestampsField +} + +// Create 创建用户,通过 User.ID 来判断是否创建成功 +func (userModel *User) Create() { + database.DB.Create(&userModel) +} + +// ComparePassword 密码是否正确 +func (userModel *User) ComparePassword(_password string) bool { + return hash.BcryptCheck(_password, userModel.Password) +} + +func (userModel *User) Save() (rowsAffected int64) { + result := database.DB.Save(&userModel) + return result.RowsAffected +} diff --git a/app/models/user/user_util.go b/app/models/user/user_util.go new file mode 100644 index 0000000..5e59bfd --- /dev/null +++ b/app/models/user/user_util.go @@ -0,0 +1,69 @@ +package user + +import ( + "gohub/pkg/app" + "gohub/pkg/database" + "gohub/pkg/paginator" + + "github.com/gin-gonic/gin" +) + +// IsEmailExist 判断 Email 已被注册 +func IsEmailExist(email string) bool { + var count int64 + database.DB.Model(User{}).Where("email = ?", email).Count(&count) + return count > 0 +} + +// IsPhoneExist 判断手机号已被注册 +func IsPhoneExist(phone string) bool { + var count int64 + database.DB.Model(User{}).Where("phone = ?", phone).Count(&count) + return count > 0 +} + +// GetByPhone 通过手机号来获取用户 +func GetByPhone(email string) (userModel User) { + database.DB.Where("phone = ?", email).First(&userModel) + return +} + +// GetByMulti 通过 手机号/Email/用户名 来获取用户 +func GetByMulti(loginID string) (userModel User) { + database.DB. + Where("phone = ?", loginID). + Or("email = ?", loginID). + Or("name = ?", loginID). + First(&userModel) + return +} + +// Get 通过 ID 获取用户 +func Get(idstr string) (userModel User) { + database.DB.Where("id", idstr).First(&userModel) + return +} + +// GetByEmail 通过 Email 来获取用户 +func GetByEmail(email string) (userModel User) { + database.DB.Where("email = ?", email).First(&userModel) + return +} + +// All 获取所有用户数据 +func All() (users []User) { + database.DB.Find(&users) + return +} + +// Paginate 分页内容 +func Paginate(c *gin.Context, perPage int) (users []User, paging paginator.Paging) { + paging = paginator.Paginate( + c, + database.DB.Model(User{}), + &users, + app.V1URL(database.TableName(&User{})), + perPage, + ) + return +} diff --git a/app/policies/topic_policy.go b/app/policies/topic_policy.go new file mode 100644 index 0000000..fcb4cce --- /dev/null +++ b/app/policies/topic_policy.go @@ -0,0 +1,13 @@ +// Package policies 用户授权 +package policies + +import ( + "gohub/app/models/topic" + "gohub/pkg/auth" + + "github.com/gin-gonic/gin" +) + +func CanModifyTopic(c *gin.Context, _topic topic.Topic) bool { + return auth.CurrentUID(c) == _topic.UserID +} diff --git a/app/requests/category_request.go b/app/requests/category_request.go new file mode 100644 index 0000000..332e1ca --- /dev/null +++ b/app/requests/category_request.go @@ -0,0 +1,32 @@ +package requests + +import ( + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type CategoryRequest struct { + Name string `valid:"name" json:"name"` + Description string `valid:"description" json:"description,omitempty"` +} + +func CategorySave(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "name": []string{"required", "min_cn:2", "max_cn:8", "not_exists:categories,name"}, + "description": []string{"min_cn:3", "max_cn:255"}, + } + messages := govalidator.MapData{ + "name": []string{ + "required:分类名称为必填项", + "min_cn:分类名称长度需至少 2 个字", + "max_cn:分类名称长度不能超过 8 个字", + "not_exists:分类名称已存在", + }, + "description": []string{ + "min_cn:分类描述长度需至少 3 个字", + "max_cn:分类描述长度不能超过 255 个字", + }, + } + return validate(data, rules, messages) +} diff --git a/app/requests/login_request.go b/app/requests/login_request.go new file mode 100644 index 0000000..dd1686e --- /dev/null +++ b/app/requests/login_request.go @@ -0,0 +1,84 @@ +package requests + +import ( + "gohub/app/requests/validators" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type LoginByPhoneRequest struct { + Phone string `json:"phone,omitempty" valid:"phone"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` +} + +// LoginByPhone 验证表单,返回长度等于零即通过 +func LoginByPhone(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "phone": []string{"required", "digits:11"}, + "verify_code": []string{"required", "digits:6"}, + } + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + // 手机验证码 + _data := data.(*LoginByPhoneRequest) + errs = validators.ValidateVerifyCode(_data.Phone, _data.VerifyCode, errs) + + return errs +} + +type LoginByPasswordRequest struct { + CaptchaID string `json:"captcha_id,omitempty" valid:"captcha_id"` + CaptchaAnswer string `json:"captcha_answer,omitempty" valid:"captcha_answer"` + + LoginID string `valid:"login_id" json:"login_id"` + Password string `valid:"password" json:"password,omitempty"` +} + +// LoginByPassword 验证表单,返回长度等于零即通过 +func LoginByPassword(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "login_id": []string{"required", "min:3"}, + "password": []string{"required", "min:6"}, + "captcha_id": []string{"required"}, + "captcha_answer": []string{"required", "digits:6"}, + } + messages := govalidator.MapData{ + "login_id": []string{ + "required:登录 ID 为必填项,支持手机号、邮箱和用户名", + "min:登录 ID 长度需大于 3", + }, + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + "captcha_id": []string{ + "required:图片验证码的 ID 为必填", + }, + "captcha_answer": []string{ + "required:图片验证码答案必填", + "digits:图片验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + // 图片验证码 + _data := data.(*LoginByPasswordRequest) + errs = validators.ValidateCaptcha(_data.CaptchaID, _data.CaptchaAnswer, errs) + + return errs +} diff --git a/app/requests/pagination_request.go b/app/requests/pagination_request.go new file mode 100644 index 0000000..32f60e0 --- /dev/null +++ b/app/requests/pagination_request.go @@ -0,0 +1,33 @@ +package requests + +import ( + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type PaginationRequest struct { + Sort string `valid:"sort" form:"sort"` + Order string `valid:"order" form:"order"` + PerPage string `valid:"per_page" form:"per_page"` +} + +func Pagination(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "sort": []string{"in:id,created_at,updated_at"}, + "order": []string{"in:asc,desc"}, + "per_page": []string{"numeric_between:2,100"}, + } + messages := govalidator.MapData{ + "sort": []string{ + "in:排序字段仅支持 id,created_at,updated_at", + }, + "order": []string{ + "in:排序规则仅支持 asc(正序),desc(倒序)", + }, + "per_page": []string{ + "numeric_between:每页条数的值介于 2~100 之间", + }, + } + return validate(data, rules, messages) +} diff --git a/app/requests/password_request.go b/app/requests/password_request.go new file mode 100644 index 0000000..c8f7611 --- /dev/null +++ b/app/requests/password_request.go @@ -0,0 +1,86 @@ +package requests + +import ( + "gohub/app/requests/validators" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type ResetByPhoneRequest struct { + Phone string `json:"phone,omitempty" valid:"phone"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` + Password string `valid:"password" json:"password,omitempty"` +} + +// ResetByPhone 验证表单,返回长度等于零即通过 +func ResetByPhone(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "phone": []string{"required", "digits:11"}, + "verify_code": []string{"required", "digits:6"}, + "password": []string{"required", "min:6"}, + } + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + } + + errs := validate(data, rules, messages) + + // 检查验证码 + _data := data.(*ResetByPhoneRequest) + errs = validators.ValidateVerifyCode(_data.Phone, _data.VerifyCode, errs) + + return errs +} + +type ResetByEmailRequest struct { + Email string `json:"email,omitempty" valid:"email"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` + Password string `valid:"password" json:"password,omitempty"` +} + +// ResetByEmail 验证表单,返回长度等于零即通过 +func ResetByEmail(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "email": []string{"required", "min:4", "max:30", "email"}, + "verify_code": []string{"required", "digits:6"}, + "password": []string{"required", "min:6"}, + } + messages := govalidator.MapData{ + "email": []string{ + "required:Email 为必填项", + "min:Email 长度需大于 4", + "max:Email 长度需小于 30", + "email:Email 格式不正确,请提供有效的邮箱地址", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + } + + errs := validate(data, rules, messages) + + // 检查验证码 + _data := data.(*ResetByEmailRequest) + errs = validators.ValidateVerifyCode(_data.Email, _data.VerifyCode, errs) + + return errs +} diff --git a/app/requests/requests.go b/app/requests/requests.go new file mode 100644 index 0000000..a861e66 --- /dev/null +++ b/app/requests/requests.go @@ -0,0 +1,59 @@ +// Package requests 处理请求数据和表单验证 +package requests + +import ( + "gohub/pkg/response" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +// ValidatorFunc 验证函数类型 +type ValidatorFunc func(interface{}, *gin.Context) map[string][]string + +// Validate 控制器里调用示例: +// if ok := requests.Validate(c, &requests.UserSaveRequest{}, requests.UserSave); !ok { +// return +// } +func Validate(c *gin.Context, obj interface{}, handler ValidatorFunc) bool { + + // 1. 解析请求,支持 JSON 数据、表单请求和 URL Query + if err := c.ShouldBind(obj); err != nil { + response.BadRequest(c, err, "请求解析错误,请确认请求格式是否正确。上传文件请使用 multipart 标头,参数请使用 JSON 格式。") + return false + } + + // 2. 表单验证 + errs := handler(obj, c) + + // 3. 判断验证是否通过 + if len(errs) > 0 { + response.ValidationError(c, errs) + return false + } + + return true +} + +func validate(data interface{}, rules govalidator.MapData, messages govalidator.MapData) map[string][]string { + // 配置选项 + opts := govalidator.Options{ + Data: data, + Rules: rules, + TagIdentifier: "valid", // 模型中的 Struct 标签标识符 + Messages: messages, + } + // 开始验证 + return govalidator.New(opts).ValidateStruct() +} + +func validateFile(c *gin.Context, data interface{}, rules govalidator.MapData, messages govalidator.MapData) map[string][]string { + opts := govalidator.Options{ + Request: c.Request, + Rules: rules, + Messages: messages, + TagIdentifier: "valid", + } + // 调用 govalidator 的 Validate 方法来验证文件 + return govalidator.New(opts).Validate() +} diff --git a/app/requests/signup_request.go b/app/requests/signup_request.go new file mode 100644 index 0000000..3129cbe --- /dev/null +++ b/app/requests/signup_request.go @@ -0,0 +1,154 @@ +// Package requests 处理请求数据和表单验证 +package requests + +import ( + "gohub/app/requests/validators" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type SignupPhoneExistRequest struct { + Phone string `json:"phone,omitempty" valid:"phone"` +} + +func SignupPhoneExist(data interface{}, c *gin.Context) map[string][]string { + // 自定义验证规则 + rules := govalidator.MapData{ + "phone": []string{"required", "digits:11"}, + } + // 自定义验证出错时的提示 + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + }, + } + return validate(data, rules, messages) +} + +type SignupEmailExistRequest struct { + Email string `json:"email,omitempty" valid:"email"` +} + +func SignupEmailExist(data interface{}, c *gin.Context) map[string][]string { + // 自定义验证规则 + rules := govalidator.MapData{ + "email": []string{"required", "min:4", "max:30", "email"}, + } + // 自定义验证出错时的提示 + messages := govalidator.MapData{ + "email": []string{ + "required:Email 为必填项", + "min:Email 长度需大于 4", + "max:Email 长度需小于 30", + "email:Email 格式不正确,请提供有效的邮箱地址", + }, + } + return validate(data, rules, messages) +} + +// SignupUsingPhoneRequest 通过手机注册的请求信息 +type SignupUsingPhoneRequest struct { + Phone string `json:"phone,omitempty" valid:"phone"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` + Name string `valid:"name" json:"name"` + Password string `valid:"password" json:"password,omitempty"` + PasswordConfirm string `valid:"password_confirm" json:"password_confirm,omitempty"` +} + +func SignupUsingPhone(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "phone": []string{"required", "digits:11", "not_exists:users,phone"}, + "name": []string{"required", "alpha_num", "between:3,20", "not_exists:users,name"}, + "password": []string{"required", "min:6"}, + "password_confirm": []string{"required"}, + "verify_code": []string{"required", "digits:6"}, + } + + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + }, + "name": []string{ + "required:用户名为必填项", + "alpha_num:用户名格式错误,只允许数字和英文", + "between:用户名长度需在 3~20 之间", + }, + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + "password_confirm": []string{ + "required:确认密码框为必填项", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + _data := data.(*SignupUsingPhoneRequest) + errs = validators.ValidatePasswordConfirm(_data.Password, _data.PasswordConfirm, errs) + errs = validators.ValidateVerifyCode(_data.Phone, _data.VerifyCode, errs) + + return errs +} + +// SignupUsingEmailRequest 通过邮箱注册的请求信息 +type SignupUsingEmailRequest struct { + Email string `json:"email,omitempty" valid:"email"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` + Name string `valid:"name" json:"name"` + Password string `valid:"password" json:"password,omitempty"` + PasswordConfirm string `valid:"password_confirm" json:"password_confirm,omitempty"` +} + +func SignupUsingEmail(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "email": []string{"required", "min:4", "max:30", "email", "not_exists:users,email"}, + "name": []string{"required", "alpha_num", "between:3,20", "not_exists:users,name"}, + "password": []string{"required", "min:6"}, + "password_confirm": []string{"required"}, + "verify_code": []string{"required", "digits:6"}, + } + + messages := govalidator.MapData{ + "email": []string{ + "required:Email 为必填项", + "min:Email 长度需大于 4", + "max:Email 长度需小于 30", + "email:Email 格式不正确,请提供有效的邮箱地址", + "not_exists:Email 已被占用", + }, + "name": []string{ + "required:用户名为必填项", + "alpha_num:用户名格式错误,只允许数字和英文", + "between:用户名长度需在 3~20 之间", + }, + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + "password_confirm": []string{ + "required:确认密码框为必填项", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + _data := data.(*SignupUsingEmailRequest) + errs = validators.ValidatePasswordConfirm(_data.Password, _data.PasswordConfirm, errs) + errs = validators.ValidateVerifyCode(_data.Email, _data.VerifyCode, errs) + + return errs +} diff --git a/app/requests/topic_request.go b/app/requests/topic_request.go new file mode 100644 index 0000000..22748c6 --- /dev/null +++ b/app/requests/topic_request.go @@ -0,0 +1,37 @@ +package requests + +import ( + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type TopicRequest struct { + Title string `json:"title,omitempty" valid:"title"` + Body string `json:"body,omitempty" valid:"body"` + CategoryID string `json:"category_id,omitempty" valid:"category_id"` +} + +func TopicSave(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + "title": []string{"required", "min_cn:3", "max_cn:40"}, + "body": []string{"required", "min_cn:10", "max_cn:50000"}, + "category_id": []string{"required", "exists:categories,id"}, + } + messages := govalidator.MapData{ + "title": []string{ + "required:帖子标题为必填项", + "min_cn:标题长度需大于 3", + "max_cn:标题长度需小于 40", + }, + "body": []string{ + "required:帖子内容为必填项", + "min_cn:长度需大于 10", + }, + "category_id": []string{ + "required:帖子分类为必填项", + "exists:帖子分类未找到", + }, + } + return validate(data, rules, messages) +} diff --git a/app/requests/user_request.go b/app/requests/user_request.go new file mode 100644 index 0000000..0bf8acc --- /dev/null +++ b/app/requests/user_request.go @@ -0,0 +1,182 @@ +package requests + +import ( + "gohub/app/requests/validators" + "gohub/pkg/auth" + "mime/multipart" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type UserUpdateProfileRequest struct { + Name string `valid:"name" json:"name"` + City string `valid:"city" json:"city"` + Indtroduction string `valid:"indtroduction" json:"indtroduction"` +} + +func UserUpdateProfile(data interface{}, c *gin.Context) map[string][]string { + + // 查询用户名重复时,过滤掉当前用户 ID + uid := auth.CurrentUID(c) + rules := govalidator.MapData{ + "name": []string{"required", "alpha_num", "between:3,20", "not_exists:users,name," + uid}, + "indtroduction": []string{"min_cn:4", "max_cn:240"}, + "city": []string{"min_cn:2", "max_cn:20"}, + } + + messages := govalidator.MapData{ + "name": []string{ + "required:用户名为必填项", + "alpha_num:用户名格式错误,只允许数字和英文", + "between:用户名长度需在 3~20 之间", + "not_exists:用户名已被占用", + }, + "indtroduction": []string{ + "min_cn:描述长度需至少 4 个字", + "max_cn:描述长度不能超过 240 个字", + }, + "city": []string{ + "min_cn:城市需至少 2 个字", + "max_cn:城市不能超过 20 个字", + }, + } + return validate(data, rules, messages) +} + +type UserUpdateEmailRequest struct { + Email string `json:"email,omitempty" valid:"email"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` +} + +func UserUpdateEmail(data interface{}, c *gin.Context) map[string][]string { + + currentUser := auth.CurrentUser(c) + rules := govalidator.MapData{ + "email": []string{ + "required", "min:4", + "max:30", + "email", + "not_exists:users,email," + currentUser.GetStringID(), + "not_in:" + currentUser.Email, + }, + "verify_code": []string{"required", "digits:6"}, + } + messages := govalidator.MapData{ + "email": []string{ + "required:Email 为必填项", + "min:Email 长度需大于 4", + "max:Email 长度需小于 30", + "email:Email 格式不正确,请提供有效的邮箱地址", + "not_exists:Email 已被占用", + "not_in:新的 Email 与老 Email 一致", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + _data := data.(*UserUpdateEmailRequest) + errs = validators.ValidateVerifyCode(_data.Email, _data.VerifyCode, errs) + + return errs +} + +type UserUpdatePhoneRequest struct { + Phone string `json:"phone,omitempty" valid:"phone"` + VerifyCode string `json:"verify_code,omitempty" valid:"verify_code"` +} + +func UserUpdatePhone(data interface{}, c *gin.Context) map[string][]string { + + currentUser := auth.CurrentUser(c) + + rules := govalidator.MapData{ + "phone": []string{ + "required", + "digits:11", + "not_exists:users,phone," + currentUser.GetStringID(), + "not_in:" + currentUser.Phone, + }, + "verify_code": []string{"required", "digits:6"}, + } + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + "not_exists:手机号已被占用", + "not_in:新的手机与老手机号一致", + }, + "verify_code": []string{ + "required:验证码答案必填", + "digits:验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + _data := data.(*UserUpdatePhoneRequest) + errs = validators.ValidateVerifyCode(_data.Phone, _data.VerifyCode, errs) + + return errs +} + +type UserUpdatePasswordRequest struct { + Password string `valid:"password" json:"password,omitempty"` + NewPassword string `valid:"new_password" json:"new_password,omitempty"` + NewPasswordConfirm string `valid:"new_password_confirm" json:"new_password_confirm,omitempty"` +} + +func UserUpdatePassword(data interface{}, c *gin.Context) map[string][]string { + rules := govalidator.MapData{ + "password": []string{"required", "min:6"}, + "new_password": []string{"required", "min:6"}, + "new_password_confirm": []string{"required", "min:6"}, + } + messages := govalidator.MapData{ + "password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + "new_password": []string{ + "required:密码为必填项", + "min:密码长度需大于 6", + }, + "new_password_confirm": []string{ + "required:确认密码框为必填项", + "min:确认密码长度需大于 6", + }, + } + + // 确保 comfirm 密码正确 + errs := validate(data, rules, messages) + _data := data.(*UserUpdatePasswordRequest) + errs = validators.ValidatePasswordConfirm(_data.NewPassword, _data.NewPasswordConfirm, errs) + + return errs +} + +type UserUpdateAvatarRequest struct { + Avatar *multipart.FileHeader `valid:"avatar" form:"avatar"` +} + +func UserUpdateAvatar(data interface{}, c *gin.Context) map[string][]string { + + rules := govalidator.MapData{ + // size 的单位为 bytes + // - 1024 bytes 为 1kb + // - 1048576 bytes 为 1mb + // - 20971520 bytes 为 20mb + "file:avatar": []string{"ext:png,jpg,jpeg", "size:20971520", "required"}, + } + messages := govalidator.MapData{ + "file:avatar": []string{ + "ext:ext头像只能上传 png, jpg, jpeg 任意一种的图片", + "size:头像文件最大不能超过 20MB", + "required:必须上传图片", + }, + } + + return validateFile(c, data, rules, messages) +} diff --git a/app/requests/validators/custom_rules.go b/app/requests/validators/custom_rules.go new file mode 100644 index 0000000..a72910e --- /dev/null +++ b/app/requests/validators/custom_rules.go @@ -0,0 +1,121 @@ +// Package validators 存放自定义规则和验证器 +package validators + +import ( + "errors" + "fmt" + "gohub/pkg/database" + "strconv" + "strings" + "unicode/utf8" + + "github.com/thedevsaddam/govalidator" +) + +// 此方法会在初始化时执行,注册自定义表单验证规则 +func init() { + + // 自定义规则 not_exists,验证请求数据必须不存在于数据库中。 + // 常用于保证数据库某个字段的值唯一,如用户名、邮箱、手机号、或者分类的名称。 + // not_exists 参数可以有两种,一种是 2 个参数,一种是 3 个参数: + // not_exists:users,email 检查数据库表里是否存在同一条信息 + // not_exists:users,email,32 排除用户掉 id 为 32 的用户 + govalidator.AddCustomRule("not_exists", func(field string, rule string, message string, value interface{}) error { + rng := strings.Split(strings.TrimPrefix(rule, "not_exists:"), ",") + + // 第一个参数,表名称,如 users + tableName := rng[0] + // 第二个参数,字段名称,如 email 或者 phone + dbFiled := rng[1] + + // 第三个参数,排除 ID + var exceptID string + if len(rng) > 2 { + exceptID = rng[2] + } + + // 用户请求过来的数据 + requestValue := value.(string) + + // 拼接 SQL + query := database.DB.Table(tableName).Where(dbFiled+" = ?", requestValue) + + // 如果传参第三个参数,加上 SQL Where 过滤 + if len(exceptID) > 0 { + query.Where("id != ?", exceptID) + } + + // 查询数据库 + var count int64 + query.Count(&count) + + // 验证不通过,数据库能找到对应的数据 + if count != 0 { + // 如果有自定义错误消息的话 + if message != "" { + return errors.New(message) + } + // 默认的错误消息 + return fmt.Errorf("%v 已被占用", requestValue) + } + // 验证通过 + return nil + }) + + // max_cn:8 中文长度设定不超过 8 + govalidator.AddCustomRule("max_cn", func(field string, rule string, message string, value interface{}) error { + valLength := utf8.RuneCountInString(value.(string)) + l, _ := strconv.Atoi(strings.TrimPrefix(rule, "max_cn:")) + if valLength > l { + // 如果有自定义错误消息的话,使用自定义消息 + if message != "" { + return errors.New(message) + } + return fmt.Errorf("长度不能超过 %d 个字", l) + } + return nil + }) + + // min_cn:2 中文长度设定不小于 2 + govalidator.AddCustomRule("min_cn", func(field string, rule string, message string, value interface{}) error { + valLength := utf8.RuneCountInString(value.(string)) + l, _ := strconv.Atoi(strings.TrimPrefix(rule, "min_cn:")) + if valLength < l { + // 如果有自定义错误消息的话,使用自定义消息 + if message != "" { + return errors.New(message) + } + return fmt.Errorf("长度需大于 %d 个字", l) + } + return nil + }) + + // 自定义规则 exists,确保数据库存在某条数据 + // 一个使用场景是创建话题时需要附带 category_id 分类 ID 为参数,此时需要保证 + // category_id 的值在数据库中存在,即可使用: + // exists:categories,id + govalidator.AddCustomRule("exists", func(field string, rule string, message string, value interface{}) error { + rng := strings.Split(strings.TrimPrefix(rule, "exists:"), ",") + + // 第一个参数,表名称,如 categories + tableName := rng[0] + // 第二个参数,字段名称,如 id + dbFiled := rng[1] + + // 用户请求过来的数据 + requestValue := value.(string) + + // 查询数据库 + var count int64 + database.DB.Table(tableName).Where(dbFiled+" = ?", requestValue).Count(&count) + // 验证不通过,数据不存在 + if count == 0 { + // 如果有自定义错误消息的话,使用自定义消息 + if message != "" { + return errors.New(message) + } + return fmt.Errorf("%v 不存在", requestValue) + } + return nil + }) +} diff --git a/app/requests/validators/custom_validators.go b/app/requests/validators/custom_validators.go new file mode 100644 index 0000000..5136d3f --- /dev/null +++ b/app/requests/validators/custom_validators.go @@ -0,0 +1,31 @@ +// Package validators 存放自定义规则和验证器 +package validators + +import ( + "gohub/pkg/captcha" + "gohub/pkg/verifycode" +) + +// ValidateCaptcha 自定义规则,验证『图片验证码』 +func ValidateCaptcha(captchaID, captchaAnswer string, errs map[string][]string) map[string][]string { + if ok := captcha.NewCaptcha().VerifyCaptcha(captchaID, captchaAnswer); !ok { + errs["captcha_answer"] = append(errs["captcha_answer"], "图片验证码错误") + } + return errs +} + +// ValidatePasswordConfirm 自定义规则,检查两次密码是否正确 +func ValidatePasswordConfirm(password, passwordConfirm string, errs map[string][]string) map[string][]string { + if password != passwordConfirm { + errs["password_confirm"] = append(errs["password_confirm"], "两次输入密码不匹配!") + } + return errs +} + +// ValidateVerifyCode 自定义规则,验证『手机/邮箱验证码』 +func ValidateVerifyCode(key, answer string, errs map[string][]string) map[string][]string { + if ok := verifycode.NewVerifyCode().CheckAnswer(key, answer); !ok { + errs["verify_code"] = append(errs["verify_code"], "验证码错误") + } + return errs +} diff --git a/app/requests/verify_code_request.go b/app/requests/verify_code_request.go new file mode 100644 index 0000000..57b697b --- /dev/null +++ b/app/requests/verify_code_request.go @@ -0,0 +1,92 @@ +package requests + +import ( + "gohub/app/requests/validators" + + "github.com/gin-gonic/gin" + "github.com/thedevsaddam/govalidator" +) + +type VeifyCodePhoneRequest struct { + CaptchaID string `json:"captcha_id,omitempty" valid:"captcha_id"` + CaptchaAnswer string `json:"captcha_answer,omitempty" valid:"captcha_answer"` + + Phone string `json:"phone,omitempty" valid:"phone"` +} + +// VeifyCodePhone 验证表单,返回长度等于零即通过 +func VeifyCodePhone(data interface{}, c *gin.Context) map[string][]string { + + // 1. 定制认证规则 + rules := govalidator.MapData{ + "phone": []string{"required", "digits:11"}, + "captcha_id": []string{"required"}, + "captcha_answer": []string{"required", "digits:6"}, + } + + // 2. 定制错误消息 + messages := govalidator.MapData{ + "phone": []string{ + "required:手机号为必填项,参数名称 phone", + "digits:手机号长度必须为 11 位的数字", + }, + "captcha_id": []string{ + "required:图片验证码的 ID 为必填", + }, + "captcha_answer": []string{ + "required:图片验证码答案必填", + "digits:图片验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + // 图片验证码 + _data := data.(*VeifyCodePhoneRequest) + errs = validators.ValidateCaptcha(_data.CaptchaID, _data.CaptchaAnswer, errs) + + return errs +} + +type VeifyCodeEmailRequest struct { + CaptchaID string `json:"captcha_id,omitempty" valid:"captcha_id"` + CaptchaAnswer string `json:"captcha_answer,omitempty" valid:"captcha_answer"` + + Email string `json:"email,omitempty" valid:"email"` +} + +// VeifyCodeEmail 验证表单,返回长度等于零即通过 +func VeifyCodeEmail(data interface{}, c *gin.Context) map[string][]string { + + // 1. 定制认证规则 + rules := govalidator.MapData{ + "email": []string{"required", "min:4", "max:30", "email"}, + "captcha_id": []string{"required"}, + "captcha_answer": []string{"required", "digits:6"}, + } + + // 2. 定制错误消息 + messages := govalidator.MapData{ + "email": []string{ + "required:Email 为必填项", + "min:Email 长度需大于 4", + "max:Email 长度需小于 30", + "email:Email 格式不正确,请提供有效的邮箱地址", + }, + "captcha_id": []string{ + "required:图片验证码的 ID 为必填", + }, + "captcha_answer": []string{ + "required:图片验证码答案必填", + "digits:图片验证码长度必须为 6 位的数字", + }, + } + + errs := validate(data, rules, messages) + + // 图片验证码 + _data := data.(*VeifyCodeEmailRequest) + errs = validators.ValidateCaptcha(_data.CaptchaID, _data.CaptchaAnswer, errs) + + return errs +} diff --git a/bootstrap/cache.go b/bootstrap/cache.go new file mode 100644 index 0000000..a630feb --- /dev/null +++ b/bootstrap/cache.go @@ -0,0 +1,22 @@ +// Package bootstrap 启动程序功能 +package bootstrap + +import ( + "fmt" + "gohub/pkg/cache" + "gohub/pkg/config" +) + +// SetupCache 缓存 +func SetupCache() { + + // 初始化缓存专用的 redis client, 使用专属缓存 DB + rds := cache.NewRedisStore( + fmt.Sprintf("%v:%v", config.GetString("redis.host"), config.GetString("redis.port")), + config.GetString("redis.username"), + config.GetString("redis.password"), + config.GetInt("redis.database_cache"), + ) + + cache.InitWithCacheStore(rds) +} diff --git a/bootstrap/database.go b/bootstrap/database.go new file mode 100644 index 0000000..835973f --- /dev/null +++ b/bootstrap/database.go @@ -0,0 +1,52 @@ +package bootstrap + +import ( + "errors" + "fmt" + "gohub/pkg/config" + "gohub/pkg/database" + "gohub/pkg/logger" + "time" + + "gorm.io/driver/mysql" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// SetupDB 初始化数据库和 ORM +func SetupDB() { + + var dbConfig gorm.Dialector + switch config.Get("database.connection") { + case "mysql": + // 构建 DSN 信息 + dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=%v&parseTime=True&multiStatements=true&loc=Local", + config.Get("database.mysql.username"), + config.Get("database.mysql.password"), + config.Get("database.mysql.host"), + config.Get("database.mysql.port"), + config.Get("database.mysql.database"), + config.Get("database.mysql.charset"), + ) + dbConfig = mysql.New(mysql.Config{ + DSN: dsn, + }) + case "sqlite": + // 初始化 sqlite + database := config.Get("database.sqlite.database") + dbConfig = sqlite.Open(database) + default: + panic(errors.New("database connection not supported")) + } + + // 连接数据库,并设置 GORM 的日志模式 + database.Connect(dbConfig, logger.NewGormLogger()) + + // 设置最大连接数 + database.SQLDB.SetMaxOpenConns(config.GetInt("database.mysql.max_open_connections")) + // 设置最大空闲连接数 + database.SQLDB.SetMaxIdleConns(config.GetInt("database.mysql.max_idle_connections")) + // 设置每个链接的过期时间 + database.SQLDB.SetConnMaxLifetime(time.Duration(config.GetInt("database.mysql.max_life_seconds")) * time.Second) + +} diff --git a/bootstrap/logger.go b/bootstrap/logger.go new file mode 100644 index 0000000..7f909f4 --- /dev/null +++ b/bootstrap/logger.go @@ -0,0 +1,20 @@ +package bootstrap + +import ( + "gohub/pkg/config" + "gohub/pkg/logger" +) + +// SetupLogger 初始化 Logger +func SetupLogger() { + + logger.InitLogger( + config.GetString("log.filename"), + config.GetInt("log.max_size"), + config.GetInt("log.max_backup"), + config.GetInt("log.max_age"), + config.GetBool("log.compress"), + config.GetString("log.type"), + config.GetString("log.level"), + ) +} diff --git a/bootstrap/redis.go b/bootstrap/redis.go new file mode 100644 index 0000000..43e008f --- /dev/null +++ b/bootstrap/redis.go @@ -0,0 +1,19 @@ +package bootstrap + +import ( + "fmt" + "gohub/pkg/config" + "gohub/pkg/redis" +) + +// SetupRedis 初始化 Redis +func SetupRedis() { + + // 建立 Redis 连接 + redis.ConnectRedis( + fmt.Sprintf("%v:%v", config.GetString("redis.host"), config.GetString("redis.port")), + config.GetString("redis.username"), + config.GetString("redis.password"), + config.GetInt("redis.database"), + ) +} diff --git a/bootstrap/route.go b/bootstrap/route.go new file mode 100644 index 0000000..c0e38d7 --- /dev/null +++ b/bootstrap/route.go @@ -0,0 +1,50 @@ +// Package bootstrap 处理程序初始化逻辑 +package bootstrap + +import ( + "gohub/app/http/middlewares" + "gohub/routes" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// SetupRoute 路由初始化 +func SetupRoute(router *gin.Engine) { + + // 注册全局中间件 + registerGlobalMiddleWare(router) + + // 注册 API 路由 + routes.RegisterAPIRoutes(router) + + // 配置 404 路由 + setup404Handler(router) +} + +func registerGlobalMiddleWare(router *gin.Engine) { + router.Use( + middlewares.Logger(), + middlewares.Recovery(), + middlewares.ForceUA(), + ) +} + +func setup404Handler(router *gin.Engine) { + // 处理 404 请求 + router.NoRoute(func(c *gin.Context) { + // 获取标头信息的 Accept 信息 + acceptString := c.Request.Header.Get("Accept") + if strings.Contains(acceptString, "text/html") { + // 如果是 HTML 的话 + c.String(http.StatusNotFound, "页面返回 404") + } else { + // 默认返回 JSON + c.JSON(http.StatusNotFound, gin.H{ + "error_code": 404, + "error_message": "路由未定义,请确认 url 和请求方法是否正确。", + }) + } + }) +} diff --git a/config/app.go b/config/app.go new file mode 100644 index 0000000..44a44b3 --- /dev/null +++ b/config/app.go @@ -0,0 +1,35 @@ +// Package config 站点配置信息 +package config + +import "gohub/pkg/config" + +func init() { + config.Add("app", func() map[string]interface{} { + return map[string]interface{}{ + + // 应用名称 + "name": config.Env("APP_NAME", "Gohub"), + + // 当前环境,用以区分多环境,一般为 local, stage, production, test + "env": config.Env("APP_ENV", "production"), + + // 是否进入调试模式 + "debug": config.Env("APP_DEBUG", false), + + // 应用服务端口 + "port": config.Env("APP_PORT", "3000"), + + // 加密会话、JWT 加密 + "key": config.Env("APP_KEY", "33446a9dcf9ea060a0a6532b166da32f304af0de"), + + // 用以生成链接 + "url": config.Env("APP_URL", "http://localhost:3000"), + + // 设置时区,JWT 里会使用,日志记录里也会使用到 + "timezone": config.Env("TIMEZONE", "Asia/Shanghai"), + + // API 域名,未设置的话所有 API URL 加 api 前缀,如 http://domain.com/api/v1/users + "api_domain": config.Env("API_DOMAIN"), + } + }) +} diff --git a/config/captcha.go b/config/captcha.go new file mode 100644 index 0000000..8fce4ff --- /dev/null +++ b/config/captcha.go @@ -0,0 +1,34 @@ +package config + +import "gohub/pkg/config" + +func init() { + config.Add("captcha", func() map[string]interface{} { + return map[string]interface{}{ + + // 验证码图片长度 + "height": 80, + + // 验证码图片宽度 + "width": 240, + + // 验证码的长度 + "length": 6, + + // 数字的最大倾斜角度 + "maxskew": 0.7, + + // 图片背景里的混淆点数量 + "dotcount": 80, + + // 过期时间,单位是分钟 + "expire_time": 15, + + // debug 模式下的过期时间,方便本地开发调试 + "debug_expire_time": 10080, + + // 非 production 环境,使用此 key 可跳过验证,方便测试 + "testing_key": "captcha_skip_test", + } + }) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..5b100df --- /dev/null +++ b/config/config.go @@ -0,0 +1,6 @@ +// Package config 存放程序所有的配置信息 +package config + +// Initialize 触发加载 config 包的所有 init 函数 +func Initialize() { +} diff --git a/config/database.go b/config/database.go new file mode 100644 index 0000000..506208d --- /dev/null +++ b/config/database.go @@ -0,0 +1,36 @@ +package config + +import ( + "gohub/pkg/config" +) + +func init() { + + config.Add("database", func() map[string]interface{} { + return map[string]interface{}{ + + // 默认数据库 + "connection": config.Env("DB_CONNECTION", "mysql"), + + "mysql": map[string]interface{}{ + + // 数据库连接信息 + "host": config.Env("DB_HOST", "127.0.0.1"), + "port": config.Env("DB_PORT", "3306"), + "database": config.Env("DB_DATABASE", "gohub"), + "username": config.Env("DB_USERNAME", ""), + "password": config.Env("DB_PASSWORD", ""), + "charset": "utf8mb4", + + // 连接池配置 + "max_idle_connections": config.Env("DB_MAX_IDLE_CONNECTIONS", 100), + "max_open_connections": config.Env("DB_MAX_OPEN_CONNECTIONS", 25), + "max_life_seconds": config.Env("DB_MAX_LIFE_SECONDS", 5*60), + }, + + "sqlite": map[string]interface{}{ + "database": config.Env("DB_SQL_FILE", "database/database.db"), + }, + } + }) +} diff --git a/config/jwt.go b/config/jwt.go new file mode 100644 index 0000000..8210d4d --- /dev/null +++ b/config/jwt.go @@ -0,0 +1,22 @@ +package config + +import "gohub/pkg/config" + +func init() { + config.Add("jwt", func() map[string]interface{} { + return map[string]interface{}{ + + // 使用 config.GetString("app.key") + // "signing_key": + + // 过期时间,单位是分钟,一般不超过两个小时 + "expire_time": config.Env("JWT_EXPIRE_TIME", 120), + + // 允许刷新时间,单位分钟,86400 为两个月,从 Token 的签名时间算起 + "max_refresh_time": config.Env("JWT_MAX_REFRESH_TIME", 86400), + + // debug 模式下的过期时间,方便本地开发调试 + "debug_expire_time": 86400, + } + }) +} diff --git a/config/log.go b/config/log.go new file mode 100644 index 0000000..2951b9b --- /dev/null +++ b/config/log.go @@ -0,0 +1,36 @@ +package config + +import "gohub/pkg/config" + +func init() { + config.Add("log", func() map[string]interface{} { + return map[string]interface{}{ + + // 日志级别,必须是以下这些选项: + // "debug" —— 信息量大,一般调试时打开。系统模块详细运行的日志,例如 HTTP 请求、数据库请求、发送邮件、发送短信 + // "info" —— 业务级别的运行日志,如用户登录、用户退出、订单撤销。 + // "warn" —— 感兴趣、需要引起关注的信息。 例如,调试时候打印调试信息(命令行输出会有高亮)。 + // "error" —— 记录错误信息。Panic 或者 Error。如数据库连接错误、HTTP 端口被占用等。一般生产环境使用的等级。 + // 以上级别从低到高,level 值设置的级别越高,记录到日志的信息就越少 + // 开发时推荐使用 "debug" 或者 "info" ,生产环境下使用 "error" + "level": config.Env("LOG_LEVEL", "debug"), + + // 日志的类型,可选: + // "single" 独立的文件 + // "daily" 按照日期每日一个 + "type": config.Env("LOG_TYPE", "single"), + + /* ------------------ 滚动日志配置 ------------------ */ + // 日志文件路径 + "filename": config.Env("LOG_NAME", "storage/logs/logs.log"), + // 每个日志文件保存的最大尺寸 单位:M + "max_size": config.Env("LOG_MAX_SIZE", 64), + // 最多保存日志文件数,0 为不限,MaxAge 到了还是会删 + "max_backup": config.Env("LOG_MAX_BACKUP", 5), + // 最多保存多少天,7 表示一周前的日志会被删除,0 表示不删 + "max_age": config.Env("LOG_MAX_AGE", 30), + // 是否压缩,压缩日志不方便查看,我们设置为 false(压缩可节省空间) + "compress": config.Env("LOG_COMPRESS", false), + } + }) +} diff --git a/config/mail.go b/config/mail.go new file mode 100644 index 0000000..cc5a111 --- /dev/null +++ b/config/mail.go @@ -0,0 +1,24 @@ +// Package config 站点配置信息 +package config + +import "gohub/pkg/config" + +func init() { + config.Add("mail", func() map[string]interface{} { + return map[string]interface{}{ + + // 默认是 Mailhog 的配置 + "smtp": map[string]interface{}{ + "host": config.Env("MAIL_HOST", "localhost"), + "port": config.Env("MAIL_PORT", 1025), + "username": config.Env("MAIL_USERNAME", ""), + "password": config.Env("MAIL_PASSWORD", ""), + }, + + "from": map[string]interface{}{ + "address": config.Env("MAIL_FROM_ADDRESS", "gohub@example.com"), + "name": config.Env("MAIL_FROM_NAME", "Gohub"), + }, + } + }) +} diff --git a/config/paging.go b/config/paging.go new file mode 100644 index 0000000..0207237 --- /dev/null +++ b/config/paging.go @@ -0,0 +1,29 @@ +package config + +import "gohub/pkg/config" + +func init() { + config.Add("paging", func() map[string]interface{} { + return map[string]interface{}{ + + // 默认每页条数 + "perpage": 10, + + // URL 中用以分辨多少页的参数 + // 此值若修改需一并修改请求验证规则 + "url_query_page": "page", + + // URL 中用以分辨排序的参数(使用 id 或者其他) + // 此值若修改需一并修改请求验证规则 + "url_query_sort": "sort", + + // URL 中用以分辨排序规则的参数(辨别是正序还是倒序) + // 此值若修改需一并修改请求验证规则 + "url_query_order": "order", + + // URL 中用以分辨每页条数的参数 + // 此值若修改需一并修改请求验证规则 + "url_query_per_page": "per_page", + } + }) +} diff --git a/config/redis.go b/config/redis.go new file mode 100644 index 0000000..4a871d1 --- /dev/null +++ b/config/redis.go @@ -0,0 +1,23 @@ +package config + +import ( + "gohub/pkg/config" +) + +func init() { + + config.Add("redis", func() map[string]interface{} { + return map[string]interface{}{ + + "host": config.Env("REDIS_HOST", "127.0.0.1"), + "port": config.Env("REDIS_PORT", "6379"), + "password": config.Env("REDIS_PASSWORD", ""), + + // 业务类存储使用 1 (图片验证码、短信验证码、会话) + "database": config.Env("REDIS_MAIN_DB", 1), + + // 缓存 cache 包使用 0 ,缓存清空理应当不影响业务 + "database_cache": config.Env("REDIS_CACHE_DB", 0), + } + }) +} diff --git a/config/sms.go b/config/sms.go new file mode 100644 index 0000000..d48b5d2 --- /dev/null +++ b/config/sms.go @@ -0,0 +1,19 @@ +// Package config 站点配置信息 +package config + +import "gohub/pkg/config" + +func init() { + config.Add("sms", func() map[string]interface{} { + return map[string]interface{}{ + + // 默认是阿里云的测试 sign_name 和 template_code + "aliyun": map[string]interface{}{ + "access_key_id": config.Env("SMS_ALIYUN_ACCESS_ID"), + "access_key_secret": config.Env("SMS_ALIYUN_ACCESS_SECRET"), + "sign_name": config.Env("SMS_ALIYUN_SIGN_NAME", "阿里云短信测试"), + "template_code": config.Env("SMS_ALIYUN_TEMPLATE_CODE", "SMS_154950909"), + }, + } + }) +} diff --git a/config/verifycode.go b/config/verifycode.go new file mode 100644 index 0000000..7eb656a --- /dev/null +++ b/config/verifycode.go @@ -0,0 +1,25 @@ +package config + +import "gohub/pkg/config" + +func init() { + config.Add("verifycode", func() map[string]interface{} { + return map[string]interface{}{ + + // 验证码的长度 + "code_length": config.Env("VERIFY_CODE_LENGTH", 6), + + // 过期时间,单位是分钟 + "expire_time": config.Env("VERIFY_CODE_EXPIRE", 15), + + // debug 模式下的过期时间,方便本地开发调试 + "debug_expire_time": 10080, + // 本地开发环境验证码使用 debug_code + "debug_code": 123456, + + // 方便本地和 API 自动测试 + "debug_phone_prefix": "000", + "debug_email_suffix": "@testing.com", + } + }) +} diff --git a/database/factories/category_factory.go b/database/factories/category_factory.go new file mode 100644 index 0000000..71d9830 --- /dev/null +++ b/database/factories/category_factory.go @@ -0,0 +1,25 @@ +package factories + +import ( + "gohub/app/models/category" + + "github.com/bxcodec/faker/v3" +) + +func MakeCategories(count int) []category.Category { + + var objs []category.Category + + // 设置唯一性,如 Category 模型的某个字段需要唯一,即可取消注释 + faker.SetGenerateUniqueValues(true) + + for i := 0; i < count; i++ { + categoryModel := category.Category{ + Name: faker.Username(), + Description: faker.Sentence(), + } + objs = append(objs, categoryModel) + } + + return objs +} diff --git a/database/factories/link_factory.go b/database/factories/link_factory.go new file mode 100644 index 0000000..a8d4fd7 --- /dev/null +++ b/database/factories/link_factory.go @@ -0,0 +1,22 @@ +package factories + +import ( + "gohub/app/models/link" + + "github.com/bxcodec/faker/v3" +) + +func MakeLinks(times int) []link.Link { + + var objs []link.Link + + for i := 0; i < times; i++ { + model := link.Link{ + Name: faker.Username(), + URL: faker.URL(), + } + objs = append(objs, model) + } + + return objs +} diff --git a/database/factories/topic_factory.go b/database/factories/topic_factory.go new file mode 100644 index 0000000..5e8337c --- /dev/null +++ b/database/factories/topic_factory.go @@ -0,0 +1,24 @@ +package factories + +import ( + "gohub/app/models/topic" + + "github.com/bxcodec/faker/v3" +) + +func MakeTopics(count int) []topic.Topic { + + var objs []topic.Topic + + for i := 0; i < count; i++ { + topicModel := topic.Topic{ + Title: faker.Sentence(), + Body: faker.Paragraph(), + CategoryID: "3", + UserID: "1", + } + objs = append(objs, topicModel) + } + + return objs +} diff --git a/database/factories/user_factory.go b/database/factories/user_factory.go new file mode 100644 index 0000000..b5fc5f8 --- /dev/null +++ b/database/factories/user_factory.go @@ -0,0 +1,29 @@ +// Package factories 存放工厂方法 +package factories + +import ( + "gohub/app/models/user" + "gohub/pkg/helpers" + + "github.com/bxcodec/faker/v3" +) + +func MakeUsers(times int) []user.User { + + var objs []user.User + + // 设置唯一值 + faker.SetGenerateUniqueValues(true) + + for i := 0; i < times; i++ { + model := user.User{ + Name: faker.Username(), + Email: faker.Email(), + Phone: helpers.RandomNumber(11), + Password: "$2a$14$oPzVkIdwJ8KqY0erYAYQxOuAAlbI/sFIsH0C0R4MPc.3JbWWSuaUe", + } + objs = append(objs, model) + } + + return objs +} diff --git a/database/migrations/2022_01_11_212406_add_users_table.go b/database/migrations/2022_01_11_212406_add_users_table.go new file mode 100644 index 0000000..2822f80 --- /dev/null +++ b/database/migrations/2022_01_11_212406_add_users_table.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "database/sql" + "gohub/app/models" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type User struct { + models.BaseModel + + Name string `gorm:"type:varchar(255);not null;index"` + Email string `gorm:"type:varchar(255);index;default:null"` + Phone string `gorm:"type:varchar(20);index;default:null"` + Password string `gorm:"type:varchar(255)"` + + models.CommonTimestampsField + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&User{}) + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropTable(&User{}) + } + + migrate.Add("2022_01_11_212406_add_users_table", up, down) +} diff --git a/database/migrations/2022_01_12_203607_add_categories_table.go b/database/migrations/2022_01_12_203607_add_categories_table.go new file mode 100644 index 0000000..f4af83c --- /dev/null +++ b/database/migrations/2022_01_12_203607_add_categories_table.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "database/sql" + "gohub/app/models" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type Category struct { + models.BaseModel + + Name string `gorm:"type:varchar(255);not null;index"` + Description string `gorm:"type:varchar(255);default:null"` + + models.CommonTimestampsField + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&Category{}) + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropTable(&Category{}) + } + + migrate.Add("2022_01_12_203607_add_categories_table", up, down) +} diff --git a/database/migrations/2022_01_12_211213_add_topics_table.go b/database/migrations/2022_01_12_211213_add_topics_table.go new file mode 100644 index 0000000..3a6726e --- /dev/null +++ b/database/migrations/2022_01_12_211213_add_topics_table.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "database/sql" + "gohub/app/models" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type User struct { + models.BaseModel + } + type Category struct { + models.BaseModel + } + + type Topic struct { + models.BaseModel + + Title string `gorm:"type:varchar(255);not null;index"` + Body string `gorm:"type:longtext;not null"` + UserID string `gorm:"type:bigint;not null;index"` + CategoryID string `gorm:"type:bigint;not null;index"` + + // 会创建 user_id 和 category_id 外键的约束 + User User + Category Category + + models.CommonTimestampsField + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&Topic{}) + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropTable(&Topic{}) + } + + migrate.Add("2022_01_12_211213_add_topics_table", up, down) +} diff --git a/database/migrations/2022_01_12_214043_add_links_table.go b/database/migrations/2022_01_12_214043_add_links_table.go new file mode 100644 index 0000000..bb3d1c0 --- /dev/null +++ b/database/migrations/2022_01_12_214043_add_links_table.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "database/sql" + "gohub/app/models" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type Link struct { + models.BaseModel + + Name string `gorm:"type:varchar(255);not null"` + URL string `gorm:"type:varchar(255);default:null"` + + models.CommonTimestampsField + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&Link{}) + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropTable(&Link{}) + } + + migrate.Add("2022_01_12_214043_add_links_table", up, down) +} diff --git a/database/migrations/2022_01_12_220433_add_fields_to_user.go b/database/migrations/2022_01_12_220433_add_fields_to_user.go new file mode 100644 index 0000000..f6dc4aa --- /dev/null +++ b/database/migrations/2022_01_12_220433_add_fields_to_user.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + "gohub/pkg/migrate" + + "gorm.io/gorm" +) + +func init() { + + type User struct { + City string `gorm:"type:varchar(10);"` + Indtroduction string `gorm:"type:varchar(255);"` + Avatar string `gorm:"type:varchar(255);default:null"` + } + + up := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.AutoMigrate(&User{}) + + } + + down := func(migrator gorm.Migrator, DB *sql.DB) { + migrator.DropColumn(&User{}, "City") + migrator.DropColumn(&User{}, "Indtroduction") + migrator.DropColumn(&User{}, "Avatar") + } + + migrate.Add("2022_01_12_220433_add_fields_to_user", up, down) +} diff --git a/database/migrations/migrations.go b/database/migrations/migrations.go new file mode 100644 index 0000000..d34f1e0 --- /dev/null +++ b/database/migrations/migrations.go @@ -0,0 +1,6 @@ +// Package migrations 存放所有数据库迁移文件 +package migrations + +func Initialize() { + // 触发加载本目录下其他文件中的 init 方法 +} diff --git a/database/seeders/categories_seeder.go b/database/seeders/categories_seeder.go new file mode 100644 index 0000000..6e5b806 --- /dev/null +++ b/database/seeders/categories_seeder.go @@ -0,0 +1,28 @@ +package seeders + +import ( + "fmt" + "gohub/database/factories" + "gohub/pkg/console" + "gohub/pkg/logger" + "gohub/pkg/seed" + + "gorm.io/gorm" +) + +func init() { + + seed.Add("SeedCategoriesTable", func(db *gorm.DB) { + + categories := factories.MakeCategories(10) + + result := db.Table("categories").Create(&categories) + + if err := result.Error; err != nil { + logger.LogIf(err) + return + } + + console.Success(fmt.Sprintf("Table [%v] %v rows seeded", result.Statement.Table, result.RowsAffected)) + }) +} \ No newline at end of file diff --git a/database/seeders/links_seeder.go b/database/seeders/links_seeder.go new file mode 100644 index 0000000..4b05ffb --- /dev/null +++ b/database/seeders/links_seeder.go @@ -0,0 +1,28 @@ +package seeders + +import ( + "fmt" + "gohub/database/factories" + "gohub/pkg/console" + "gohub/pkg/logger" + "gohub/pkg/seed" + + "gorm.io/gorm" +) + +func init() { + + seed.Add("SeedLinksTable", func(db *gorm.DB) { + + links := factories.MakeLinks(5) + + result := db.Table("links").Create(&links) + + if err := result.Error; err != nil { + logger.LogIf(err) + return + } + + console.Success(fmt.Sprintf("Table [%v] %v rows seeded", result.Statement.Table, result.RowsAffected)) + }) +} diff --git a/database/seeders/seeder.go b/database/seeders/seeder.go new file mode 100644 index 0000000..094be95 --- /dev/null +++ b/database/seeders/seeder.go @@ -0,0 +1,14 @@ +// Package seeders 存放数据填充文件 +package seeders + +import "gohub/pkg/seed" + +func Initialize() { + + // 触发加载本目录下其他文件中的 init 方法 + + // 指定优先于同目录下的其他文件运行 + seed.SetRunOrder([]string{ + "SeedUsersTable", + }) +} diff --git a/database/seeders/topics_seeder.go b/database/seeders/topics_seeder.go new file mode 100644 index 0000000..de728ab --- /dev/null +++ b/database/seeders/topics_seeder.go @@ -0,0 +1,28 @@ +package seeders + +import ( + "fmt" + "gohub/database/factories" + "gohub/pkg/console" + "gohub/pkg/logger" + "gohub/pkg/seed" + + "gorm.io/gorm" +) + +func init() { + + seed.Add("SeedTopicsTable", func(db *gorm.DB) { + + topics := factories.MakeTopics(10) + + result := db.Table("topics").Create(&topics) + + if err := result.Error; err != nil { + logger.LogIf(err) + return + } + + console.Success(fmt.Sprintf("Table [%v] %v rows seeded", result.Statement.Table, result.RowsAffected)) + }) +} \ No newline at end of file diff --git a/database/seeders/users_seeder.go b/database/seeders/users_seeder.go new file mode 100644 index 0000000..bf216ef --- /dev/null +++ b/database/seeders/users_seeder.go @@ -0,0 +1,33 @@ +package seeders + +import ( + "fmt" + "gohub/database/factories" + "gohub/pkg/console" + "gohub/pkg/logger" + "gohub/pkg/seed" + + "gorm.io/gorm" +) + +func init() { + + // 添加 Seeder + seed.Add("SeedUsersTable", func(db *gorm.DB) { + + // 创建 10 个用户对象 + users := factories.MakeUsers(10) + + // 批量创建用户(注意批量创建不会调用模型钩子) + result := db.Table("users").Create(&users) + + // 记录错误 + if err := result.Error; err != nil { + logger.LogIf(err) + return + } + + // 打印运行情况 + console.Success(fmt.Sprintf("Table [%v] %v rows seeded", result.Statement.Table, result.RowsAffected)) + }) +} diff --git a/go.mod b/go.mod index 11a71f5..afb7a37 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,71 @@ module gohub go 1.17 + +require ( + github.com/KenmyZhang/aliyun-communicate v0.0.0-20180308134849-7997edc57454 + github.com/bxcodec/faker/v3 v3.7.0 + github.com/disintegration/imaging v1.6.2 + github.com/gertd/go-pluralize v0.1.7 + github.com/gin-gonic/gin v1.7.7 + github.com/go-redis/redis/v8 v8.11.4 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/iancoleman/strcase v0.2.0 + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d + github.com/mojocn/base64Captcha v1.3.5 + github.com/spf13/cast v1.4.1 + github.com/spf13/cobra v1.3.0 + github.com/spf13/viper v1.10.1 + github.com/thedevsaddam/govalidator v1.9.10 + github.com/ulule/limiter/v3 v3.9.0 + go.uber.org/zap v1.20.0 + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 + gopkg.in/natefinch/lumberjack.v2 v2.0.0 + gorm.io/driver/mysql v1.2.3 + gorm.io/driver/sqlite v1.2.6 + gorm.io/gorm v1.22.4 +) + +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.10.0 // indirect + github.com/go-sql-driver/mysql v1.6.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.1.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/magiconair/properties v1.8.5 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-sqlite3 v1.14.9 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pelletier/go-toml v1.9.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/afero v1.8.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.2.0 // indirect + github.com/ugorji/go/codec v1.2.6 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/text v0.3.7 // indirect + google.golang.org/protobuf v1.27.1 // indirect + gopkg.in/ini.v1 v1.66.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..001745f --- /dev/null +++ b/go.sum @@ -0,0 +1,949 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/KenmyZhang/aliyun-communicate v0.0.0-20180308134849-7997edc57454 h1:yLnifRMipbXZIZzj6rDst8cKfjkw4+WhEAeFVGemcGI= +github.com/KenmyZhang/aliyun-communicate v0.0.0-20180308134849-7997edc57454/go.mod h1:Cr6xeQTct8NJPbZcpxNQYAH2w185lxI3fxjPLM2N74w= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bxcodec/faker/v3 v3.7.0 h1:qWAFFwcyVS0ukF0UoJju1wBLO0cuPQ7JdVBPggM8kNo= +github.com/bxcodec/faker/v3 v3.7.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/gertd/go-pluralize v0.1.7 h1:RgvJTJ5W7olOoAks97BOwOlekBFsLEyh00W48Z6ZEZY= +github.com/gertd/go-pluralize v0.1.7/go.mod h1:O4eNeeIf91MHh1GJ2I47DNtaesm66NYvjYgAahcqSDQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +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.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= +github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= +github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= +github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= +github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= +github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= +github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +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/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +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.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.3 h1:PlHq1bSCSZL9K0wUhbm2pGLoTWs2GwVhsP6emvGV/ZI= +github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= +github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= +github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/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/mojocn/base64Captcha v1.3.5 h1:Qeilr7Ta6eDtG4S+tQuZ5+hO+QHbiGAJdi4PfoagaA0= +github.com/mojocn/base64Captcha v1.3.5/go.mod h1:/tTTXn4WTpX9CfrmipqRytCpJ27Uw3G6I7NcP2WwcmY= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= +github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60= +github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= +github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +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.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= +github.com/spf13/viper v1.10.1 h1:nuJZuYpG7gTj/XqiUwg8bA0cp1+M2mC3J4g5luUYBKk= +github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU= +github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go v1.2.6 h1:tGiWC9HENWE2tqYycIqFTNorMmFRVhNwCpDOpWqnk8E= +github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ= +github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw= +github.com/ulule/limiter/v3 v3.9.0 h1:ebASTkd6QNNUGhuDrWqImMpsg9GtItgNgxF3nKao58Q= +github.com/ulule/limiter/v3 v3.9.0/go.mod h1:icWc7rrF3T07dj59AhU4+HqKh0uWeh1wKct31wmcoF0= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.uber.org/zap v1.20.0 h1:N4oPlghZwYG55MlU6LXk/Zp00FVNE9X9wrYO8CEs4lc= +go.uber.org/zap v1.20.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/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-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/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-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI= +gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +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.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.2.3 h1:cZqzlOfg5Kf1VIdLC1D9hT6Cy9BgxhExLj/2tIgUe7Y= +gorm.io/driver/mysql v1.2.3/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo= +gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4= +gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY= +gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= +gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM= +gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/main.go b/main.go index 20f0d0f..1e554e7 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,69 @@ package main import ( "fmt" + "gohub/app/cmd" + "gohub/app/cmd/make" + "gohub/bootstrap" + btsConig "gohub/config" + "gohub/pkg/config" + "gohub/pkg/console" + "os" + + "github.com/spf13/cobra" ) +func init() { + // 加载 config 目录下的配置信息 + btsConig.Initialize() +} + func main() { - fmt.Println("Hello World!") + + // 应用的主入口,默认调用 cmd.CmdServe 命令 + var rootCmd = &cobra.Command{ + Use: config.Get("app.name"), + Short: "A simple forum project", + Long: `Default will run "serve" command, you can use "-h" flag to see all subcommands`, + + // rootCmd 的所有子命令都会执行以下代码 + PersistentPreRun: func(command *cobra.Command, args []string) { + + // 配置初始化,依赖命令行 --env 参数 + config.InitConfig(cmd.Env) + + // 初始化 Logger + bootstrap.SetupLogger() + + // 初始化数据库 + bootstrap.SetupDB() + + // 初始化 Redis + bootstrap.SetupRedis() + + // 初始化缓存 + bootstrap.SetupCache() + }, + } + + // 注册子命令 + rootCmd.AddCommand( + cmd.CmdServe, + cmd.CmdKey, + cmd.CmdPlay, + make.CmdMake, + cmd.CmdMigrate, + cmd.CmdDBSeed, + cmd.CmdCache, + ) + + // 配置默认运行 Web 服务 + cmd.RegisterDefaultCmd(rootCmd, cmd.CmdServe) + + // 注册全局参数,--env + cmd.RegisterGlobalFlags(rootCmd) + + // 执行主命令 + if err := rootCmd.Execute(); err != nil { + console.Exit(fmt.Sprintf("Failed to run app with %v: %s", os.Args, err.Error())) + } } diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..42befb0 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,35 @@ +// Package app 应用信息 +package app + +import ( + "gohub/pkg/config" + "time" +) + +func IsLocal() bool { + return config.Get("app.env") == "local" +} + +func IsProduction() bool { + return config.Get("app.env") == "production" +} + +func IsTesting() bool { + return config.Get("app.env") == "testing" +} + +// TimenowInTimezone 获取当前时间,支持时区 +func TimenowInTimezone() time.Time { + chinaTimezone, _ := time.LoadLocation(config.GetString("app.timezone")) + return time.Now().In(chinaTimezone) +} + +// URL 传参 path 拼接站点的 URL +func URL(path string) string { + return config.Get("app.url") + path +} + +// V1URL 拼接带 v1 标示 URL +func V1URL(path string) string { + return URL("/v1/" + path) +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 0000000..c750c66 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,50 @@ +// Package auth 授权相关逻辑 +package auth + +import ( + "errors" + "gohub/app/models/user" + "gohub/pkg/logger" + + "github.com/gin-gonic/gin" +) + +// Attempt 尝试登录 +func Attempt(email string, password string) (user.User, error) { + userModel := user.GetByMulti(email) + if userModel.ID == 0 { + return user.User{}, errors.New("账号不存在") + } + + if !userModel.ComparePassword(password) { + return user.User{}, errors.New("密码错误") + } + + return userModel, nil +} + +// LoginByPhone 登录指定用户 +func LoginByPhone(phone string) (user.User, error) { + userModel := user.GetByPhone(phone) + if userModel.ID == 0 { + return user.User{}, errors.New("手机号未注册") + } + + return userModel, nil +} + +// CurrentUser 从 gin.context 中获取当前登录用户 +func CurrentUser(c *gin.Context) user.User { + userModel, ok := c.MustGet("current_user").(user.User) + if !ok { + logger.LogIf(errors.New("无法获取用户")) + return user.User{} + } + // db is now a *DB value + return userModel +} + +// CurrentUID 从 gin.context 中获取当前登录用户 ID +func CurrentUID(c *gin.Context) string { + return c.GetString("current_user_id") +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..681a809 --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,143 @@ +// Package cache 缓存工具类,可以缓存各种类型包括 struct 对象 +package cache + +import ( + "encoding/json" + "gohub/pkg/logger" + "sync" + "time" + + "github.com/spf13/cast" +) + +type CacheService struct { + Store Store +} + +var once sync.Once +var Cache *CacheService + +func InitWithCacheStore(store Store) { + once.Do(func() { + Cache = &CacheService{ + Store: store, + } + }) +} + +func Set(key string, obj interface{}, expireTime time.Duration) { + b, err := json.Marshal(&obj) + logger.LogIf(err) + Cache.Store.Set(key, string(b), expireTime) +} + +func Get(key string) interface{} { + stringValue := Cache.Store.Get(key) + var wanted interface{} + err := json.Unmarshal([]byte(stringValue), &wanted) + logger.LogIf(err) + return wanted +} + +func Has(key string) bool { + return Cache.Store.Has(key) +} + +// GetObject 应该传地址,用法如下: +// model := user.User{} +// cache.GetObject("key", &model) +func GetObject(key string, wanted interface{}) { + val := Cache.Store.Get(key) + if len(val) > 0 { + err := json.Unmarshal([]byte(val), &wanted) + logger.LogIf(err) + } +} + +func GetString(key string) string { + return cast.ToString(Get(key)) +} + +func GetBool(key string) bool { + return cast.ToBool(Get(key)) +} + +func GetInt(key string) int { + return cast.ToInt(Get(key)) +} + +func GetInt32(key string) int32 { + return cast.ToInt32(Get(key)) +} + +func GetInt64(key string) int64 { + return cast.ToInt64(Get(key)) +} + +func GetUint(key string) uint { + return cast.ToUint(Get(key)) +} + +func GetUint32(key string) uint32 { + return cast.ToUint32(Get(key)) +} + +func GetUint64(key string) uint64 { + return cast.ToUint64(Get(key)) +} + +func GetFloat64(key string) float64 { + return cast.ToFloat64(Get(key)) +} + +func GetTime(key string) time.Time { + return cast.ToTime(Get(key)) +} + +func GetDuration(key string) time.Duration { + return cast.ToDuration(Get(key)) +} + +func GetIntSlice(key string) []int { + return cast.ToIntSlice(Get(key)) +} + +func GetStringSlice(key string) []string { + return cast.ToStringSlice(Get(key)) +} + +func GetStringMap(key string) map[string]interface{} { + return cast.ToStringMap(Get(key)) +} + +func GetStringMapString(key string) map[string]string { + return cast.ToStringMapString(Get(key)) +} + +func GetStringMapStringSlice(key string) map[string][]string { + return cast.ToStringMapStringSlice(Get(key)) +} + +func Forget(key string) { + Cache.Store.Forget((key)) +} + +func Forever(key string, value string) { + Cache.Store.Set(key, value, 0) +} + +func Flush() { + Cache.Store.Flush() +} + +func Increment(parameters ...interface{}) { + Cache.Store.Increment(parameters...) +} + +func Decrement(parameters ...interface{}) { + Cache.Store.Decrement(parameters...) +} + +func IsAlive() error { + return Cache.Store.IsAlive() +} diff --git a/pkg/cache/store_interface.go b/pkg/cache/store_interface.go new file mode 100644 index 0000000..6c38d4d --- /dev/null +++ b/pkg/cache/store_interface.go @@ -0,0 +1,22 @@ +package cache + +import "time" + +type Store interface { + Set(key string, value string, expireTime time.Duration) + Get(key string) string + Has(key string) bool + Forget(key string) + Forever(key string, value string) + Flush() + + IsAlive() error + + // Increment 当参数只有 1 个时,为 key,增加 1。 + // 当参数有 2 个时,第一个参数为 key ,第二个参数为要增加的值 int64 类型。 + Increment(parameters ...interface{}) + + // Decrement 当参数只有 1 个时,为 key,减去 1。 + // 当参数有 2 个时,第一个参数为 key ,第二个参数为要减去的值 int64 类型。 + Decrement(parameters ...interface{}) +} diff --git a/pkg/cache/store_redis.go b/pkg/cache/store_redis.go new file mode 100644 index 0000000..a74c6cf --- /dev/null +++ b/pkg/cache/store_redis.go @@ -0,0 +1,56 @@ +package cache + +import ( + "gohub/pkg/config" + "gohub/pkg/redis" + "time" +) + +// RedisStore 实现 cache.Store interface +type RedisStore struct { + RedisClient *redis.RedisClient + KeyPrefix string +} + +func NewRedisStore(address string, username string, password string, db int) *RedisStore { + rs := &RedisStore{} + rs.RedisClient = redis.NewClient(address, username, password, db) + rs.KeyPrefix = config.GetString("app.name") + ":cache:" + return rs +} + +func (s *RedisStore) Set(key string, value string, expireTime time.Duration) { + s.RedisClient.Set(s.KeyPrefix+key, value, expireTime) +} + +func (s *RedisStore) Get(key string) string { + return s.RedisClient.Get(s.KeyPrefix + key) +} + +func (s *RedisStore) Has(key string) bool { + return s.RedisClient.Has(s.KeyPrefix + key) +} + +func (s *RedisStore) Forget(key string) { + s.RedisClient.Del((s.KeyPrefix + key)) +} + +func (s *RedisStore) Forever(key string, value string) { + s.RedisClient.Set(s.KeyPrefix+key, value, 0) +} + +func (s *RedisStore) Flush() { + s.RedisClient.FlushDB() +} + +func (s *RedisStore) Increment(parameters ...interface{}) { + s.RedisClient.Increment(parameters...) +} + +func (s *RedisStore) Decrement(parameters ...interface{}) { + s.RedisClient.Decrement(parameters...) +} + +func (s *RedisStore) IsAlive() error { + return s.RedisClient.Ping() +} diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go new file mode 100644 index 0000000..17491d3 --- /dev/null +++ b/pkg/captcha/captcha.go @@ -0,0 +1,66 @@ +// Package captcha 处理图片验证码逻辑 +package captcha + +import ( + "gohub/pkg/app" + "gohub/pkg/config" + "gohub/pkg/redis" + "sync" + + "github.com/mojocn/base64Captcha" +) + +type Captcha struct { + Base64Captcha *base64Captcha.Captcha +} + +// once 确保 internalCaptcha 对象只初始化一次 +var once sync.Once + +// internalCaptcha 内部使用的 Captcha 对象 +var internalCaptcha *Captcha + +// NewCaptcha 单例模式获取 +func NewCaptcha() *Captcha { + once.Do(func() { + // 初始化 Captcha 对象 + internalCaptcha = &Captcha{} + + // 使用全局 Redis 对象,并配置存储 Key 的前缀 + store := RedisStore{ + RedisClient: redis.Redis, + KeyPrefix: config.GetString("app.name") + ":captcha:", + } + + // 配置 base64Captcha 驱动信息 + driver := base64Captcha.NewDriverDigit( + config.GetInt("captcha.height"), // 宽 + config.GetInt("captcha.width"), // 高 + config.GetInt("captcha.length"), // 长度 + config.GetFloat64("captcha.maxskew"), // 数字的最大倾斜角度 + config.GetInt("captcha.dotcount"), // 图片背景里的混淆点数量 + ) + + // 实例化 base64Captcha 并赋值给内部使用的 internalCaptcha 对象 + internalCaptcha.Base64Captcha = base64Captcha.NewCaptcha(driver, &store) + }) + + return internalCaptcha +} + +// GenerateCaptcha 生成图片验证码 +func (c *Captcha) GenerateCaptcha() (id string, b64s string, err error) { + return c.Base64Captcha.Generate() +} + +// VerifyCaptcha 验证验证码是否正确 +func (c *Captcha) VerifyCaptcha(id string, answer string) (match bool) { + + // 方便本地和 API 自动测试 + if !app.IsProduction() && id == config.GetString("captcha.testing_key") { + return true + } + // 第三个参数是验证后是否删除,我们选择 false + // 这样方便用户多次提交,防止表单提交错误需要多次输入图片验证码 + return c.Base64Captcha.Store.Verify(id, answer, false) +} diff --git a/pkg/captcha/store_redis.go b/pkg/captcha/store_redis.go new file mode 100644 index 0000000..f103257 --- /dev/null +++ b/pkg/captcha/store_redis.go @@ -0,0 +1,46 @@ +package captcha + +import ( + "errors" + "gohub/pkg/app" + "gohub/pkg/config" + "gohub/pkg/redis" + "time" +) + +// RedisStore 实现 base64Captcha.Store interface +type RedisStore struct { + RedisClient *redis.RedisClient + KeyPrefix string +} + +// Set 实现 base64Captcha.Store interface 的 Set 方法 +func (s *RedisStore) Set(key string, value string) error { + + ExpireTime := time.Minute * time.Duration(config.GetInt64("captcha.expire_time")) + // 方便本地开发调试 + if app.IsLocal() { + ExpireTime = time.Minute * time.Duration(config.GetInt64("captcha.debug_expire_time")) + } + + if ok := s.RedisClient.Set(s.KeyPrefix+key, value, ExpireTime); !ok { + return errors.New("无法存储图片验证码答案") + } + return nil +} + +// Get 实现 base64Captcha.Store interface 的 Get 方法 +func (s *RedisStore) Get(key string, clear bool) (value string) { + key = s.KeyPrefix + key + val := s.RedisClient.Get(key) + if clear { + s.RedisClient.Del(key) + } + return val +} + +// Verify 实现 base64Captcha.Store interface 的 Verify 方法 +func (s *RedisStore) Verify(key, answer string, clear bool) bool { + v := s.Get(key, clear) + return v == answer +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..588ce26 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,138 @@ +// Package config 负责配置信息 +package config + +import ( + "gohub/pkg/helpers" + "os" + + "github.com/spf13/cast" + viperlib "github.com/spf13/viper" // 自定义包名,避免与内置 viper 实例冲突 +) + +// viper 库实例 +var viper *viperlib.Viper + +// ConfigFunc 动态加载配置信息 +type ConfigFunc func() map[string]interface{} + +// ConfigFuncs 先加载到此数组,loadConfig 在动态生成配置信息 +var ConfigFuncs map[string]ConfigFunc + +func init() { + + // 1. 初始化 Viper 库 + viper = viperlib.New() + // 2. 配置类型,支持 "json", "toml", "yaml", "yml", "properties", + // "props", "prop", "env", "dotenv" + viper.SetConfigType("env") + // 3. 环境变量配置文件查找的路径,相对于 main.go + viper.AddConfigPath(".") + // 4. 设置环境变量前缀,用以区分 Go 的系统环境变量 + viper.SetEnvPrefix("appenv") + // 5. 读取环境变量(支持 flags) + viper.AutomaticEnv() + + ConfigFuncs = make(map[string]ConfigFunc) +} + +// InitConfig 初始化配置信息,完成对环境变量以及 config 信息的加载 +func InitConfig(env string) { + // 1. 加载环境变量 + loadEnv(env) + // 2. 注册配置信息 + loadConfig() +} + +func loadConfig() { + for name, fn := range ConfigFuncs { + viper.Set(name, fn()) + } +} + +func loadEnv(envSuffix string) { + + // 默认加载 .env 文件,如果有传参 --env=name 的话,加载 .env.name 文件 + envPath := ".env" + if len(envSuffix) > 0 { + filepath := ".env." + envSuffix + if _, err := os.Stat(filepath); err == nil { + // 如 .env.testing 或 .env.stage + envPath = filepath + } + } + + // 加载 env + viper.SetConfigName(envPath) + if err := viper.ReadInConfig(); err != nil { + panic(err) + } + + // 监控 .env 文件,变更时重新加载 + viper.WatchConfig() +} + +// Env 读取环境变量,支持默认值 +func Env(envName string, defaultValue ...interface{}) interface{} { + if len(defaultValue) > 0 { + return internalGet(envName, defaultValue[0]) + } + return internalGet(envName) +} + +// Add 新增配置项 +func Add(name string, configFn ConfigFunc) { + ConfigFuncs[name] = configFn +} + +// Get 获取配置项 +// 第一个参数 path 允许使用点式获取,如:app.name +// 第二个参数允许传参默认值 +func Get(path string, defaultValue ...interface{}) string { + return GetString(path, defaultValue...) +} + +func internalGet(path string, defaultValue ...interface{}) interface{} { + // config 或者环境变量不存在的情况 + if !viper.IsSet(path) || helpers.Empty(viper.Get(path)) { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return nil + } + return viper.Get(path) +} + +// GetString 获取 String 类型的配置信息 +func GetString(path string, defaultValue ...interface{}) string { + return cast.ToString(internalGet(path, defaultValue...)) +} + +// GetInt 获取 Int 类型的配置信息 +func GetInt(path string, defaultValue ...interface{}) int { + return cast.ToInt(internalGet(path, defaultValue...)) +} + +// GetFloat64 获取 float64 类型的配置信息 +func GetFloat64(path string, defaultValue ...interface{}) float64 { + return cast.ToFloat64(internalGet(path, defaultValue...)) +} + +// GetInt64 获取 Int64 类型的配置信息 +func GetInt64(path string, defaultValue ...interface{}) int64 { + return cast.ToInt64(internalGet(path, defaultValue...)) +} + +// GetUint 获取 Uint 类型的配置信息 +func GetUint(path string, defaultValue ...interface{}) uint { + return cast.ToUint(internalGet(path, defaultValue...)) +} + +// GetBool 获取 Bool 类型的配置信息 +func GetBool(path string, defaultValue ...interface{}) bool { + return cast.ToBool(internalGet(path, defaultValue...)) +} + +// GetStringMapString 获取结构数据 +func GetStringMapString(path string) map[string]string { + return viper.GetStringMapString(path) +} diff --git a/pkg/console/console.go b/pkg/console/console.go new file mode 100644 index 0000000..19e8ae8 --- /dev/null +++ b/pkg/console/console.go @@ -0,0 +1,42 @@ +// Package console 命令行辅助方法 +package console + +import ( + "fmt" + "os" + + "github.com/mgutz/ansi" +) + +// Success 打印一条成功消息,绿色输出 +func Success(msg string) { + colorOut(msg, "green") +} + +// Error 打印一条报错消息,红色输出 +func Error(msg string) { + colorOut(msg, "red") +} + +// Warning 打印一条提示消息,黄色输出 +func Warning(msg string) { + colorOut(msg, "yellow") +} + +// Exit 打印一条报错消息,并退出 os.Exit(1) +func Exit(msg string) { + Error(msg) + os.Exit(1) +} + +// ExitIf 语法糖,自带 err != nil 判断 +func ExitIf(err error) { + if err != nil { + Exit(err.Error()) + } +} + +// colorOut 内部使用,设置高亮颜色 +func colorOut(message, color string) { + fmt.Fprintln(os.Stdout, ansi.Color(message, color)) +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..7a29af3 --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,88 @@ +// Package database 数据库操作 +package database + +import ( + "database/sql" + "errors" + "fmt" + "gohub/pkg/config" + + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +// DB 对象 +var DB *gorm.DB +var SQLDB *sql.DB + +// Connect 连接数据库 +func Connect(dbConfig gorm.Dialector, _logger gormlogger.Interface) { + + // 使用 gorm.Open 连接数据库 + var err error + DB, err = gorm.Open(dbConfig, &gorm.Config{ + Logger: _logger, + }) + // 处理错误 + if err != nil { + fmt.Println(err.Error()) + } + + // 获取底层的 sqlDB + SQLDB, err = DB.DB() + if err != nil { + fmt.Println(err.Error()) + } +} +func CurrentDatabase() (dbname string) { + dbname = DB.Migrator().CurrentDatabase() + return +} + +func DeleteAllTables() error { + + var err error + + switch config.Get("database.connection") { + case "mysql": + err = deleteMysqlDatabase() + case "sqlite": + deleteAllSqliteTables() + default: + panic(errors.New("database connection not supported")) + } + + return err +} + +func deleteAllSqliteTables() error { + tables := []string{} + DB.Select(&tables, "SELECT name FROM sqlite_master WHERE type='table'") + for _, table := range tables { + DB.Migrator().DropTable(table) + } + return nil +} + +func deleteMysqlDatabase() error { + dbname := CurrentDatabase() + sql := fmt.Sprintf("DROP DATABASE %s;", dbname) + if err := DB.Exec(sql).Error; err != nil { + return err + } + sql = fmt.Sprintf("CREATE DATABASE %s;", dbname) + if err := DB.Exec(sql).Error; err != nil { + return err + } + sql = fmt.Sprintf("USE %s;", dbname) + if err := DB.Exec(sql).Error; err != nil { + return err + } + return nil +} + +func TableName(obj interface{}) string { + stmt := &gorm.Statement{DB: DB} + stmt.Parse(obj) + return stmt.Schema.Table +} diff --git a/pkg/file/file.go b/pkg/file/file.go new file mode 100644 index 0000000..2e1ce69 --- /dev/null +++ b/pkg/file/file.go @@ -0,0 +1,80 @@ +// Package file 文件操作辅助函数 +package file + +import ( + "fmt" + "gohub/pkg/app" + "gohub/pkg/auth" + "gohub/pkg/helpers" + "io/ioutil" + "mime/multipart" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "github.com/gin-gonic/gin" +) + +// Put 将数据存入文件 +func Put(data []byte, to string) error { + err := ioutil.WriteFile(to, data, 0644) + if err != nil { + return err + } + return nil +} + +// Exists 判断文件是否存在 +func Exists(fileToCheck string) bool { + if _, err := os.Stat(fileToCheck); os.IsNotExist(err) { + return false + } + return true +} + +func FileNameWithoutExtension(fileName string) string { + return strings.TrimSuffix(fileName, filepath.Ext(fileName)) +} + +func SaveUploadAvatar(c *gin.Context, file *multipart.FileHeader) (string, error) { + + var avatar string + // 确保目录存在,不存在创建 + publicPath := "public" + dirName := fmt.Sprintf("/uploads/avatars/%s/%s/", app.TimenowInTimezone().Format("2006/01/02"), auth.CurrentUID(c)) + os.MkdirAll(publicPath+dirName, 0755) + + // 保存文件 + fileName := randomNameFromUploadFile(file) + // public/uploads/avatars/2021/12/22/1/nFDacgaWKpWWOmOt.png + avatarPath := publicPath + dirName + fileName + if err := c.SaveUploadedFile(file, avatarPath); err != nil { + return avatar, err + } + + // 裁切图片 + img, err := imaging.Open(avatarPath, imaging.AutoOrientation(true)) + if err != nil { + return avatar, err + } + resizeAvatar := imaging.Thumbnail(img, 256, 256, imaging.Lanczos) + resizeAvatarName := randomNameFromUploadFile(file) + resizeAvatarPath := publicPath + dirName + resizeAvatarName + err = imaging.Save(resizeAvatar, resizeAvatarPath) + if err != nil { + return avatar, err + } + + // 删除老文件 + err = os.Remove(avatarPath) + if err != nil { + return avatar, err + } + + return dirName + resizeAvatarName, nil +} + +func randomNameFromUploadFile(file *multipart.FileHeader) string { + return helpers.RandomString(16) + filepath.Ext(file.Filename) +} diff --git a/pkg/hash/hash.go b/pkg/hash/hash.go new file mode 100644 index 0000000..d3cce91 --- /dev/null +++ b/pkg/hash/hash.go @@ -0,0 +1,29 @@ +// Package hash 哈希操作类 +package hash + +import ( + "gohub/pkg/logger" + + "golang.org/x/crypto/bcrypt" +) + +// BcryptHash 使用 bcrypt 对密码进行加密 +func BcryptHash(password string) string { + // GenerateFromPassword 的第二个参数是 cost 值。建议大于 12,数值越大耗费时间越长 + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + logger.LogIf(err) + + return string(bytes) +} + +// BcryptCheck 对比明文密码和数据库的哈希值 +func BcryptCheck(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// BcryptIsHashed 判断字符串是否是哈希过的数据 +func BcryptIsHashed(str string) bool { + // bcrypt 加密后的长度等于 60 + return len(str) == 60 +} diff --git a/pkg/helpers/helpers.go b/pkg/helpers/helpers.go new file mode 100644 index 0000000..4f4724a --- /dev/null +++ b/pkg/helpers/helpers.go @@ -0,0 +1,75 @@ +// Package helpers 存放辅助方法 +package helpers + +import ( + "crypto/rand" + "fmt" + "io" + mathrand "math/rand" + "reflect" + "time" +) + +// Empty 类似于 PHP 的 empty() 函数 +func Empty(val interface{}) bool { + if val == nil { + return true + } + v := reflect.ValueOf(val) + switch v.Kind() { + case reflect.String, reflect.Array: + return v.Len() == 0 + case reflect.Map, reflect.Slice: + return v.Len() == 0 || v.IsNil() + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + } + return reflect.DeepEqual(val, reflect.Zero(v.Type()).Interface()) +} + +// MicrosecondsStr 将 time.Duration 类型(nano seconds 为单位) +// 输出为小数点后 3 位的 ms (microsecond 毫秒,千分之一秒) +func MicrosecondsStr(elapsed time.Duration) string { + return fmt.Sprintf("%.3fms", float64(elapsed.Nanoseconds())/1e6) +} + +// RandomNumber 生成长度为 length 随机数字字符串 +func RandomNumber(length int) string { + table := [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} + b := make([]byte, length) + n, err := io.ReadAtLeast(rand.Reader, b, length) + if n != length { + panic(err) + } + for i := 0; i < len(b); i++ { + b[i] = table[int(b[i])%len(table)] + } + return string(b) +} + +// FirstElement 安全地获取 args[0],避免 panic: runtime error: index out of range +func FirstElement(args []string) string { + if len(args) > 0 { + return args[0] + } + return "" +} + +// RandomString 生成长度为 length 的随机字符串 +func RandomString(length int) string { + mathrand.Seed(time.Now().UnixNano()) + letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + b := make([]byte, length) + for i := range b { + b[i] = letters[mathrand.Intn(len(letters))] + } + return string(b) +} diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..8020101 --- /dev/null +++ b/pkg/jwt/jwt.go @@ -0,0 +1,196 @@ +// Package jwt 处理 JWT 认证 +package jwt + +import ( + "errors" + "gohub/pkg/app" + "gohub/pkg/config" + "gohub/pkg/logger" + "strings" + "time" + + "github.com/gin-gonic/gin" + jwtpkg "github.com/golang-jwt/jwt" +) + +var ( + ErrTokenExpired error = errors.New("令牌已过期") + ErrTokenExpiredMaxRefresh error = errors.New("令牌已过最大刷新时间") + ErrTokenMalformed error = errors.New("请求令牌格式有误") + ErrTokenInvalid error = errors.New("请求令牌无效") + ErrHeaderEmpty error = errors.New("需要认证才能访问!") + ErrHeaderMalformed error = errors.New("请求头中 Authorization 格式有误") +) + +// JWT 定义一个jwt对象 +type JWT struct { + + // 秘钥,用以加密 JWT,读取配置信息 app.key + SignKey []byte + + // 刷新 Token 的最大过期时间 + MaxRefresh time.Duration +} + +// JWTCustomClaims 自定义载荷 +type JWTCustomClaims struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + ExpireAtTime int64 `json:"expire_time"` + + // StandardClaims 结构体实现了 Claims 接口继承了 Valid() 方法 + // JWT 规定了7个官方字段,提供使用: + // - iss (issuer):发布者 + // - sub (subject):主题 + // - iat (Issued At):生成签名的时间 + // - exp (expiration time):签名过期时间 + // - aud (audience):观众,相当于接受者 + // - nbf (Not Before):生效时间 + // - jti (JWT ID):编号 + jwtpkg.StandardClaims +} + +func NewJWT() *JWT { + return &JWT{ + SignKey: []byte(config.GetString("app.key")), + MaxRefresh: time.Duration(config.GetInt64("jwt.max_refresh_time")) * time.Minute, + } +} + +// ParserToken 解析 Token,中间件中调用 +func (jwt *JWT) ParserToken(c *gin.Context) (*JWTCustomClaims, error) { + + tokenString, parseErr := jwt.getTokenFromHeader(c) + if parseErr != nil { + return nil, parseErr + } + + // 1. 调用 jwt 库解析用户传参的 Token + token, err := jwt.parseTokenString(tokenString) + + // 2. 解析出错 + if err != nil { + validationErr, ok := err.(*jwtpkg.ValidationError) + if ok { + if validationErr.Errors == jwtpkg.ValidationErrorMalformed { + return nil, ErrTokenMalformed + } else if validationErr.Errors == jwtpkg.ValidationErrorExpired { + return nil, ErrTokenExpired + } + } + return nil, ErrTokenInvalid + } + + // 3. 将 token 中的 claims 信息解析出来和 JWTCustomClaims 数据结构进行校验 + if claims, ok := token.Claims.(*JWTCustomClaims); ok && token.Valid { + return claims, nil + } + + return nil, ErrTokenInvalid +} + +// RefreshToken 更新 Token,用以提供 refresh token 接口 +func (jwt *JWT) RefreshToken(c *gin.Context) (string, error) { + + // 1. 从 Header 里获取 token + tokenString, parseErr := jwt.getTokenFromHeader(c) + if parseErr != nil { + return "", parseErr + } + + // 2. 调用 jwt 库解析用户传参的 Token + token, err := jwt.parseTokenString(tokenString) + + // 3. 解析出错,未报错证明是合法的 Token(甚至未到过期时间) + if err != nil { + validationErr, ok := err.(*jwtpkg.ValidationError) + // 满足 refresh 的条件:只是单一的报错 ValidationErrorExpired + if !ok || validationErr.Errors != jwtpkg.ValidationErrorExpired { + return "", err + } + } + + // 4. 解析 JWTCustomClaims 的数据 + claims := token.Claims.(*JWTCustomClaims) + + // 5. 检查是否过了『最大允许刷新的时间』 + x := app.TimenowInTimezone().Add(-jwt.MaxRefresh).Unix() + if claims.IssuedAt > x { + // 修改过期时间 + claims.StandardClaims.ExpiresAt = jwt.expireAtTime() + return jwt.createToken(*claims) + } + + return "", ErrTokenExpiredMaxRefresh +} + +// IssueToken 生成 Token,在登录成功时调用 +func (jwt *JWT) IssueToken(userID string, userName string) string { + + // 1. 构造用户 claims 信息(负荷) + expireAtTime := jwt.expireAtTime() + claims := JWTCustomClaims{ + userID, + userName, + expireAtTime, + jwtpkg.StandardClaims{ + NotBefore: app.TimenowInTimezone().Unix(), // 签名生效时间 + IssuedAt: app.TimenowInTimezone().Unix(), // 首次签名时间(后续刷新 Token 不会更新) + ExpiresAt: expireAtTime, // 签名过期时间 + Issuer: config.GetString("app.name"), // 签名颁发者 + }, + } + + // 2. 根据 claims 生成token对象 + token, err := jwt.createToken(claims) + if err != nil { + logger.LogIf(err) + return "" + } + + return token +} + +// createToken 创建 Token,内部使用,外部请调用 IssueToken +func (jwt *JWT) createToken(claims JWTCustomClaims) (string, error) { + // 使用HS256算法进行token生成 + token := jwtpkg.NewWithClaims(jwtpkg.SigningMethodHS256, claims) + return token.SignedString(jwt.SignKey) +} + +// expireAtTime 过期时间 +func (jwt *JWT) expireAtTime() int64 { + timenow := app.TimenowInTimezone() + + var expireTime int64 + if config.GetBool("app.debug") { + expireTime = config.GetInt64("jwt.debug_expire_time") + } else { + expireTime = config.GetInt64("jwt.expire_time") + } + + expire := time.Duration(expireTime) * time.Minute + return timenow.Add(expire).Unix() +} + +// parseTokenString 使用 jwtpkg.ParseWithClaims 解析 Token +func (jwt *JWT) parseTokenString(tokenString string) (*jwtpkg.Token, error) { + return jwtpkg.ParseWithClaims(tokenString, &JWTCustomClaims{}, func(token *jwtpkg.Token) (interface{}, error) { + return jwt.SignKey, nil + }) +} + +// getTokenFromHeader 使用 jwtpkg.ParseWithClaims 解析 Token +// Authorization:Bearer xxxxx +func (jwt *JWT) getTokenFromHeader(c *gin.Context) (string, error) { + authHeader := c.Request.Header.Get("Authorization") + if authHeader == "" { + return "", ErrHeaderEmpty + } + // 按空格分割 + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + return "", ErrHeaderMalformed + } + return parts[1], nil +} diff --git a/pkg/limiter/limiter.go b/pkg/limiter/limiter.go new file mode 100644 index 0000000..3f9e4ab --- /dev/null +++ b/pkg/limiter/limiter.go @@ -0,0 +1,58 @@ +// Package limiter 处理限流逻辑 +package limiter + +import ( + "gohub/pkg/config" + "gohub/pkg/logger" + "gohub/pkg/redis" + "strings" + + "github.com/gin-gonic/gin" + limiterlib "github.com/ulule/limiter/v3" + sredis "github.com/ulule/limiter/v3/drivers/store/redis" +) + +// GetKeyIP 获取 Limitor 的 Key,IP +func GetKeyIP(c *gin.Context) string { + return c.ClientIP() +} + +// GetKeyRouteWithIP Limitor 的 Key,路由+IP,针对单个路由做限流 +func GetKeyRouteWithIP(c *gin.Context) string { + return routeToKeyString(c.FullPath()) + c.ClientIP() +} + +// CheckRate 检测请求是否超额 +func CheckRate(c *gin.Context, key string, formatted string) (limiterlib.Context, error) { + + // 实例化依赖的 limiter 包的 limiter.Rate 对象 + var context limiterlib.Context + rate, err := limiterlib.NewRateFromFormatted(formatted) + if err != nil { + logger.LogIf(err) + return context, err + } + + // 初始化存储,使用我们程序里共用的 redis.Redis 对象 + store, err := sredis.NewStoreWithOptions(redis.Redis.Client, limiterlib.StoreOptions{ + // 为 limiter 设置前缀,保持 redis 里数据的整洁 + Prefix: config.GetString("app.name") + ":limiter", + }) + if err != nil { + logger.LogIf(err) + return context, err + } + + // 使用上面的初始化的 limiter.Rate 对象和存储对象 + limiterObj := limiterlib.New(store, rate) + + // 获取限流的结果 + return limiterObj.Get(c, key) +} + +// routeToKeyString 辅助方法,将 URL 中的 / 格式为 - +func routeToKeyString(routeName string) string { + routeName = strings.ReplaceAll(routeName, "/", "-") + routeName = strings.ReplaceAll(routeName, ":", "_") + return routeName +} diff --git a/pkg/logger/gorm_logger.go b/pkg/logger/gorm_logger.go new file mode 100644 index 0000000..fdca7fb --- /dev/null +++ b/pkg/logger/gorm_logger.go @@ -0,0 +1,118 @@ +package logger + +import ( + "context" + "errors" + "gohub/pkg/helpers" + "path/filepath" + "runtime" + "strings" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +// GormLogger 操作对象,实现 gormlogger.Interface +type GormLogger struct { + ZapLogger *zap.Logger + SlowThreshold time.Duration +} + +// NewGormLogger 外部调用。实例化一个 GormLogger 对象,示例: +// DB, err := gorm.Open(dbConfig, &gorm.Config{ +// Logger: logger.NewGormLogger(), +// }) +func NewGormLogger() GormLogger { + return GormLogger{ + ZapLogger: Logger, // 使用全局的 logger.Logger 对象 + SlowThreshold: 200 * time.Millisecond, // 慢查询阈值,单位为千分之一秒 + } +} + +// LogMode 实现 gormlogger.Interface 的 LogMode 方法 +func (l GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + return GormLogger{ + ZapLogger: l.ZapLogger, + SlowThreshold: l.SlowThreshold, + } +} + +// Info 实现 gormlogger.Interface 的 Info 方法 +func (l GormLogger) Info(ctx context.Context, str string, args ...interface{}) { + l.logger().Sugar().Debugf(str, args...) +} + +// Warn 实现 gormlogger.Interface 的 Warn 方法 +func (l GormLogger) Warn(ctx context.Context, str string, args ...interface{}) { + l.logger().Sugar().Warnf(str, args...) +} + +// Error 实现 gormlogger.Interface 的 Error 方法 +func (l GormLogger) Error(ctx context.Context, str string, args ...interface{}) { + l.logger().Sugar().Errorf(str, args...) +} + +// Trace 实现 gormlogger.Interface 的 Trace 方法 +func (l GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + + // 获取运行时间 + elapsed := time.Since(begin) + // 获取 SQL 请求和返回条数 + sql, rows := fc() + + // 通用字段 + logFields := []zap.Field{ + zap.String("sql", sql), + zap.String("time", helpers.MicrosecondsStr(elapsed)), + zap.Int64("rows", rows), + } + + // Gorm 错误 + if err != nil { + // 记录未找到的错误使用 warning 等级 + if errors.Is(err, gorm.ErrRecordNotFound) { + l.logger().Warn("Database ErrRecordNotFound", logFields...) + } else { + // 其他错误使用 error 等级 + logFields = append(logFields, zap.Error(err)) + l.logger().Error("Database Error", logFields...) + } + } + + // 慢查询日志 + if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { + l.logger().Warn("Database Slow Log", logFields...) + } + + // 记录所有 SQL 请求 + l.logger().Debug("Database Query", logFields...) +} + +// logger 内用的辅助方法,确保 Zap 内置信息 Caller 的准确性(如 paginator/paginator.go:148) +func (l GormLogger) logger() *zap.Logger { + + // 跳过 gorm 内置的调用 + var ( + gormPackage = filepath.Join("gorm.io", "gorm") + zapgormPackage = filepath.Join("moul.io", "zapgorm2") + ) + + // 减去一次封装,以及一次在 logger 初始化里添加 zap.AddCallerSkip(1) + clone := l.ZapLogger.WithOptions(zap.AddCallerSkip(-2)) + + for i := 2; i < 15; i++ { + _, file, _, ok := runtime.Caller(i) + switch { + case !ok: + case strings.HasSuffix(file, "_test.go"): + case strings.Contains(file, gormPackage): + case strings.Contains(file, zapgormPackage): + default: + // 返回一个附带跳过行号的新的 zap logger + return clone.WithOptions(zap.AddCallerSkip(i)) + } + } + return l.ZapLogger +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..ccf38d3 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,222 @@ +// Package logger 处理日志相关逻辑 +package logger + +import ( + "encoding/json" + "fmt" + "gohub/pkg/app" + "os" + "strings" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +// Logger 全局 Logger 对象 +var Logger *zap.Logger + +// InitLogger 日志初始化 +func InitLogger(filename string, maxSize, maxBackup, maxAge int, compress bool, logType string, level string) { + + // 获取日志写入介质 + writeSyncer := getLogWriter(filename, maxSize, maxBackup, maxAge, compress, logType) + + // 设置日志等级,具体请见 config/log.go 文件 + logLevel := new(zapcore.Level) + if err := logLevel.UnmarshalText([]byte(level)); err != nil { + fmt.Println("日志初始化错误,日志级别设置有误。请修改 config/log.go 文件中的 log.level 配置项") + } + + // 初始化 core + core := zapcore.NewCore(getEncoder(), writeSyncer, logLevel) + + // 初始化 Logger + Logger = zap.New(core, + zap.AddCaller(), // 调用文件和行号,内部使用 runtime.Caller + zap.AddCallerSkip(1), // 封装了一层,调用文件去除一层(runtime.Caller(1)) + zap.AddStacktrace(zap.ErrorLevel), // Error 时才会显示 stacktrace + ) + + // 将自定义的 logger 替换为全局的 logger + // zap.L().Fatal() 调用时,就会使用我们自定的 Logger + zap.ReplaceGlobals(Logger) +} + +// getEncoder 设置日志存储格式 +func getEncoder() zapcore.Encoder { + + // 日志格式规则 + encoderConfig := zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", // 代码调用,如 paginator/paginator.go:148 + FunctionKey: zapcore.OmitKey, + MessageKey: "message", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, // 每行日志的结尾添加 "\n" + EncodeLevel: zapcore.CapitalLevelEncoder, // 日志级别名称大写,如 ERROR、INFO + EncodeTime: customTimeEncoder, // 时间格式,我们自定义为 2006-01-02 15:04:05 + EncodeDuration: zapcore.SecondsDurationEncoder, // 执行时间,以秒为单位 + EncodeCaller: zapcore.ShortCallerEncoder, // Caller 短格式,如:types/converter.go:17,长格式为绝对路径 + } + + // 本地环境配置 + if app.IsLocal() { + // 终端输出的关键词高亮 + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + // 本地设置内置的 Console 解码器(支持 stacktrace 换行) + return zapcore.NewConsoleEncoder(encoderConfig) + } + + // 线上环境使用 JSON 编码器 + return zapcore.NewJSONEncoder(encoderConfig) +} + +// customTimeEncoder 自定义友好的时间格式 +func customTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format("2006-01-02 15:04:05")) +} + +// getLogWriter 日志记录介质。Gohub 中使用了两种介质,os.Stdout 和文件 +func getLogWriter(filename string, maxSize, maxBackup, maxAge int, compress bool, logType string) zapcore.WriteSyncer { + + // 如果配置了按照日期记录日志文件 + if logType == "daily" { + logname := time.Now().Format("2006-01-02.log") + filename = strings.ReplaceAll(filename, "logs.log", logname) + } + + // 滚动日志,详见 config/log.go + lumberJackLogger := &lumberjack.Logger{ + Filename: filename, + MaxSize: maxSize, + MaxBackups: maxBackup, + MaxAge: maxAge, + Compress: compress, + } + // 配置输出介质 + if app.IsLocal() { + // 本地开发终端打印和记录文件 + return zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(lumberJackLogger)) + } else { + // 生产环境只记录文件 + return zapcore.AddSync(lumberJackLogger) + } +} + +// Dump 调试专用,不会中断程序,会在终端打印出 warning 消息。 +// 第一个参数会使用 json.Marshal 进行渲染,第二个参数消息(可选) +// logger.Dump(user.User{Name:"test"}) +// logger.Dump(user.User{Name:"test"}, "用户信息") +func Dump(value interface{}, msg ...string) { + valueString := jsonString(value) + // 判断第二个参数是否传参 msg + if len(msg) > 0 { + Logger.Warn("Dump", zap.String(msg[0], valueString)) + } else { + Logger.Warn("Dump", zap.String("data", valueString)) + } +} + +// LogIf 当 err != nil 时记录 error 等级的日志 +func LogIf(err error) { + if err != nil { + Logger.Error("Error Occurred:", zap.Error(err)) + } +} + +// LogWarnIf 当 err != nil 时记录 warning 等级的日志 +func LogWarnIf(err error) { + if err != nil { + Logger.Warn("Error Occurred:", zap.Error(err)) + } +} + +// LogInfoIf 当 err != nil 时记录 info 等级的日志 +func LogInfoIf(err error) { + if err != nil { + Logger.Info("Error Occurred:", zap.Error(err)) + } +} + +// Debug 调试日志,详尽的程序日志 +// 调用示例: +// logger.Debug("Database", zap.String("sql", sql)) +func Debug(moduleName string, fields ...zap.Field) { + Logger.Debug(moduleName, fields...) +} + +// Info 告知类日志 +func Info(moduleName string, fields ...zap.Field) { + Logger.Info(moduleName, fields...) +} + +// Warn 警告类 +func Warn(moduleName string, fields ...zap.Field) { + Logger.Warn(moduleName, fields...) +} + +// Error 错误时记录,不应该中断程序,查看日志时重点关注 +func Error(moduleName string, fields ...zap.Field) { + Logger.Error(moduleName, fields...) +} + +// Fatal 级别同 Error(), 写完 log 后调用 os.Exit(1) 退出程序 +func Fatal(moduleName string, fields ...zap.Field) { + Logger.Fatal(moduleName, fields...) +} + +// DebugString 记录一条字符串类型的 debug 日志,调用示例: +// logger.DebugString("SMS", "短信内容", string(result.RawResponse)) +func DebugString(moduleName, name, msg string) { + Logger.Debug(moduleName, zap.String(name, msg)) +} + +func InfoString(moduleName, name, msg string) { + Logger.Info(moduleName, zap.String(name, msg)) +} + +func WarnString(moduleName, name, msg string) { + Logger.Warn(moduleName, zap.String(name, msg)) +} + +func ErrorString(moduleName, name, msg string) { + Logger.Error(moduleName, zap.String(name, msg)) +} + +func FatalString(moduleName, name, msg string) { + Logger.Fatal(moduleName, zap.String(name, msg)) +} + +// DebugJSON 记录一条字符串类型的 debug 日志,调用示例: +// logger.DebugString("SMS", "短信内容", string(result.RawResponse)) +func DebugJSON(moduleName, name string, value interface{}) { + Logger.Debug(moduleName, zap.String(name, jsonString(value))) +} + +func InfoJSON(moduleName, name string, value interface{}) { + Logger.Info(moduleName, zap.String(name, jsonString(value))) +} + +func WarnJSON(moduleName, name string, value interface{}) { + Logger.Warn(moduleName, zap.String(name, jsonString(value))) +} + +func ErrorJSON(moduleName, name string, value interface{}) { + Logger.Error(moduleName, zap.String(name, jsonString(value))) +} + +func FatalJSON(moduleName, name string, value interface{}) { + Logger.Fatal(moduleName, zap.String(name, jsonString(value))) +} + +func jsonString(value interface{}) string { + b, err := json.Marshal(value) + if err != nil { + Logger.Error("Logger", zap.String("JSON marshal error", err.Error())) + } + return string(b) +} diff --git a/pkg/mail/driver_interface.go b/pkg/mail/driver_interface.go new file mode 100644 index 0000000..6198d57 --- /dev/null +++ b/pkg/mail/driver_interface.go @@ -0,0 +1,6 @@ +package mail + +type Driver interface { + // 检查验证码 + Send(email Email, config map[string]string) bool +} diff --git a/pkg/mail/driver_smtp.go b/pkg/mail/driver_smtp.go new file mode 100644 index 0000000..054cd97 --- /dev/null +++ b/pkg/mail/driver_smtp.go @@ -0,0 +1,46 @@ +package mail + +import ( + "fmt" + "gohub/pkg/logger" + "net/smtp" + + emailPKG "github.com/jordan-wright/email" +) + +// SMTP 实现 email.Driver interface +type SMTP struct{} + +// Send 实现 email.Driver interface 的 Send 方法 +func (s *SMTP) Send(email Email, config map[string]string) bool { + + e := emailPKG.NewEmail() + + e.From = fmt.Sprintf("%v <%v>", email.From.Name, email.From.Address) + e.To = email.To + e.Bcc = email.Bcc + e.Cc = email.Cc + e.Subject = email.Subject + e.Text = email.Text + e.HTML = email.HTML + + logger.DebugJSON("发送邮件", "发件详情", e) + + err := e.Send( + fmt.Sprintf("%v:%v", config["host"], config["port"]), + + smtp.PlainAuth( + "", + config["username"], + config["password"], + config["host"], + ), + ) + if err != nil { + logger.ErrorString("发送邮件", "发件出错", err.Error()) + return false + } + + logger.DebugString("发送邮件", "发件成功", "") + return true +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 0000000..cdd66e8 --- /dev/null +++ b/pkg/mail/mail.go @@ -0,0 +1,44 @@ +// Package mail 发送短信 +package mail + +import ( + "gohub/pkg/config" + "sync" +) + +type From struct { + Address string + Name string +} + +type Email struct { + From From + To []string + Bcc []string + Cc []string + Subject string + Text []byte // Plaintext message (optional) + HTML []byte // Html message (optional) +} + +type Mailer struct { + Driver Driver +} + +var once sync.Once +var internalMailer *Mailer + +// NewMailer 单例模式获取 +func NewMailer() *Mailer { + once.Do(func() { + internalMailer = &Mailer{ + Driver: &SMTP{}, + } + }) + + return internalMailer +} + +func (mailer *Mailer) Send(email Email) bool { + return mailer.Driver.Send(email, config.GetStringMapString("mail.smtp")) +} diff --git a/pkg/migrate/migration_file.go b/pkg/migrate/migration_file.go new file mode 100644 index 0000000..885a02a --- /dev/null +++ b/pkg/migrate/migration_file.go @@ -0,0 +1,49 @@ +package migrate + +import ( + "database/sql" + + "gorm.io/gorm" +) + +// migrationFunc 定义 up 和 down 回调方法的类型 +type migrationFunc func(gorm.Migrator, *sql.DB) + +// migrationFiles 所有的迁移文件数组 +var migrationFiles []MigrationFile + +// MigrationFile 代表着单个迁移文件 +type MigrationFile struct { + Up migrationFunc + Down migrationFunc + FileName string +} + +// Add 新增一个迁移文件,所有的迁移文件都需要调用此方法来注册 +func Add(name string, up migrationFunc, down migrationFunc) { + migrationFiles = append(migrationFiles, MigrationFile{ + FileName: name, + Up: up, + Down: down, + }) +} + +// getMigrationFile 通过迁移文件的名称来获取到 MigrationFile 对象 +func getMigrationFile(name string) MigrationFile { + for _, mfile := range migrationFiles { + if name == mfile.FileName { + return mfile + } + } + return MigrationFile{} +} + +// isNotMigrated 判断迁移是否已执行 +func (mfile MigrationFile) isNotMigrated(migrations []Migration) bool { + for _, migration := range migrations { + if migration.Migration == mfile.FileName { + return false + } + } + return true +} diff --git a/pkg/migrate/migrator.go b/pkg/migrate/migrator.go new file mode 100644 index 0000000..6caa381 --- /dev/null +++ b/pkg/migrate/migrator.go @@ -0,0 +1,230 @@ +// Package migrate 处理数据库迁移 +package migrate + +import ( + "gohub/pkg/console" + "gohub/pkg/database" + "gohub/pkg/file" + "io/ioutil" + + "gorm.io/gorm" +) + +// Migrator 数据迁移操作类 +type Migrator struct { + Folder string + DB *gorm.DB + Migrator gorm.Migrator +} + +// Migration 对应数据的 migrations 表里的一条数据 +type Migration struct { + ID uint64 `gorm:"primaryKey;autoIncrement;"` + Migration string `gorm:"type:varchar(255);not null;unique;"` + Batch int +} + +// NewMigrator 创建 Migrator 实例,用以执行迁移操作 +func NewMigrator() *Migrator { + + // 初始化必要属性 + migrator := &Migrator{ + Folder: "database/migrations/", + DB: database.DB, + Migrator: database.DB.Migrator(), + } + // migrations 不存在的话就创建它 + migrator.createMigrationsTable() + + return migrator +} + +// 创建 migrations 表 +func (migrator *Migrator) createMigrationsTable() { + + migration := Migration{} + + // 不存在才创建 + if !migrator.Migrator.HasTable(&migration) { + migrator.Migrator.CreateTable(&migration) + } +} + +// Up 执行所有未迁移过的文件 +func (migrator *Migrator) Up() { + + // 读取所有迁移文件,确保按照时间排序 + migrateFiles := migrator.readAllMigrationFiles() + + // 获取当前批次的值 + batch := migrator.getBatch() + + // 获取所有迁移数据 + migrations := []Migration{} + migrator.DB.Find(&migrations) + + // 可以通过此值来判断数据库是否已是最新 + runed := false + + // 对迁移文件进行遍历,如果没有执行过,就执行 up 回调 + for _, mfile := range migrateFiles { + + // 对比文件名称,看是否已经运行过 + if mfile.isNotMigrated(migrations) { + migrator.runUpMigration(mfile, batch) + runed = true + } + } + + if !runed { + console.Success("database is up to date.") + } +} + +// Rollback 回滚上一个操作 +func (migrator *Migrator) Rollback() { + + // 获取最后一批次的迁移数据 + lastMigration := Migration{} + migrator.DB.Order("id DESC").First(&lastMigration) + migrations := []Migration{} + migrator.DB.Where("batch = ?", lastMigration.Batch).Order("id DESC").Find(&migrations) + + // 回滚最后一批次的迁移 + if !migrator.rollbackMigrations(migrations) { + console.Success("[migrations] table is empty, nothing to rollback.") + } +} + +// 回退迁移,按照倒序执行迁移的 down 方法 +func (migrator *Migrator) rollbackMigrations(migrations []Migration) bool { + + // 标记是否真的有执行了迁移回退的操作 + runed := false + + for _, _migration := range migrations { + + // 友好提示 + console.Warning("rollback " + _migration.Migration) + + // 执行迁移文件的 down 方法 + mfile := getMigrationFile(_migration.Migration) + if mfile.Down != nil { + mfile.Down(database.DB.Migrator(), database.SQLDB) + } + + runed = true + + // 回退成功了就删除掉这条记录 + migrator.DB.Delete(&_migration) + + // 打印运行状态 + console.Success("finsh " + mfile.FileName) + } + return runed +} + +// 获取当前这个批次的值 +func (migrator *Migrator) getBatch() int { + + // 默认为 1 + batch := 1 + + // 取最后执行的一条迁移数据 + lastMigration := Migration{} + migrator.DB.Order("id DESC").First(&lastMigration) + + // 如果有值的话,加一 + if lastMigration.ID > 0 { + batch = lastMigration.Batch + 1 + } + return batch +} + +// 从文件目录读取文件,保证正确的时间排序 +func (migrator *Migrator) readAllMigrationFiles() []MigrationFile { + + // 读取 database/migrations/ 目录下的所有文件 + // 默认是会按照文件名称进行排序 + files, err := ioutil.ReadDir(migrator.Folder) + console.ExitIf(err) + + var migrateFiles []MigrationFile + for _, f := range files { + + // 去除文件后缀 .go + fileName := file.FileNameWithoutExtension(f.Name()) + + // 通过迁移文件的名称获取『MigrationFile』对象 + mfile := getMigrationFile(fileName) + + // 加个判断,确保迁移文件可用,再放进 migrateFiles 数组中 + if len(mfile.FileName) > 0 { + migrateFiles = append(migrateFiles, mfile) + } + } + + // 返回排序好的『MigrationFile』数组 + return migrateFiles +} + +// 执行迁移,执行迁移的 up 方法 +func (migrator *Migrator) runUpMigration(mfile MigrationFile, batch int) { + + // 执行 up 区块的 SQL + if mfile.Up != nil { + // 友好提示 + console.Warning("migrating " + mfile.FileName) + // 执行 up 方法 + mfile.Up(database.DB.Migrator(), database.SQLDB) + // 提示已迁移了哪个文件 + console.Success("migrated " + mfile.FileName) + } + + // 入库 + err := migrator.DB.Create(&Migration{Migration: mfile.FileName, Batch: batch}).Error + console.ExitIf(err) +} + +// Reset 回滚所有迁移 +func (migrator *Migrator) Reset() { + + migrations := []Migration{} + + // 按照倒序读取所有迁移文件 + migrator.DB.Order("id DESC").Find(&migrations) + + // 回滚所有迁移 + if !migrator.rollbackMigrations(migrations) { + console.Success("[migrations] table is empty, nothing to reset.") + } +} + +// Refresh 回滚所有迁移,并运行所有迁移 +func (migrator *Migrator) Refresh() { + + // 回滚所有迁移 + migrator.Reset() + + // 再次执行所有迁移 + migrator.Up() +} + +// Fresh Drop 所有的表并重新运行所有迁移 +func (migrator *Migrator) Fresh() { + + // 获取数据库名称,用以提示 + dbname := database.CurrentDatabase() + + // 删除所有表 + err := database.DeleteAllTables() + console.ExitIf(err) + console.Success("clearup database " + dbname) + + // 重新创建 migrates 表 + migrator.createMigrationsTable() + console.Success("[migrations] table created.") + + // 重新调用 up 命令 + migrator.Up() +} diff --git a/pkg/paginator/paginator.go b/pkg/paginator/paginator.go new file mode 100644 index 0000000..68afa90 --- /dev/null +++ b/pkg/paginator/paginator.go @@ -0,0 +1,200 @@ +// Package paginator 处理分页逻辑 +package paginator + +import ( + "fmt" + "gohub/pkg/config" + "gohub/pkg/logger" + "math" + "strings" + + "github.com/gin-gonic/gin" + "github.com/spf13/cast" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Paging 分页数据 +type Paging struct { + CurrentPage int // 当前页 + PerPage int // 每页条数 + TotalPage int // 总页数 + TotalCount int64 // 总条数 + NextPageURL string // 下一页的链接 + PrevPageURL string // 上一页的链接 +} + +// Paginator 分页操作类 +type Paginator struct { + BaseURL string // 用以拼接 URL + PerPage int // 每页条数 + Page int // 当前页 + Offset int // 数据库读取数据时 Offset 的值 + TotalCount int64 // 总条数 + TotalPage int // 总页数 = TotalCount/PerPage + Sort string // 排序规则 + Order string // 排序顺序 + + query *gorm.DB // db query 句柄 + ctx *gin.Context // gin context,方便调用 +} + +// Paginate 分页 +// c —— gin.context 用来获取分页的 URL 参数 +// db —— GORM 查询句柄,用以查询数据集和获取数据总数 +// baseURL —— 用以分页链接 +// data —— 模型数组,传址获取数据 +// PerPage —— 每页条数,优先从 url 参数里取,否则使用 perPage 的值 +// 用法: +// query := database.DB.Model(Topic{}).Where("category_id = ?", cid) +// var topics []Topic +// paging := paginator.Paginate( +// c, +// query, +// &topics, +// app.APIURL(database.TableName(&Topic{})), +// perPage, +// ) +func Paginate(c *gin.Context, db *gorm.DB, data interface{}, baseURL string, perPage int) Paging { + + // 初始化 Paginator 实例 + p := &Paginator{ + query: db, + ctx: c, + } + p.initProperties(perPage, baseURL) + + // 查询数据库 + err := p.query.Preload(clause.Associations). // 读取关联 + Order(p.Sort + " " + p.Order). // 排序 + Limit(p.PerPage). + Offset(p.Offset). + Find(data). + Error + + // 数据库出错 + if err != nil { + logger.LogIf(err) + return Paging{} + } + + return Paging{ + CurrentPage: p.Page, + PerPage: p.PerPage, + TotalPage: p.TotalPage, + TotalCount: p.TotalCount, + NextPageURL: p.getNextPageURL(), + PrevPageURL: p.getPrevPageURL(), + } +} + +// 初始化分页必须用到的属性,基于这些属性查询数据库 +func (p *Paginator) initProperties(perPage int, baseURL string) { + + p.BaseURL = p.formatBaseURL(baseURL) + p.PerPage = p.getPerPage(perPage) + + // 排序参数(控制器中以验证过这些参数,可放心使用) + p.Order = p.ctx.DefaultQuery(config.Get("paging.url_query_order"), "asc") + p.Sort = p.ctx.DefaultQuery(config.Get("paging.url_query_sort"), "id") + + p.TotalCount = p.getTotalCount() + p.TotalPage = p.getTotalPage() + p.Page = p.getCurrentPage() + p.Offset = (p.Page - 1) * p.PerPage +} + +func (p Paginator) getPerPage(perPage int) int { + // 优先使用请求 per_page 参数 + queryPerpage := p.ctx.Query(config.Get("paging.url_query_per_page")) + if len(queryPerpage) > 0 { + perPage = cast.ToInt(queryPerpage) + } + + // 没有传参,使用默认 + if perPage <= 0 { + perPage = config.GetInt("paging.perpage") + } + + return perPage +} + +// getCurrentPage 返回当前页码 +func (p Paginator) getCurrentPage() int { + // 优先取用户请求的 page + page := cast.ToInt(p.ctx.Query(config.Get("paging.url_query_page"))) + if page <= 0 { + // 默认为 1 + page = 1 + } + // TotalPage 等于 0 ,意味着数据不够分页 + if p.TotalPage == 0 { + return 0 + } + // 请求页数大于总页数,返回总页数 + if page > p.TotalPage { + return p.TotalPage + } + return page +} + +// getTotalCount 返回的是数据库里的条数 +func (p *Paginator) getTotalCount() int64 { + var count int64 + if err := p.query.Count(&count).Error; err != nil { + return 0 + } + return count +} + +// getTotalPage 计算总页数 +func (p Paginator) getTotalPage() int { + if p.TotalCount == 0 { + return 0 + } + nums := int64(math.Ceil(float64(p.TotalCount) / float64(p.PerPage))) + if nums == 0 { + nums = 1 + } + return int(nums) +} + +// 兼容 URL 带与不带 `?` 的情况 +func (p *Paginator) formatBaseURL(baseURL string) string { + if strings.Contains(baseURL, "?") { + baseURL = baseURL + "&" + config.Get("paging.url_query_page") + "=" + } else { + baseURL = baseURL + "?" + config.Get("paging.url_query_page") + "=" + } + return baseURL +} + +// 拼接分页链接 +func (p Paginator) getPageLink(page int) string { + return fmt.Sprintf("%v%v&%s=%s&%s=%s&%s=%v", + p.BaseURL, + page, + config.Get("paging.url_query_sort"), + p.Sort, + config.Get("paging.url_query_order"), + p.Order, + config.Get("paging.url_query_per_page"), + p.PerPage, + ) +} + +// getNextPageURL 返回下一页的链接 +func (p Paginator) getNextPageURL() string { + if p.TotalPage > p.Page { + return p.getPageLink(p.Page + 1) + } + return "" +} + +// getPrevPageURL 返回下一页的链接 +func (p Paginator) getPrevPageURL() string { + if p.Page == 1 || p.Page > p.TotalPage { + return "" + } + return p.getPageLink(p.Page - 1) +} diff --git a/pkg/redis/redis.go b/pkg/redis/redis.go new file mode 100644 index 0000000..29a5c43 --- /dev/null +++ b/pkg/redis/redis.go @@ -0,0 +1,158 @@ +// Package redis 工具包 +package redis + +import ( + "context" + "gohub/pkg/logger" + "sync" + "time" + + redis "github.com/go-redis/redis/v8" +) + +// RedisClient Redis 服务 +type RedisClient struct { + Client *redis.Client + Context context.Context +} + +// once 确保全局的 Redis 对象只实例一次 +var once sync.Once + +// Redis 全局 Redis,使用 db 1 +var Redis *RedisClient + +// ConnectRedis 连接 redis 数据库,设置全局的 Redis 对象 +func ConnectRedis(address string, username string, password string, db int) { + once.Do(func() { + Redis = NewClient(address, username, password, db) + }) +} + +// NewClient 创建一个新的 redis 连接 +func NewClient(address string, username string, password string, db int) *RedisClient { + + // 初始化自定的 RedisClient 实例 + rds := &RedisClient{} + // 使用默认的 context + rds.Context = context.Background() + + // 使用 redis 库里的 NewClient 初始化连接 + rds.Client = redis.NewClient(&redis.Options{ + Addr: address, + Username: username, + Password: password, + DB: db, + }) + + // 测试一下连接 + err := rds.Ping() + logger.LogIf(err) + + return rds +} + +// Ping 用以测试 redis 连接是否正常 +func (rds RedisClient) Ping() error { + _, err := rds.Client.Ping(rds.Context).Result() + return err +} + +// Set 存储 key 对应的 value,且设置 expiration 过期时间 +func (rds RedisClient) Set(key string, value interface{}, expiration time.Duration) bool { + if err := rds.Client.Set(rds.Context, key, value, expiration).Err(); err != nil { + logger.ErrorString("Redis", "Set", err.Error()) + return false + } + return true +} + +// Get 获取 key 对应的 value +func (rds RedisClient) Get(key string) string { + result, err := rds.Client.Get(rds.Context, key).Result() + if err != nil { + if err != redis.Nil { + logger.ErrorString("Redis", "Get", err.Error()) + } + return "" + } + return result +} + +// Has 判断一个 key 是否存在,内部错误和 redis.Nil 都返回 false +func (rds RedisClient) Has(key string) bool { + _, err := rds.Client.Get(rds.Context, key).Result() + if err != nil { + if err != redis.Nil { + logger.ErrorString("Redis", "Get", err.Error()) + } + return false + } + return true +} + +// Del 删除存储在 redis 里的数据,支持多个 key 传参 +func (rds RedisClient) Del(keys ...string) bool { + if err := rds.Client.Del(rds.Context, keys...).Err(); err != nil { + logger.ErrorString("Redis", "Del", err.Error()) + return false + } + return true +} + +// FlushDB 清空当前 redis db 里的所有数据 +func (rds RedisClient) FlushDB(keys ...string) bool { + if err := rds.Client.FlushDB(rds.Context).Err(); err != nil { + logger.ErrorString("Redis", "FlushDB", err.Error()) + return false + } + return true +} + +// Increment 当参数只有 1 个时,为 key,其值增加 1。 +// 当参数有 2 个时,第一个参数为 key ,第二个参数为要增加的值 int64 类型。 +func (rds RedisClient) Increment(parameters ...interface{}) bool { + switch len(parameters) { + case 1: + key := parameters[0].(string) + if err := rds.Client.Incr(rds.Context, key).Err(); err != nil { + logger.ErrorString("Redis", "Increment", err.Error()) + return false + } + case 2: + key := parameters[0].(string) + value := parameters[0].(int64) + if err := rds.Client.IncrBy(rds.Context, key, value).Err(); err != nil { + logger.ErrorString("Redis", "Increment", err.Error()) + return false + } + default: + logger.ErrorString("Redis", "Increment", "参数过多") + return false + } + return true +} + +// Decrement 当参数只有 1 个时,为 key,其值减去 1。 +// 当参数有 2 个时,第一个参数为 key ,第二个参数为要减去的值 int64 类型。 +func (rds RedisClient) Decrement(parameters ...interface{}) bool { + switch len(parameters) { + case 1: + key := parameters[0].(string) + if err := rds.Client.Decr(rds.Context, key).Err(); err != nil { + logger.ErrorString("Redis", "Decrement", err.Error()) + return false + } + case 2: + key := parameters[0].(string) + value := parameters[0].(int64) + if err := rds.Client.DecrBy(rds.Context, key, value).Err(); err != nil { + logger.ErrorString("Redis", "Decrement", err.Error()) + return false + } + default: + logger.ErrorString("Redis", "Decrement", "参数过多") + return false + } + return true +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..28d9c2f --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,131 @@ +// Package response 响应处理工具 +package response + +import ( + "gohub/pkg/logger" + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// JSON 响应 200 和 JSON 数据 +func JSON(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, data) +} + +// Success 响应 200 和预设『操作成功!』的JSON 数据 +// 执行某个『没有具体返回数据』的『变更』操作成功后调用,例如删除、修改密码、修改手机号 +func Success(c *gin.Context) { + JSON(c, gin.H{ + "success": true, + "message": "操作成功!", + }) +} + +// Data 响应 200 和带 data 键的JSON 数据 +// 执行『更新操作』成功后调用,例如更新话题,成功后返回已更新的话题 +func Data(c *gin.Context, data interface{}) { + JSON(c, gin.H{ + "success": true, + "data": data, + }) +} + +// Created 响应 201 和带 data 键的JSON 数据 +// 执行『更新操作』成功后调用,例如更新话题,成功后返回已更新的话题 +func Created(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "data": data, + }) +} + +// CreatedJSON 响应 200 和 JSON 数据 +func CreatedJSON(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, data) +} + +// Abort404 响应 404,未传参 msg 时使用默认消息 +func Abort404(c *gin.Context, msg ...string) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ + "message": defaultMessage("数据不存在,请确定请求正确", msg...), + }) +} + +// Abort403 响应 403,未传参 msg 时使用默认消息 +func Abort403(c *gin.Context, msg ...string) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "message": defaultMessage("权限不足,请确定您有对应的权限", msg...), + }) +} + +// Abort500 响应 500,未传参 msg 时使用默认消息 +func Abort500(c *gin.Context, msg ...string) { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "message": defaultMessage("服务器内部错误,请稍后再试", msg...), + }) +} + +// BadRequest 响应 400,传参 err 对象,未传参 msg 时使用默认消息 +// 在解析用户请求,请求的格式或者方法不符合预期时调用 +func BadRequest(c *gin.Context, err error, msg ...string) { + logger.LogIf(err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "message": defaultMessage("请求解析错误,请确认请求格式是否正确。上传文件请使用 multipart 标头,参数请使用 JSON 格式。", msg...), + "error": err.Error(), + }) +} + +// Error 响应 404 或 422,未传参 msg 时使用默认消息 +// 处理请求时出现错误 err,会附带返回 error 信息,如登录错误、找不到 ID 对应的 Model +func Error(c *gin.Context, err error, msg ...string) { + logger.LogIf(err) + + // error 类型为『数据库未找到内容』 + if err == gorm.ErrRecordNotFound { + Abort404(c) + return + } + + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{ + "message": defaultMessage("请求处理失败,请查看 error 的值", msg...), + "error": err.Error(), + }) +} + +// ValidationError 处理表单验证不通过的错误,返回的 JSON 示例: +// { +// "errors": { +// "phone": [ +// "手机号为必填项,参数名称 phone", +// "手机号长度必须为 11 位的数字" +// ] +// }, +// "message": "请求验证不通过,具体请查看 errors" +// } +func ValidationError(c *gin.Context, errors map[string][]string) { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{ + "message": "请求验证不通过,具体请查看 errors", + "errors": errors, + }) +} + +// Unauthorized 响应 401,未传参 msg 时使用默认消息 +// 登录失败、jwt 解析失败时调用 +func Unauthorized(c *gin.Context, msg ...string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": defaultMessage("请求解析错误,请确认请求格式是否正确。上传文件请使用 multipart 标头,参数请使用 JSON 格式。", msg...), + }) +} + +// defaultMessage 内用的辅助函数,用以支持默认参数默认值 +// Go 不支持参数默认值,只能使用多变参数来实现类似效果 +func defaultMessage(defaultMsg string, msg ...string) (message string) { + if len(msg) > 0 { + message = msg[0] + } else { + message = defaultMsg + } + return +} diff --git a/pkg/seed/seeder.go b/pkg/seed/seeder.go new file mode 100644 index 0000000..670a5e0 --- /dev/null +++ b/pkg/seed/seeder.go @@ -0,0 +1,83 @@ +// Package seed 处理数据库填充相关逻辑 +package seed + +import ( + "gohub/pkg/console" + "gohub/pkg/database" + + "gorm.io/gorm" +) + +// 存放所有 Seeder +var seeders []Seeder + +// 按顺序执行的 Seeder 数组 +// 支持一些必须按顺序执行的 seeder,例如 topic 创建的 +// 时必须依赖于 user, 所以 TopicSeeder 应该在 UserSeeder 后执行 +var orderedSeederNames []string + +type SeederFunc func(*gorm.DB) + +// Seeder 对应每一个 database/seeders 目录下的 Seeder 文件 +type Seeder struct { + Func SeederFunc + Name string +} + +// Add 注册到 seeders 数组中 +func Add(name string, fn SeederFunc) { + seeders = append(seeders, Seeder{ + Name: name, + Func: fn, + }) +} + +// SetRunOrder 设置『按顺序执行的 Seeder 数组』 +func SetRunOrder(names []string) { + orderedSeederNames = names +} + +// GetSeeder 通过名称来获取 Seeder 对象 +func GetSeeder(name string) Seeder { + for _, sdr := range seeders { + if name == sdr.Name { + return sdr + } + } + return Seeder{} +} + +// RunAll 运行所有 Seeder +func RunAll() { + + // 先运行 ordered 的 + executed := make(map[string]string) + for _, name := range orderedSeederNames { + sdr := GetSeeder(name) + if len(sdr.Name) > 0 { + console.Warning("Running Odered Seeder: " + sdr.Name) + sdr.Func(database.DB) + executed[name] = name + + } + } + + // 再运行剩下的 + for _, sdr := range seeders { + // 过滤已运行 + if _, ok := executed[sdr.Name]; !ok { + console.Warning("Running Seeder: " + sdr.Name) + sdr.Func(database.DB) + } + } +} + +// RunSeeder 运行单个 Seeder +func RunSeeder(name string) { + for _, sdr := range seeders { + if name == sdr.Name { + sdr.Func(database.DB) + break + } + } +} diff --git a/pkg/sms/driver_aliyun.go b/pkg/sms/driver_aliyun.go new file mode 100644 index 0000000..298363f --- /dev/null +++ b/pkg/sms/driver_aliyun.go @@ -0,0 +1,56 @@ +package sms + +import ( + "encoding/json" + "gohub/pkg/logger" + + aliyunsmsclient "github.com/KenmyZhang/aliyun-communicate" +) + +// Aliyun 实现 sms.Driver interface +type Aliyun struct{} + +// Send 实现 sms.Driver interface 的 Send 方法 +func (s *Aliyun) Send(phone string, message Message, config map[string]string) bool { + + smsClient := aliyunsmsclient.New("http://dysmsapi.aliyuncs.com/") + + templateParam, err := json.Marshal(message.Data) + if err != nil { + logger.ErrorString("短信[阿里云]", "解析绑定错误", err.Error()) + return false + } + + logger.DebugJSON("短信[阿里云]", "配置信息", config) + + result, err := smsClient.Execute( + config["access_key_id"], + config["access_key_secret"], + phone, + config["sign_name"], + message.Template, + string(templateParam), + ) + + logger.DebugJSON("短信[阿里云]", "请求内容", smsClient.Request) + logger.DebugJSON("短信[阿里云]", "接口响应", result) + + if err != nil { + logger.ErrorString("短信[阿里云]", "发信失败", err.Error()) + return false + } + + resultJSON, err := json.Marshal(result) + if err != nil { + logger.ErrorString("短信[阿里云]", "解析响应 JSON 错误", err.Error()) + return false + } + + if result.IsSuccessful() { + logger.DebugString("短信[阿里云]", "发信成功", "") + return true + } else { + logger.ErrorString("短信[阿里云]", "服务商返回错误", string(resultJSON)) + return false + } +} diff --git a/pkg/sms/driver_interface.go b/pkg/sms/driver_interface.go new file mode 100644 index 0000000..9d24518 --- /dev/null +++ b/pkg/sms/driver_interface.go @@ -0,0 +1,6 @@ +package sms + +type Driver interface { + // 发送短信 + Send(phone string, message Message, config map[string]string) bool +} diff --git a/pkg/sms/sms.go b/pkg/sms/sms.go new file mode 100644 index 0000000..3fcdb8f --- /dev/null +++ b/pkg/sms/sms.go @@ -0,0 +1,41 @@ +// Package sms 发送短信 +package sms + +import ( + "gohub/pkg/config" + "sync" +) + +// Message 是短信的结构体 +type Message struct { + Template string + Data map[string]string + + Content string +} + +// SMS 是我们发送短信的操作类 +type SMS struct { + Driver Driver +} + +// once 单例模式 +var once sync.Once + +// internalSMS 内部使用的 SMS 对象 +var internalSMS *SMS + +// NewSMS 单例模式获取 +func NewSMS() *SMS { + once.Do(func() { + internalSMS = &SMS{ + Driver: &Aliyun{}, + } + }) + + return internalSMS +} + +func (sms *SMS) Send(phone string, message Message) bool { + return sms.Driver.Send(phone, message, config.GetStringMapString("sms.aliyun")) +} diff --git a/pkg/str/str.go b/pkg/str/str.go new file mode 100644 index 0000000..00b6f86 --- /dev/null +++ b/pkg/str/str.go @@ -0,0 +1,32 @@ +// Package str 字符串辅助方法 +package str + +import ( + "github.com/gertd/go-pluralize" + "github.com/iancoleman/strcase" +) + +// Plural 转为复数 user -> users +func Plural(word string) string { + return pluralize.NewClient().Plural(word) +} + +// Singular 转为单数 users -> user +func Singular(word string) string { + return pluralize.NewClient().Singular(word) +} + +// Snake 转为 snake_case,如 TopicComment -> topic_comment +func Snake(s string) string { + return strcase.ToSnake(s) +} + +// Camel 转为 CamelCase,如 topic_comment -> TopicComment +func Camel(s string) string { + return strcase.ToCamel(s) +} + +// LowerCamel 转为 lowerCamelCase,如 TopicComment -> topicComment +func LowerCamel(s string) string { + return strcase.ToLowerCamel(s) +} diff --git a/pkg/verifycode/store_interface.go b/pkg/verifycode/store_interface.go new file mode 100644 index 0000000..117b92f --- /dev/null +++ b/pkg/verifycode/store_interface.go @@ -0,0 +1,12 @@ +package verifycode + +type Store interface { + // 保存验证码 + Set(id string, value string) bool + + // 获取验证码 + Get(id string, clear bool) string + + // 检查验证码 + Verify(id, answer string, clear bool) bool +} diff --git a/pkg/verifycode/store_redis.go b/pkg/verifycode/store_redis.go new file mode 100644 index 0000000..23a5178 --- /dev/null +++ b/pkg/verifycode/store_redis.go @@ -0,0 +1,42 @@ +package verifycode + +import ( + "gohub/pkg/app" + "gohub/pkg/config" + "gohub/pkg/redis" + "time" +) + +// RedisStore 实现 verifycode.Store interface +type RedisStore struct { + RedisClient *redis.RedisClient + KeyPrefix string +} + +// Set 实现 verifycode.Store interface 的 Set 方法 +func (s *RedisStore) Set(key string, value string) bool { + + ExpireTime := time.Minute * time.Duration(config.GetInt64("verifycode.expire_time")) + // 本地环境方便调试 + if app.IsLocal() { + ExpireTime = time.Minute * time.Duration(config.GetInt64("verifycode.debug_expire_time")) + } + + return s.RedisClient.Set(s.KeyPrefix+key, value, ExpireTime) +} + +// Get 实现 verifycode.Store interface 的 Get 方法 +func (s *RedisStore) Get(key string, clear bool) (value string) { + key = s.KeyPrefix + key + val := s.RedisClient.Get(key) + if clear { + s.RedisClient.Del(key) + } + return val +} + +// Verify 实现 verifycode.Store interface 的 Verify 方法 +func (s *RedisStore) Verify(key, answer string, clear bool) bool { + v := s.Get(key, clear) + return v == answer +} diff --git a/pkg/verifycode/verifycode.go b/pkg/verifycode/verifycode.go new file mode 100644 index 0000000..2ecd5b2 --- /dev/null +++ b/pkg/verifycode/verifycode.go @@ -0,0 +1,116 @@ +// Package verifycode 用以发送手机验证码和邮箱验证码 +package verifycode + +import ( + "fmt" + "gohub/pkg/app" + "gohub/pkg/config" + "gohub/pkg/helpers" + "gohub/pkg/logger" + "gohub/pkg/mail" + "gohub/pkg/redis" + "gohub/pkg/sms" + "strings" + "sync" +) + +type VerifyCode struct { + Store Store +} + +var once sync.Once +var internalVerifyCode *VerifyCode + +// NewVerifyCode 单例模式获取 +func NewVerifyCode() *VerifyCode { + once.Do(func() { + internalVerifyCode = &VerifyCode{ + Store: &RedisStore{ + RedisClient: redis.Redis, + // 增加前缀保持数据库整洁,出问题调试时也方便 + KeyPrefix: config.GetString("app.name") + ":verifycode:", + }, + } + }) + + return internalVerifyCode +} + +// SendSMS 发送短信验证码,调用示例: +// verifycode.NewVerifyCode().SendSMS(request.Phone) +func (vc *VerifyCode) SendSMS(phone string) bool { + + // 生成验证码 + code := vc.generateVerifyCode(phone) + + // 方便本地和 API 自动测试 + if !app.IsProduction() && strings.HasPrefix(phone, config.GetString("verifycode.debug_phone_prefix")) { + return true + } + + // 发送短信 + return sms.NewSMS().Send(phone, sms.Message{ + Template: config.GetString("sms.aliyun.template_code"), + Data: map[string]string{"code": code}, + }) +} + +// SendEmail 发送邮件验证码,调用示例: +// verifycode.NewVerifyCode().SendEmail(request.Email) +func (vc *VerifyCode) SendEmail(email string) error { + + // 生成验证码 + code := vc.generateVerifyCode(email) + + // 方便本地和 API 自动测试 + if !app.IsProduction() && strings.HasSuffix(email, config.GetString("verifycode.debug_email_suffix")) { + return nil + } + + content := fmt.Sprintf("