commit 7218ba6508e184076aa5871836c10fdf8d1fa37e Author: black1552 Date: Fri Jan 30 15:51:42 2026 +0800 第一次提交 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f19b1da --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +Copyright 2021 black1552 + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/autoMigrate/autoMigrate.go b/autoMigrate/autoMigrate.go new file mode 100644 index 0000000..fcf4c3b --- /dev/null +++ b/autoMigrate/autoMigrate.go @@ -0,0 +1,89 @@ +package autoMigrate + +import ( + "context" + + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gctx" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var ( + ctx context.Context + db *gorm.DB +) + +type AutoMigrate struct { + ctx context.Context + db *gorm.DB + dns string +} + +var am *AutoMigrate + +func New() { + am = &AutoMigrate{} + am.ctx = gctx.New() + err := g.DB().PingMaster() + if err != nil { + g.Log().Error(ctx, "数据库连接失败", err) + return + } + dns, err := gcfg.Instance().Get(ctx, "dns", "") + if err != nil { + g.Log().Error(ctx, "获取配置失败", err) + return + } + if g.IsEmpty(dns) { + g.Log().Error(ctx, "gormDNS未配置", "请检查配置文件") + return + } + am.dns = dns.String() + am.db, err = gorm.Open(mysql.New(mysql.Config{ + DSN: dns.String(), + DefaultStringSize: 255, + }), &gorm.Config{}) + if err != nil { + g.Log().Error(ctx, "gorm连接数据库失败", err) + return + } +} +func SetAutoMigrate(models ...interface{}) { + New() + if g.IsNil(am.db) { + g.Log().Error(ctx, "数据库连接失败") + return + } + db = am.db.Set("gorm:table_options", "ENGINE=InnoDB") + err := db.AutoMigrate(models...) + if err != nil { + g.Log().Error(ctx, "数据库迁移失败", err) + } +} +func RenameColumn(dst interface{}, name, newName string) { + if am.db.Migrator().HasColumn(dst, name) { + err := am.db.Migrator().RenameColumn(dst, name, newName) + if err != nil { + g.Log().Error(am.ctx, "数据库修改字段失败", err) + } + } else { + g.Log().Info(am.ctx, "数据库字段不存在", name) + } +} + +// DropColumn +// 删除字段 +// 例:DropColumn(&User{}, "Sex") +func DropColumn(dst interface{}, name string) { + if am.db.Migrator().HasColumn(dst, name) { + err := am.db.Migrator().DropColumn(dst, name) + if err != nil { + g.Log().Error(am.ctx, "数据库删除字段失败", err) + } + } else { + g.Log().Info(am.ctx, "数据库字段不存在", name) + } +} diff --git a/baseGrpc/grpc.go b/baseGrpc/grpc.go new file mode 100644 index 0000000..918fb7c --- /dev/null +++ b/baseGrpc/grpc.go @@ -0,0 +1,102 @@ +package baseGrpc + +import ( + "context" + "time" + + v2 "git.magicany.cc/black1552/gf-common/utils" + "github.com/duke-git/lancet/v2/slice" + "github.com/gogf/gf/contrib/registry/etcd/v2" + "github.com/gogf/gf/contrib/rpc/grpcx/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gsvc" + "github.com/gogf/gf/v2/os/gctx" + "google.golang.org/grpc" +) + +var ( + name string + open bool +) + +type SGrpc struct { +} + +func New() *SGrpc { + name = v2.GenerateString(20) + config, _ := g.Config().Get(gctx.New(), "grpc.open", false) + open = config.Bool() + return &SGrpc{} +} + +func (s *SGrpc) clientTimeout(ctx context.Context, method string, req, reply interface{}, + cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption, +) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + err := invoker(ctx, method, req, reply, cc, opts...) + return err +} + +// InitServer 初始化服务 初始化后使用需要服务的grpc可以利用其进行注册 +func (s *SGrpc) InitServer() *grpcx.GrpcServer { + s.RegisterResolver(gctx.New()) + config := grpcx.Server.NewConfig() + config.Options = append(config.Options, []grpc.ServerOption{ + grpcx.Server.ChainUnary( + grpcx.Server.UnaryError, + )}..., + ) + config.Name = s.GetServerName() + return grpcx.Server.New(config) +} + +// Client 获取对应服务CONN 需要客户端使用它进行初始化 +func (s *SGrpc) Client(ctx context.Context, name string) *grpc.ClientConn { + servers, err := s.GetServers(ctx) + if err != nil { + g.Log().Errorf(ctx, "%+v", err) + return nil + } + _, ok := slice.FindBy(servers, func(index int, item gsvc.Service) bool { + return item.GetName() == name + }) + if !ok { + return nil + } + var conn = grpcx.Client.MustNewGrpcClientConn(name, grpcx.Client.ChainUnary( + s.clientTimeout, + )) + return conn +} + +// GetServers 获取所有服务 +func (s *SGrpc) GetServers(ctx context.Context, inputConfig ...gsvc.SearchInput) ([]gsvc.Service, error) { + input := gsvc.SearchInput{} + if len(inputConfig) > 0 { + input = inputConfig[0] + } + servers, err := gsvc.GetRegistry().Search(ctx, input) + if err != nil { + return nil, err + } + return servers, nil +} + +// RegisterResolver 注册服务发现 +func (s *SGrpc) RegisterResolver(ctx context.Context) { + etcdConfig, err := g.Config().Get(ctx, "etcd.host") + if err != nil { + panic(err) + } + grpcx.Resolver.Register(etcd.New(etcdConfig.String())) +} + +// GetServerName 获取服务名 +func (s *SGrpc) GetServerName() string { + return name +} + +func (s *SGrpc) IsOpen() bool { + return open +} diff --git a/excel/index.go b/excel/index.go new file mode 100644 index 0000000..6df8f78 --- /dev/null +++ b/excel/index.go @@ -0,0 +1,141 @@ +package excel + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/gogf/gf/v2/container/gmap" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gfile" + "github.com/xuri/excelize/v2" +) + +func CreateExcel(sheetName string, headers, cols []string, drops []DropdownCol, fileName string, centerCol []string, creatCol func(f *excelize.File, cols []string) *excelize.File) string { + f := excelize.NewFile() + defer func() { + if err := f.Close(); err != nil { + panic(fmt.Sprintf("关闭Excel文件错误:%s", err)) + } + }() + index, err := f.NewSheet(sheetName) + if err != nil { + panic(fmt.Sprintf("创建工作表错误:%s", err)) + } + f.SetActiveSheet(index) + _ = f.SetRowHeight(sheetName, 0, 150) + for i, v := range headers { + _ = f.SetCellValue("sheet1", cols[i]+"1", v) + } + f = creatCol(f, cols) + if !g.IsEmpty(drops) { + ExcelDownCol(f, drops) + } + if !g.IsEmpty(centerCol) { + for _, col := range centerCol { + _ = f.SetColStyle("sheet1", col, CenterCol(f)) + } + } + basePath := filepath.Join("excel", fileName) + fPath := filepath.Join(gfile.Pwd(), "resource", basePath) + if err := f.SaveAs(fPath); err != nil { + panic(fmt.Sprintf("导出房源Excel模板文件错误:%s", err)) + } + return "/uploads/" + basePath +} + +// ExcelDownCol 导出模板列下拉数据 +// 添加下拉框和注释 +// @param f *excelize.File +// @param dropdownCols []model.DropdownCol +// @return 返回文件 +func ExcelDownCol(f *excelize.File, dropdownCols []DropdownCol) *excelize.File { + // 为导入模板的列添加下拉数据 + for _, dropdown := range dropdownCols { + if dropdown.EndRow < dropdown.StartRow { + // 默认设置1000行下拉框 + dropdown.EndRow = dropdown.StartRow + 999 + } + + // 构建范围字符串 (如 "B2:B1000") + dvRange := fmt.Sprintf( + "%s%d:%s%d", + dropdown.Column, + dropdown.StartRow, + dropdown.Column, + dropdown.EndRow, + ) + + // 创建数据验证对象 + dv := excelize.NewDataValidation(true) // 允许空值 + dv.SetSqref(dvRange) // 设置应用范围 + + // 设置下拉列表选项 + if err := dv.SetDropList(dropdown.Options); err != nil { + fmt.Errorf("设置下拉选项失败: %w", err) + continue + } + + // 添加数据验证到工作表 + if err := f.AddDataValidation("sheet1", dv); err != nil { + fmt.Errorf("添加下拉框失败: %w", err) + continue + } + + // 添加注释提示(使用新版API) + commentCell := fmt.Sprintf("%s1", dropdown.Column) + if err := f.AddComment("sheet1", excelize.Comment{ + Cell: commentCell, + Author: "系统提示", + Paragraph: []excelize.RichTextRun{ + {Text: "请从下拉列表中选择:\n"}, + {Text: JoinOptions(dropdown.Options)}, + }, + }); err != nil { + log.Printf("添加注释失败: %v", err) + continue + } + } + return f +} + +// JoinOptions 将选项列表转换为逗号分隔的字符串(用于注释) +func JoinOptions(options []string) string { + result := "" + for i, opt := range options { + if i > 0 { + result += ", " + } + result += opt + } + return result +} + +// TransMap 闯入MAP 并根据isCn查询key 返回value +func TransMap(maps map[string]string, key string, isCn bool) string { + tagMap := gmap.NewStrStrMap() + tagMap.Sets(maps) + if !isCn { + tagMap.Flip() + } + val, ok := tagMap.Search(key) + if ok { + return val + } else { + return "" + } +} + +func CenterCol(f *excelize.File) int { + style, err := f.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + }, + }) + if err != nil { + fmt.Println("创建样式失败:", err) + return 0 + } + return style +} diff --git a/excel/model.go b/excel/model.go new file mode 100644 index 0000000..d71a195 --- /dev/null +++ b/excel/model.go @@ -0,0 +1,8 @@ +package excel + +type DropdownCol struct { + Column string // 需要下拉框的列标识(如B, C) + Options []string // 下拉选项列表 + StartRow int // 下拉框起始行(从1开始) + EndRow int // 下拉框结束行 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3f84f74 --- /dev/null +++ b/go.mod @@ -0,0 +1,73 @@ +module git.magicany.cc/black1552/gf-common + +go 1.24.3 + +require ( + github.com/duke-git/lancet/v2 v2.3.7 + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.3 + github.com/gogf/gf/contrib/registry/etcd/v2 v2.9.3 + github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.9.3 + github.com/gogf/gf/v2 v2.9.3 + github.com/gorilla/websocket v1.5.3 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/xuri/excelize/v2 v2.9.1 + google.golang.org/grpc v1.76.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.3.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.9.2 // indirect + github.com/gogf/gf/contrib/registry/file/v2 v2.9.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.0.9 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tiendc/go-deepcopy v1.6.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.1 // indirect + go.etcd.io/etcd/api/v3 v3.5.17 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect + go.etcd.io/etcd/client/v3 v3.5.17 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.21.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/exp v0.0.0-20221208152030-732eee02a75a // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff436d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,217 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HHHNs= +github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.3 h1:P4jrnp+Vmh3kDeaH/kyHPI6rfoMmQD+sPJa716aMbS0= +github.com/gogf/gf/contrib/drivers/mysql/v2 v2.9.3/go.mod h1:yEhfx78wgpxUJhH9C9bWJ7I3JLcVCzUg11A4ORYTKeg= +github.com/gogf/gf/contrib/registry/etcd/v2 v2.9.3 h1:4ztKAHfwtddPRwxlVRRfLdJMzp42Z+9K5tFHrPPZl1Q= +github.com/gogf/gf/contrib/registry/etcd/v2 v2.9.3/go.mod h1:ey99pcs/hSwShOLWjd4mUUmq4S9fLy7elf7SslUAs20= +github.com/gogf/gf/contrib/registry/file/v2 v2.9.3 h1:7f+55KmwJzW0Kmam+N3VrLMlMkCTc5LTPDaZeElCLd0= +github.com/gogf/gf/contrib/registry/file/v2 v2.9.3/go.mod h1:kOYsqBlbf6pElgfLyIEFN6FpXnSSdgkV4w+7+w/78do= +github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.9.3 h1:HoUu3a7t0iLI0/GbVP46rPHbrfBlKa6rfHsnCvry4+4= +github.com/gogf/gf/contrib/rpc/grpcx/v2 v2.9.3/go.mod h1:F5v+4GumRPIFZsAiz6H5QNoOiPM7cluMCvGF0zbzsGE= +github.com/gogf/gf/v2 v2.9.3 h1:qjN4s55FfUzxZ1AE8vUHNDX3V0eIOUGXhF2DjRTVZQ4= +github.com/gogf/gf/v2 v2.9.3/go.mod h1:w6rcfD13SmO7FKI80k9LSLiSMGqpMYp50Nfkrrc2sEE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= +github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo= +github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= +github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= +github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= +github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.1.27/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.17 h1:cQB8eb8bxwuxOilBpMJAEo8fAONyrdXTHUNcMd8yT1w= +go.etcd.io/etcd/api/v3 v3.5.17/go.mod h1:d1hvkRuXkts6PmaYk2Vrgqbv7H4ADfAKhyJqHNLJCB4= +go.etcd.io/etcd/client/pkg/v3 v3.5.17 h1:XxnDXAWq2pnxqx76ljWwiQ9jylbpC4rvkAeRVOUKKVw= +go.etcd.io/etcd/client/pkg/v3 v3.5.17/go.mod h1:4DqK1TKacp/86nJk4FLQqo6Mn2vvQFBmruW3pP14H/w= +go.etcd.io/etcd/client/v3 v3.5.17 h1:o48sINNeWz5+pjy/Z0+HKpj/xSnBkuVhVvXkjEXbqZY= +go.etcd.io/etcd/client/v3 v3.5.17/go.mod h1:j2d4eXTHWkT2ClBgnnEPm/Wuu7jsqku41v9DZ3OtjQo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20221208152030-732eee02a75a h1:4iLhBPcpqFmylhnkbY3W0ONLUYYkDAW9xMFLfxgsvCw= +golang.org/x/exp v0.0.0-20221208152030-732eee02a75a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-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-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/mqtt/client/mqtt.go b/mqtt/client/mqtt.go new file mode 100644 index 0000000..dbb23d4 --- /dev/null +++ b/mqtt/client/mqtt.go @@ -0,0 +1,220 @@ +package client + +import ( + "context" + "fmt" + "sync" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/gogf/gf/v2/os/glog" +) + +// Client MQTT客户端结构 +type Client struct { + client mqtt.Client + opts *mqtt.ClientOptions + ctx context.Context + subscribed map[string]byte + subMutex sync.RWMutex + callback mqtt.MessageHandler + // 错误处理相关 + onConnectionLost func(error) + onReconnect func() + onConnect func() + onSubscriptionError func(error) + onPublishError func(error) +} + +// NewClientWithAuth 创建带用户名密码认证的MQTT客户端 +func NewClientWithAuth(ctx context.Context, broker, clientId, username, password string) *Client { + c := &Client{ + ctx: ctx, + subscribed: make(map[string]byte), + } + + opts := mqtt.NewClientOptions() + opts.AddBroker(broker) + opts.SetClientID(clientId) + opts.SetUsername(username) + opts.SetPassword(password) + // 设置连接丢失处理函数 + opts.SetConnectionLostHandler(func(client mqtt.Client, err error) { + glog.Error(ctx, "MQTT连接断开:", err) + if c.onConnectionLost != nil { + c.onConnectionLost(err) + } + }) + + // 设置重连处理函数 + opts.SetReconnectingHandler(func(client mqtt.Client, opts *mqtt.ClientOptions) { + glog.Info(ctx, "MQTT客户端正在尝试重连...") + if c.onReconnect != nil { + c.onReconnect() + } + }) + + // 设置连接成功处理函数,重连成功后重新订阅主题 + opts.SetOnConnectHandler(func(client mqtt.Client) { + glog.Info(ctx, "MQTT客户端重新连接成功") + if c.onConnect != nil { + c.onConnect() + } + // 重连成功后重新订阅主题 + go c.resubscribe() + }) + + // 设置其他选项... + + mqttClient := mqtt.NewClient(opts) + c.client = mqttClient + c.opts = opts + return c +} + +// Connect 连接到MQTT服务器 +func (c *Client) Connect() error { + glog.Info(c.ctx, "开始连接到MQTT服务器...") + token := c.client.Connect() + glog.Info(c.ctx, "等待连接完成...") + + // 使用更长的超时时间,避免网络延迟导致连接失败 + if token.WaitTimeout(30 * time.Second) { + glog.Info(c.ctx, "连接操作完成") + if token.Error() != nil { + err := fmt.Errorf("连接到MQTT代理时发生错误: %w", token.Error()) + glog.Error(c.ctx, "连接MQTT服务器时发生错误:", token.Error()) + return err + } + } else { + // 连接超时 + err := fmt.Errorf("连接到MQTT服务器超时") + glog.Error(c.ctx, "连接MQTT服务器超时") + return err + } + glog.Info(c.ctx, "成功连接到MQTT服务器") + return nil +} + +// Disconnect 断开MQTT连接 +func (c *Client) Disconnect() { + if c.client.IsConnected() { + c.client.Disconnect(250) + glog.Info(c.ctx, "已断开MQTT连接") + } +} + +// SubscribeMultiple 同时订阅多个主题 +func (c *Client) SubscribeMultiple(topics map[string]byte, callback mqtt.MessageHandler) error { + // 保存订阅信息 + c.subMutex.Lock() + for topic, qos := range topics { + c.subscribed[topic] = qos + } + c.callback = callback + c.subMutex.Unlock() + + token := c.client.SubscribeMultiple(topics, callback) + // 增加订阅超时时间 + if token.WaitTimeout(30*time.Second) && token.Error() != nil { + err := fmt.Errorf("同时订阅多个主题出现错误: %w", token.Error()) + glog.Error(c.ctx, "订阅主题时发生错误:", token.Error()) + if c.onSubscriptionError != nil { + c.onSubscriptionError(err) + } + return err + } + + // 检查订阅是否成功 + if token.WaitTimeout(30 * time.Second) { + glog.Info(c.ctx, "成功订阅主题:", topics) + } else { + err := fmt.Errorf("订阅主题超时: %v", topics) + glog.Error(c.ctx, "订阅主题超时:", topics) + if c.onSubscriptionError != nil { + c.onSubscriptionError(err) + } + return err + } + + return nil +} + +// Publish 发布消息 +func (c *Client) Publish(topic string, qos byte, retained bool, payload interface{}) error { + token := c.client.Publish(topic, qos, retained, payload) + // 增加发布超时时间 + if token.WaitTimeout(30*time.Second) && token.Error() != nil { + err := fmt.Errorf("发送消息到主题%s出现错误: %w", topic, token.Error()) + glog.Error(c.ctx, "发布消息到主题", topic, "时发生错误:", token.Error()) + if c.onPublishError != nil { + c.onPublishError(err) + } + return err + } + + // 检查发布是否成功 + if token.WaitTimeout(30 * time.Second) { + glog.Info(c.ctx, "成功发布消息到主题:", topic) + } else { + err := fmt.Errorf("发布消息到主题%s超时", topic) + glog.Error(c.ctx, "发布消息到主题超时:", topic) + if c.onPublishError != nil { + c.onPublishError(err) + } + return err + } + + return nil +} + +// resubscribe 重新订阅主题 +func (c *Client) resubscribe() { + c.subMutex.RLock() + defer c.subMutex.RUnlock() + + if len(c.subscribed) == 0 { + glog.Info(c.ctx, "没有需要重新订阅的主题") + return + } + + // 复制订阅信息避免并发问题 + topics := make(map[string]byte) + for topic, qos := range c.subscribed { + topics[topic] = qos + } + + glog.Info(c.ctx, "开始重新订阅主题:", topics) + // 增加重新订阅的超时时间 + token := c.client.SubscribeMultiple(topics, c.callback) + if token.WaitTimeout(30 * time.Second) { + if token.Error() != nil { + err := fmt.Errorf("重新订阅主题时发生错误: %w", token.Error()) + glog.Error(c.ctx, "重新订阅主题时发生错误:", token.Error()) + if c.onSubscriptionError != nil { + c.onSubscriptionError(err) + } + } else { + glog.Info(c.ctx, "重新订阅主题成功") + } + } else { + err := fmt.Errorf("重新订阅主题超时") + glog.Error(c.ctx, "重新订阅主题超时") + if c.onSubscriptionError != nil { + c.onSubscriptionError(err) + } + } +} + +// IsConnected 检查是否连接 +func (c *Client) IsConnected() bool { + isConnected := c.client.IsConnected() + glog.Debug(c.ctx, "检查连接状态:", isConnected) + return isConnected +} + +// Subscribe 单独订阅一个主题 +func (c *Client) Subscribe(topic string, qos byte, callback mqtt.MessageHandler) error { + topics := map[string]byte{topic: qos} + return c.SubscribeMultiple(topics, callback) +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..3cdc0ab --- /dev/null +++ b/server/config.go @@ -0,0 +1,189 @@ +package server + +import ( + "fmt" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/encoding/gyaml" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" +) + +type Config struct { + Server ServiceConfig `yaml:"server"` + Database *DatabaseConfig `yaml:"database"` + SkipUrl string `yaml:"skipUrl"` + OpenAPITitle string `yaml:"openAPITitle"` + OpenAPIDescription string `yaml:"openAPIDescription"` + OpenAPIUrl string `yaml:"openAPIUrl"` + OpenAPIName string `yaml:"openAPIName"` + DoMain []string `yaml:"doMain"` + OpenAPIVersion string `yaml:"openAPIVersion"` + Logger LoggerConfig `yaml:"logger"` + Dns string `yaml:"dns"` +} + +type ServiceConfig struct { + Default ServiceDefault `yaml:"default"` +} + +type ServiceDefault struct { + Address string `yaml:"address"` + LogPath string `yaml:"logPath"` + LogStdout bool `yaml:"logStdout"` + ErrorStack bool `yaml:"errorStack"` + ErrorLogEnabled bool `yaml:"errorLogEnabled"` + ErrorLogPattern string `yaml:"errorLogPattern"` + AccessLogEnable bool `yaml:"accessLogEnable"` + AccessLogPattern string `yaml:"accessLogPattern"` + FileServerEnabled bool `yaml:"fileServerEnabled"` +} + +type DatabaseConfig struct { + Default DatabaseDefault `yaml:"default"` +} + +type DatabaseDefault struct { + Host string `yaml:"host" json:"host"` + Link string `yaml:"link" dc:"数据库连接字符串" json:"link"` + Port string `yaml:"port" json:"port"` + User string `yaml:"user" json:"user"` + Pass string `yaml:"pass" json:"pass"` + Name string `yaml:"name" json:"name"` + Type string `yaml:"type" json:"type"` + Timezone string `yaml:"timezone" json:"timezone"` + Debug bool `yaml:"debug" json:"debug"` + Charset string `yaml:"charset" json:"charset"` + CreatedAt string `yaml:"createdAt" json:"createdAt"` + UpdatedAt string `yaml:"updatedAt" json:"updatedAt"` +} + +type LoggerConfig struct { + Path string `yaml:"path" json:"path"` + File string `yaml:"file" json:"file"` + Level string `yaml:"level" json:"level"` + TimeFormat string `yaml:"timeFormat" json:"timeFormat"` + CtxKeys []string `yaml:"ctxKeys" json:"ctxKeys"` + Header bool `yaml:"header" json:"header"` + Stdout bool `yaml:"stdout" json:"stdout"` + RotateSize string `yaml:"rotateSize" json:"rotateSize"` + RotateBackupLimit int `yaml:"rotateBackupLimit" json:"rotateBackupLimit"` + StdoutColorDisabled bool `yaml:"stdoutColorDisabled" json:"stdoutColorDisabled"` + WriterColorEnable bool `yaml:"writerColorEnable" json:"writerColorEnable"` +} + +var DefaultConfig = Config{ + Server: ServiceConfig{ + Default: ServiceDefault{ + Address: ":8080", + LogPath: "./log/", + LogStdout: true, + ErrorStack: true, + ErrorLogEnabled: true, + ErrorLogPattern: "error-{Ymd}.log", + AccessLogEnable: false, + FileServerEnabled: true, + }, + }, + OpenAPITitle: "", + OpenAPIDescription: "Api列表 包含各端接口信息 字段注释 枚举说明", + OpenAPIUrl: "https://panel.magicany.cc:8888/btpanel", + OpenAPIName: "", + DoMain: []string{"localhost", "127.0.0.1"}, + OpenAPIVersion: "v1.0", + Dns: "root:123456@tcp(127.0.0.1:3306)/test", + Logger: LoggerConfig{ + Path: "./log/", + File: "access-{Ymd}.log", + Level: "all", + TimeFormat: "2006-01-02 15:04:05", + CtxKeys: []string{}, + Header: true, + Stdout: true, + RotateSize: "1M", + RotateBackupLimit: 10, + }, +} + +func DefaultConfigInit() { + database := &DatabaseConfig{Default: DatabaseDefault{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Pass: "123456", + Name: "database", + Type: "mysql", + Timezone: "Local", + Debug: true, + Charset: "utf8", + CreatedAt: "create_time", + UpdatedAt: "update_time", + }} + DefaultConfig.Database = database + yaml, err := gyaml.Encode(DefaultConfig) + if err != nil { + g.Log().Error(gctx.New(), "转换yaml失败", err) + } + + if !gfile.IsDir(uploadPath) { + _ = gfile.Mkdir(uploadPath) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "template")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "scripts")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "html")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "css")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "image")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "js")) + } + g.Log().Info(gctx.New(), "正在检查配置文件", gfile.IsFile(ConfigPath)) + if !gfile.IsFile(ConfigPath) { + g.Log().Info(gctx.New(), "正在创建配置文件", ConfigPath) + _, _ = gfile.Create(ConfigPath) + g.Log().Info(gctx.New(), "正在写入配置文件", ConfigPath) + _ = gfile.PutContents(ConfigPath, gconv.String(yaml)) + g.Log().Info(gctx.New(), "配置文件创建成功!") + } else { + gcfg.Instance().GetAdapter().(*gcfg.AdapterFile).SetFileName(ConfigPath) + } +} + +// DefaultSqliteConfigInit 创建默认的sqlite数据库配置 不会再生成配置文件 +// @param path sqlite数据库路径 +// @param autoTime 自动时间字段[]string{"create_time","update_time"} +// @param debug 数据库调试模式 +// @param prefix 表前缀可空 +func DefaultSqliteConfigInit(path string, autoTime []string, debug bool, prefix ...string) { + glog.Info(gctx.New(), "正在检查文件夹", gfile.IsFile(uploadPath)) + if !gfile.IsDir(uploadPath) { + _ = gfile.Mkdir(uploadPath) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "template")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "scripts")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "html")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "css")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "image")) + _ = gfile.Mkdir(gfile.Join(gfile.Pwd(), "resource", "public", "resource", "js")) + } + g.Log().Info(gctx.New(), "正在设置数据库配置") + node := gdb.ConfigNode{ + Link: fmt.Sprintf("sqlite::@file(%s)", path), + Timezone: "Local", + Charset: "utf8", + CreatedAt: autoTime[0], + UpdatedAt: autoTime[1], + Debug: debug, + } + if len(prefix) > 0 { + node.Prefix = prefix[0] + } + err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{ + node, + }}) + if err != nil { + g.Log().Error(gctx.New(), "设置数据库配置失败", err) + } + g.Log().Info(gctx.New(), "设置数据库配置成功") +} diff --git a/server/v2.go b/server/v2.go new file mode 100644 index 0000000..1578835 --- /dev/null +++ b/server/v2.go @@ -0,0 +1,401 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/os/glog" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/net/goai" + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" +) + +type Json struct { + Code int `json:"code" d:"1"` + Data any `json:"data"` + Msg string `json:"msg" d:"操作成功"` +} + +type ApiRes struct { + ctx context.Context + json *Json +} + +func Success(ctx context.Context) *ApiRes { + json := Json{ + Code: 1, + } + + var a = ApiRes{ + ctx: ctx, + json: &json, + } + return &a +} + +func Error(ctx context.Context) *ApiRes { + json := Json{ + Code: 0, + } + + var a = ApiRes{ + ctx: ctx, + json: &json, + } + return &a +} + +func (a *ApiRes) SetCode(code int) *ApiRes { + a.json.Code = code + return a +} + +func (a *ApiRes) SetData(data interface{}) *ApiRes { + a.json.Data = data + return a +} + +func (a *ApiRes) SetMsg(msg string) *ApiRes { + a.json.Msg = msg + return a +} + +func (a *ApiRes) End() { + from := g.RequestFromCtx(a.ctx) + from.Header.Set("Access-Control-Expose-Headers", "Set-Cookie") + from.Response.Status = 200 + from.Response.WriteJson(a.json) + return +} + +func (a *ApiRes) FileDownload(path, name string) { + from := g.RequestFromCtx(a.ctx) + from.Response.ServeFileDownload(path) + return +} + +func (a *ApiRes) FileSelect(path string) { + from := g.RequestFromCtx(a.ctx) + from.Response.ServeFile(path) + return +} + +// LoginJson 返回登录json数据 +/* + * @param ctx 上下文 + * @param msg 返回信息 + * @param data 返回数据 + */ +func LoginJson(r *ghttp.Request, msg string, data ...interface{}) { + var info interface{} + if len(data) > 0 { + info = data[0] + } else { + info = nil + } + r.Response.WriteJsonExit(Json{ + Code: 1, + Data: info, + Msg: msg, + }) +} + +// ResponseJson 返回json数据 +/* + * @param ctx 上下文 + * @param data 返回数据 + */ +func ResponseJson(ctx context.Context, data interface{}) { + g.RequestFromCtx(ctx).Response.WriteJson(data) + return +} + +type PageSize struct { + CurrentPage int `json:"currentPage"` + Data interface{} `json:"data"` + LastPage int `json:"lastPage"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// SetPage 设置分页 +/* + * @param page 当前页 + * @param limit 每页显示条数 + * @param total 总条数 + * @param data 返回数据 + * @return PageSize + */ +func SetPage(page, limit, total int, data interface{}) *PageSize { + var size = new(PageSize) + if page == 1 { + size.LastPage = 1 + } else { + size.LastPage = page - 1 + } + size.PerPage = limit + size.Total = total + size.CurrentPage = page + size.Data = data + return size +} + +// MiddlewareError 异常处理中间件 +func MiddlewareError(r *ghttp.Request) { + r.Middleware.Next() + var ( + err = r.GetError() + msg string + res = r.GetHandlerResponse() + status = r.Response.Status + ) + json := new(Json) + json.Code = 1 + json.Data = res + json.Msg = "操作成功" + if err != nil { + bo := gstr.Contains(err.Error(), ": ") + if bo { + msg = gstr.SubStrFromEx(err.Error(), ": ") + } else { + msg = err.Error() + } + r.Response.ClearBuffer() + json.Code = 0 + json.Msg = msg + r.Response.Status = http.StatusInternalServerError + } + if r.Response.BufferLength() > 0 { + return + } + if status == 401 { + json.Code = 0 + json.Msg = "请登录后操作" + } + r.Response.WriteJson(json) +} + +// AuthBase 鉴权中间件,只有前端或者后端登录成功之后才能通过 +func AuthBase(r *ghttp.Request, name string) { + info, err := r.Session.Get(name, nil) + if err != nil { + panic(err.Error()) + } + if !info.IsEmpty() { + r.Middleware.Next() + } else { + NoLogin(r) + } +} + +// AuthAdmin 鉴权中间件,只有后端登录成功之后才能通过 +func AuthAdmin(r *ghttp.Request) { + AuthBase(r, "admin") +} + +// AuthIndex 鉴权中间件,只有前端登录成功之后才能通过 +func AuthIndex(r *ghttp.Request) { + AuthBase(r, "user") +} + +// NoLogin 未登录返回 +func NoLogin(r *ghttp.Request) { + r.Response.Status = 401 + r.Response.WriteJsonExit(Json{ + Code: 401, + Data: nil, + Msg: "请登录后操作", + }) +} + +// CreateFileDir 创建文件目录 +func CreateFileDir() error { + path := gfile.Pwd() + "/resource" + if !gfile.IsDir(path) { + if err := gfile.Mkdir(path); err != nil { + return err + } + if err := gfile.Mkdir(path + "/public/upload"); err != nil { + return err + } + } + return nil +} + +func AuthLoginSession(ctx context.Context, sessionKey string) { + ti, err := g.RequestFromCtx(ctx).Session.Get(sessionKey+"LoginTime", "") + if err != nil { + panic(err.Error()) + } + if !ti.IsEmpty() { + now := gtime.Now().Timestamp() + if now-gconv.Int64(ti) <= 300 { + number, err := g.RequestFromCtx(ctx).Session.Get(sessionKey+"LoginNum", 0) + if err != nil { + panic(err.Error()) + } + if !number.IsEmpty() { + count := gconv.Int(number) + if count == 3 { + panic("请等待5分钟后再次尝试或修改后尝试登录") + } + } + } + } +} + +func LoginCountSession(ctx context.Context, sessionKey string) { + ti, err := g.RequestFromCtx(ctx).Session.Get(sessionKey+"LoginTime", "") + if err != nil { + panic(err.Error()) + } + if ti.IsEmpty() { + _ = g.RequestFromCtx(ctx).Session.Set(sessionKey+"LoginTime", gtime.Now().Timestamp()) + } + now := gtime.Now().Timestamp() + if now-gconv.Int64(ti) <= 300 { + number, err := g.RequestFromCtx(ctx).Session.Get(sessionKey+"LoginNum", 0) + if err != nil { + panic(err.Error()) + } + if number.IsEmpty() { + _ = g.RequestFromCtx(ctx).Session.Set(sessionKey+"LoginNum", 1) + } else { + count := gconv.Int(number) + if count == 3 { + panic("尝试登录已超过限制,请等待5分钟后再次尝试或修改后尝试登录") + } + _ = g.RequestFromCtx(ctx).Session.Set(sessionKey+"LoginNum", count+1) + } + } else { + _ = g.RequestFromCtx(ctx).Session.Set(sessionKey+"LoginTime", gtime.Now().Timestamp()) + _ = g.RequestFromCtx(ctx).Session.Set(sessionKey+"LoginNum", 1) + } +} + +func enhanceOpenAPIDoc(s *ghttp.Server) { + openapi := s.GetOpenApi() + openapi.Config.CommonResponse = ghttp.DefaultHandlerResponse{} + openapi.Config.CommonResponseDataField = `Data` + + // API description. + openapi.Info = goai.Info{ + Title: "Api列表", + Description: "Api列表 包含各端接口信息 字段注释 枚举说明", + Contact: &goai.Contact{ + Name: "Api列表", + URL: "https://panel.magicany.cc:8888/btpanel", + }, + License: &goai.License{ + Name: "马国栋", + URL: "https://panel.magicany.cc:8888/btpanel", + }, + Version: "Api列表", + } +} + +var ConfigPath = filepath.Join(gfile.Pwd(), "manifest", "config", "config.yaml") +var uploadPath = filepath.Join(gfile.Pwd(), "resource") + +// Start 启动服务 +/* + * @param agent string 浏览器标识 + * @param maxSessionTime time.Duration session最大时间 + * @param isApi bool 是否开启api + * @param maxBody ...int64 最大上传文件大小 默认200M + * @return *ghttp.Server 服务实例 + */ +func Start(agent string, maxSessionTime time.Duration, isApi bool, maxBody ...int64) *ghttp.Server { + // var s *ghttp.Server + s := g.Server() + s.SetDumpRouterMap(false) + s.AddStaticPath(fmt.Sprintf("%vstatic", gfile.Separator), uploadPath) + err := s.SetLogPath(gfile.Join(gfile.Pwd(), "resource", "log")) + if err != nil { + fmt.Println(err) + } + s.SetLogLevel("all") + s.SetLogStdout(false) + if len(maxBody) > 0 { + s.SetClientMaxBodySize(maxBody[0]) + } else { + s.SetClientMaxBodySize(200 * 1024 * 1024) + } + s.SetFormParsingMemory(50 * 1024 * 1024) + if isApi { + s.SetOpenApiPath("/api.json") + s.SetSwaggerPath("/swagger") + } + s.SetMaxHeaderBytes(1024 * 20) + s.SetErrorStack(true) + s.SetSessionIdName("zrSession") + s.SetAccessLogEnabled(true) + s.SetSessionMaxAge(maxSessionTime) + err = s.SetConfigWithMap(g.Map{ + "sessionPath": gfile.Join(gfile.Pwd(), "resource", "session"), + "serverAgent": agent, + }) + if err != nil { + fmt.Println(err) + } + s.Use(MiddlewareError) + enhanceOpenAPIDoc(s) + return s +} + +// SetConfigAndRun 设置配置并运行服务 +// @param s *ghttp.Server 服务实例 +// @param address string 监听地址 +func SetConfigAndRun(s *ghttp.Server, address string) { + g.Log().Info(gctx.New(), "正在设置日志配置") + g.Log().File("{Y-m-d}.log") + g.Log().Path(gfile.Join(gfile.Pwd(), "log")) + g.Log().Level(glog.LEVEL_ALL) + g.Log().SetWriterColorEnable(false) + g.Log().SetTimeFormat("2006-01-02 15:04:05") + g.Log().Stdout(false) + cfg := g.Log().GetConfig() + cfg.RotateBackupLimit = 10 + cfg.RotateSize = 1024 * 1024 * 2 + err := g.Log().SetConfig(cfg) + if err != nil { + panic(fmt.Sprintf("设置日志配置失败: %+v", err)) + } + s.SetAccessLogEnabled(false) + s.SetErrorLogEnabled(true) + sLog := s.Logger() + sLog.Level(glog.LEVEL_ERRO) + err = sLog.SetPath(gfile.Join(gfile.Pwd(), "resource", "log")) + if err != nil { + panic(fmt.Sprintf("添加服务日志路径失败: %+v", err)) + } + sLog.SetLevelPrefix(glog.LEVEL_ERRO, "error") + s.SetLogger(sLog) + g.Log().Info(gctx.New(), "设置日志配置完成") + g.Log().Info(gctx.New(), "正在设置服务监听") + s.SetAddr(address) + s.SetFileServerEnabled(true) + s.SetCookieDomain(fmt.Sprintf("http://%s", address)) + g.Log().Info(gctx.New(), "设置服务监听完成,执行自动服务") + s.Run() +} + +func CORSMiddleware(r *ghttp.Request) { + corsOptions := r.Response.DefaultCORSOptions() + cfg, _ := gcfg.Instance().Get(r.Context(), "doMain", nil) + if !cfg.IsNil() { + corsOptions.AllowDomain = cfg.Strings() + } + r.Response.CORS(corsOptions) + r.Middleware.Next() +} diff --git a/server/ws/example.go b/server/ws/example.go new file mode 100644 index 0000000..1420de9 --- /dev/null +++ b/server/ws/example.go @@ -0,0 +1,73 @@ +package ws + +import ( + "log" + "net/http" + "time" + + "github.com/gogf/gf/v2/util/gconv" +) + +var manager = NewWs() + +func NewWs() *Manager { + // 1. 自定义配置(可选,也可使用默认配置) + customConfig := &Config{ + AllowAllOrigins: true, + HeartbeatInterval: 20 * time.Second, // 20秒发一次心跳 + HeartbeatTimeout: 40 * time.Second, // 40秒超时 + } + + // 2. 创建管理器 + m := NewManager(customConfig) + + // 3. 覆盖业务回调(核心:自定义消息处理逻辑) + // 连接建立回调 + m.OnConnect = func(connID string) { + log.Printf("业务回调:连接[%s]上线,当前在线数:%d", connID, m.GetOnlineCount()) + // 欢迎消息 + _ = m.SendToConn(connID, []byte("欢迎连接WebSocket服务!")) + } + + // 收到消息回调 + m.OnMessage = func(connID string, msgType int, data any) { + log.Printf("业务回调:收到连接[%s]消息:%s", connID, gconv.String(data)) + // 示例:echo回复 + reply := []byte("服务端回复:" + gconv.String(data)) + _ = m.SendToConn(connID, reply) + + // 示例:广播消息给所有连接 + _ = m.Broadcast([]byte("广播:" + connID + "说:" + gconv.String(data))) + } + + // 连接断开回调 + m.OnDisconnect = func(connID string, err error) { + log.Printf("业务回调:连接[%s]下线,原因:%v,当前在线数:%d", connID, err, m.GetOnlineCount()) + } + return m +} +func Upgrade(w http.ResponseWriter, r *http.Request, connID string) { + _, err := manager.Upgrade(w, r, connID) + if err != nil { + log.Printf("升级连接失败:%v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} +func main() { + // 4. 注册WebSocket路由 + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + // 自定义连接ID(示例:使用请求参数中的user_id) + connID := r.URL.Query().Get("user_id") + if connID == "" { + http.Error(w, "user_id不能为空", http.StatusBadRequest) + return + } + // 升级连接 + Upgrade(w, r, connID) + }) + + // 5. 启动服务 + log.Println("WebSocket服务启动:http://localhost:8080/ws") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/server/ws/websocket.go b/server/ws/websocket.go new file mode 100644 index 0000000..e3d9b2f --- /dev/null +++ b/server/ws/websocket.go @@ -0,0 +1,488 @@ +package ws + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/os/gtimer" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gorilla/websocket" +) + +// 常量定义:默认配置 +const ( + // 默认读写缓冲区大小(字节) + DefaultReadBufferSize = 1024 + DefaultWriteBufferSize = 1024 + // 默认心跳间隔(秒):每30秒发送一次心跳 + DefaultHeartbeatInterval = 30 * time.Second + // 默认心跳超时(秒):60秒未收到客户端心跳响应则关闭连接 + DefaultHeartbeatTimeout = 60 * time.Second + // 默认读写超时(秒) + DefaultReadTimeout = 60 * time.Second + DefaultWriteTimeout = 10 * time.Second + // 消息类型 + MessageTypeText = websocket.TextMessage + MessageTypeBinary = websocket.BinaryMessage + // 心跳最大重试次数 + HeartbeatMaxRetry = 3 +) + +// Config WebSocket服务端配置 +type Config struct { + // 读写缓冲区大小 + ReadBufferSize int + WriteBufferSize int + // 跨域配置:是否允许所有跨域(生产环境建议指定Origin) + AllowAllOrigins bool + // 允许的跨域Origin列表(AllowAllOrigins=false时生效) + AllowedOrigins []string + // 心跳配置 + HeartbeatInterval time.Duration // 心跳发送间隔 + HeartbeatTimeout time.Duration // 心跳超时时间 + // 读写超时 + ReadTimeout time.Duration + WriteTimeout time.Duration + MsgType int // 发送消息的默认类型 + HeartbeatValue string // 心跳消息的标识字段值(如"heartbeat"、"pong") + HeartbeatKey string // 心跳消息的标识字段名(如"type") +} + +// 默认配置 +func DefaultConfig() *Config { + return &Config{ + ReadBufferSize: DefaultReadBufferSize, + WriteBufferSize: DefaultWriteBufferSize, + AllowAllOrigins: true, + AllowedOrigins: []string{}, + HeartbeatInterval: DefaultHeartbeatInterval, + HeartbeatTimeout: DefaultHeartbeatTimeout, + ReadTimeout: DefaultReadTimeout, + WriteTimeout: DefaultWriteTimeout, + MsgType: MessageTypeText, + HeartbeatValue: "heartbeat", + HeartbeatKey: "type", // 心跳消息的标识字段名,默认"type" + } +} + +// Connection WebSocket连接结构体 +type Connection struct { + conn *websocket.Conn // 底层连接 + connID string // 唯一连接ID + manager *Manager // 所属管理器 + createTime time.Time // 连接创建时间 + heartbeatChan time.Time // 心跳通道(用于检测客户端响应) + heartbeatTime *gtimer.Entry + ctx context.Context // 上下文 + cancel context.CancelFunc // 上下文取消函数 + writeMutex sync.Mutex // 写消息互斥锁(防止并发写) + heartbeatRetry int // 心跳发送重试次数 +} + +// Manager WebSocket连接管理器 +type Manager struct { + config *Config // 配置 + upgrader *websocket.Upgrader // HTTP升级器 + connections map[string]*Connection // 所有在线连接(connID -> Connection) + mutex sync.RWMutex // 读写锁(保护connections) + // 业务回调:收到消息时触发(用户自定义处理逻辑) + OnMessage func(connID string, msgType int, data any) + // 业务回调:连接建立时触发 + OnConnect func(connID string) + // 业务回调:连接关闭时触发 + OnDisconnect func(connID string, err error) +} + +// Merge 合并配置,用传入的配置覆盖非零值部分 +func (c *Config) Merge(other *Config) *Config { + result := *c // 复制当前配置 + + if other == nil { + return &result + } + + if other.ReadBufferSize > 0 { + result.ReadBufferSize = other.ReadBufferSize + } + if other.WriteBufferSize > 0 { + result.WriteBufferSize = other.WriteBufferSize + } + if other.HeartbeatInterval > 0 { + result.HeartbeatInterval = other.HeartbeatInterval + } + if other.HeartbeatTimeout > 0 { + result.HeartbeatTimeout = other.HeartbeatTimeout + } + if other.ReadTimeout > 0 { + result.ReadTimeout = other.ReadTimeout + } + if other.WriteTimeout > 0 { + result.WriteTimeout = other.WriteTimeout + } + if other.AllowAllOrigins { + result.AllowAllOrigins = other.AllowAllOrigins + } + if other.HeartbeatValue != "" { + result.HeartbeatValue = other.HeartbeatValue + } + if other.HeartbeatKey != "" { + result.HeartbeatKey = other.HeartbeatKey + } + if len(other.AllowedOrigins) > 0 { + result.AllowedOrigins = other.AllowedOrigins + } + if other.MsgType != 0 { + result.MsgType = other.MsgType + } + + return &result +} + +// NewManager 创建连接管理器 +func NewManager(config *Config) *Manager { + defaultConfig := DefaultConfig() + finalConfig := defaultConfig.Merge(config) + // 初始化升级器 + upgrader := &websocket.Upgrader{ + ReadBufferSize: config.ReadBufferSize, + WriteBufferSize: config.WriteBufferSize, + CheckOrigin: func(r *http.Request) bool { + // 跨域检查 + if config.AllowAllOrigins { + return true + } + origin := r.Header.Get("Origin") + for _, allowed := range finalConfig.AllowedOrigins { + if origin == allowed { + return true + } + } + return false + }, + } + + return &Manager{ + config: finalConfig, + upgrader: upgrader, + connections: make(map[string]*Connection), + mutex: sync.RWMutex{}, + // 默认回调(用户可覆盖) + OnMessage: func(connID string, msgType int, data any) { + log.Printf("[默认回调] 收到连接[%s]消息:%s", connID, gconv.String(data)) + }, + OnConnect: func(connID string) { + log.Printf("[默认回调] 连接[%s]已建立", connID) + }, + OnDisconnect: func(connID string, err error) { + log.Printf("[默认回调] 连接[%s]已关闭:%v", connID, err) + }, + } +} + +// Upgrade HTTP升级为WebSocket连接 +// connID:自定义连接唯一ID(如用户ID、设备ID) +func (m *Manager) Upgrade(w http.ResponseWriter, r *http.Request, connID string) (*Connection, error) { + if connID == "" { + return nil, errors.New("连接ID不能为空") + } + + // 检查连接ID是否已存在 + m.mutex.RLock() + _, exists := m.connections[connID] + m.mutex.RUnlock() + if exists { + return nil, fmt.Errorf("连接ID[%s]已存在", connID) + } + + // 升级HTTP连接 + conn, err := m.upgrader.Upgrade(w, r, nil) + if err != nil { + return nil, fmt.Errorf("升级WebSocket失败:%w", err) + } + + // 创建上下文(用于优雅关闭) + ctx, cancel := context.WithCancel(context.Background()) + + // 创建连接实例 + wsConn := &Connection{ + conn: conn, + connID: connID, + manager: m, + createTime: time.Now(), + heartbeatChan: time.Now(), // 缓冲1,防止阻塞 + ctx: ctx, + cancel: cancel, + writeMutex: sync.Mutex{}, + heartbeatRetry: 0, + } + wsConn.heartbeatTime = gtimer.AddSingleton(gctx.New(), m.config.HeartbeatTimeout, func(ctx context.Context) { + log.Printf("[心跳检测] 连接[%s]已关闭:心跳超时", wsConn.connID) + wsConn.heartbeatTime.Close() + wsConn.heartbeatTime.Stop() + wsConn.heartbeatTime = nil + wsConn.ctx.Done() + wsConn.Close(fmt.Errorf("心跳超时")) + }) + // 添加到管理器 + m.mutex.Lock() + m.connections[connID] = wsConn + m.mutex.Unlock() + + // 触发连接建立回调 + m.OnConnect(connID) + + // 启动读消息协程 + go wsConn.ReadPump() + // 启动写消息协程(处理异步发送) + go wsConn.WritePump() + // 启动心跳检测协程 + go wsConn.Heartbeat() + + return wsConn, nil +} + +// ReadPump 读取客户端消息(持续运行) +func (c *Connection) ReadPump() { + defer func() { + // 发生panic时关闭连接 + if err := recover(); err != nil { + log.Printf("连接[%s]读消息协程panic:%v", c.connID, err) + } + // 关闭连接并清理 + c.Close(fmt.Errorf("读消息协程退出")) + }() + + // 循环读取消息 + for { + select { + case <-c.ctx.Done(): + return // 上下文已取消,退出 + default: + // 设置读超时(每次读取前重置,防止长时间无消息超时) + c.conn.SetReadDeadline(time.Now().Add(c.manager.config.ReadTimeout)) + // 读取客户端消息 + msgType, data, err := c.conn.ReadMessage() + if err != nil { + // 区分正常关闭和异常错误 + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + c.Close(fmt.Errorf("客户端主动关闭:%s(代码:%d)", closeErr.Text, closeErr.Code)) + } else { + c.Close(fmt.Errorf("读取消息失败:%w", err)) + } + return + } + + // 尝试解析JSON格式的心跳消息(精准判断,替代包含判断) + isHeartbeat := false + // 先尝试解析为JSON对象 + var msgMap map[string]interface{} + if err := gjson.DecodeTo(data, &msgMap); err == nil { + // 获取心跳标识字段的值 + heartbeatValue := gconv.String(msgMap[c.manager.config.HeartbeatKey]) + if heartbeatValue == c.manager.config.HeartbeatValue { + isHeartbeat = true + } + } else { + // 非JSON格式,降级为包含判断(兼容纯文本心跳) + str := gconv.String(data) + if gstr.Contains(str, c.manager.config.HeartbeatValue) { + isHeartbeat = true + } + } + if isHeartbeat { + log.Printf("[心跳] 收到连接[%s]心跳消息:%s", c.connID, string(data)) + // 心跳消息:重置重试次数 + 发送心跳信号 + 重置读超时 + js, err := gjson.Encode(&Msg[any]{c.manager.config.HeartbeatValue, nil, gtime.Timestamp()}) + if err != nil { + log.Printf("[心跳] 客户端[%s]json编码失败", c.connID) + continue + } + err = c.Send(js) + if err != nil { + log.Printf("[心跳] 客户端[%s]发送心跳消息失败", c.connID) + continue + } + c.heartbeatTime.Reset() + continue // 跳过业务回调 + } + + // 非心跳消息:触发业务回调 + c.manager.OnMessage(c.connID, msgType, data) + } + } +} + +type Msg[T any] struct { + Type string `json:"type"` + Data T `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +// WritePump 处理异步写消息(持续运行) +// 扩展为监听写队列,防止消息丢失 +func (c *Connection) WritePump() { + defer func() { + if err := recover(); err != nil { + log.Printf("连接[%s]写消息协程panic:%v", c.connID, err) + } + }() + + // 暂时保持简化,实际可扩展为带缓冲的写队列 + <-c.ctx.Done() +} + +// Heartbeat 心跳检测(持续运行) +func (c *Connection) Heartbeat() { + defer func() { + if err := recover(); err != nil { + log.Printf("连接[%s]心跳协程panic:%v", c.connID, err) + } + }() + c.heartbeatTime.Start() +} + +// Send 发送消息到客户端(线程安全) +func (c *Connection) Send(data []byte) error { + select { + case <-c.ctx.Done(): + return errors.New("连接已关闭,无法发送消息") + default: + // 加锁防止并发写 + c.writeMutex.Lock() + defer c.writeMutex.Unlock() + + // 设置写超时 + c.conn.SetWriteDeadline(time.Now().Add(c.manager.config.WriteTimeout)) + + // 发送消息(使用连接的默认类型,支持动态调整) + err := c.conn.WriteMessage(c.manager.config.MsgType, data) + if err != nil { + return fmt.Errorf("发送消息失败:%w", err) + } + return nil + } +} + +// Close 关闭连接(优雅清理) +func (c *Connection) Close(err error) { + // 防止重复关闭 + select { + case <-c.ctx.Done(): + return + default: + } + + // 取消上下文(终止所有协程) + c.cancel() + + // 关闭底层连接(友好关闭) + _ = c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error())) + _ = c.conn.Close() + + // 从管理器移除 + c.manager.mutex.Lock() + delete(c.manager.connections, c.connID) + c.manager.mutex.Unlock() + + // 触发断开回调 + c.manager.OnDisconnect(c.connID, err) + + log.Printf("连接[%s]已关闭,当前在线数:%d,原因:%v", c.connID, c.manager.GetOnlineCount(), err) +} + +// GetOnlineCount 获取在线连接数 +func (m *Manager) GetOnlineCount() int { + m.mutex.RLock() + defer m.mutex.RUnlock() + return len(m.connections) +} + +// Broadcast 广播消息到所有在线连接 +func (m *Manager) Broadcast(data []byte) error { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(m.connections) == 0 { + return errors.New("无在线连接") + } + + // 并发发送(非阻塞) + var wg sync.WaitGroup + var errMsg string + + for _, conn := range m.connections { + wg.Add(1) + go func(c *Connection) { + defer wg.Done() + if err := c.Send(data); err != nil { + errMsg += fmt.Sprintf("连接[%s]广播失败:%v;", c.connID, err) + } + }(conn) + } + + wg.Wait() + + if errMsg != "" { + return errors.New(errMsg) + } + return nil +} + +// SendToConn 定向发送消息到指定连接 +func (m *Manager) SendToConn(connID string, data []byte) error { + m.mutex.RLock() + conn, exists := m.connections[connID] + m.mutex.RUnlock() + + if !exists { + return fmt.Errorf("连接[%s]不存在", connID) + } + + return conn.Send(data) +} + +func (m *Manager) GetAllConn() map[string]*Connection { + m.mutex.RLock() + defer m.mutex.RUnlock() + // 返回副本,防止外部修改 + connCopy := make(map[string]*Connection, len(m.connections)) + for k, v := range m.connections { + connCopy[k] = v + } + return connCopy +} + +func (m *Manager) GetConn(connID string) *Connection { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.connections[connID] +} + +// CloseAll 关闭所有连接 +func (m *Manager) CloseAll() { + m.mutex.RLock() + connIDs := make([]string, 0, len(m.connections)) + for connID := range m.connections { + connIDs = append(connIDs, connID) + } + m.mutex.RUnlock() + + for _, connID := range connIDs { + m.mutex.RLock() + conn := m.connections[connID] + m.mutex.RUnlock() + if conn != nil { + conn.Close(errors.New("服务端主动关闭所有连接")) + } + } +} diff --git a/task/consts.go b/task/consts.go new file mode 100644 index 0000000..7d81237 --- /dev/null +++ b/task/consts.go @@ -0,0 +1,10 @@ +package task + +// TaskStatus 任务状态 +type TaskStatus string + +const ( + TaskStatusProcessing TaskStatus = "processing" + TaskStatusCompleted TaskStatus = "completed" + TaskStatusFailed TaskStatus = "failed" +) diff --git a/task/model.go b/task/model.go new file mode 100644 index 0000000..da9abe0 --- /dev/null +++ b/task/model.go @@ -0,0 +1,10 @@ +package task + +// Task 通用任务结构体 +type Task struct { + Processed int `json:"processed" dc:"已处理数量"` + Total int `json:"total" dc:"总计数量"` + Status TaskStatus `json:"status" dc:"状态:processing, completed, failed"` + Message string `json:"message" dc:"消息"` + Path string `json:"path" dc:"文件路径"` +} diff --git a/task/taskManager.go b/task/taskManager.go new file mode 100644 index 0000000..0bb5352 --- /dev/null +++ b/task/taskManager.go @@ -0,0 +1,76 @@ +package task + +import "sync" + +// TaskManager 任务管理器 +type sTaskManager struct { + tasks map[string]*Task + mutex sync.RWMutex +} + +var Manager = &sTaskManager{} + +func init() { + Manager = &sTaskManager{ + tasks: make(map[string]*Task), + } +} + +// CreateTask 创建任务 +func (m *sTaskManager) CreateTask(token string, total int) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.tasks[token] = &Task{ + Processed: 0, + Total: total, + Status: TaskStatusProcessing, + Message: "开始处理...", + Path: "", + } +} + +// UpdateProgress 更新任务进度 +func (m *sTaskManager) UpdateProgress(token string, processed int, message string) { + m.mutex.Lock() + defer m.mutex.Unlock() + if task, exists := m.tasks[token]; exists { + task.Processed = processed + task.Message = message + } +} + +// CompleteTask 完成任务 +func (m *sTaskManager) CompleteTask(token string, message string, path string) { + m.mutex.Lock() + defer m.mutex.Unlock() + if task, exists := m.tasks[token]; exists { + task.Status = TaskStatusCompleted + task.Message = message + task.Path = path + } +} + +// FailTask 失败任务 +func (m *sTaskManager) FailTask(token string, message string) { + m.mutex.Lock() + defer m.mutex.Unlock() + if task, exists := m.tasks[token]; exists { + task.Status = TaskStatusFailed + task.Message = message + } +} + +// GetTask 获取任务 +func (m *sTaskManager) GetTask(token string) (*Task, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + task, exists := m.tasks[token] + return task, exists +} + +// RemoveTask 移除任务 +func (m *sTaskManager) RemoveTask(token string) { + m.mutex.Lock() + defer m.mutex.Unlock() + delete(m.tasks, token) +} diff --git a/tcp/example.go b/tcp/example.go new file mode 100644 index 0000000..cd54038 --- /dev/null +++ b/tcp/example.go @@ -0,0 +1,47 @@ +package tcp + +import ( + "fmt" + "time" +) + +// Example 展示如何使用TCP服务 +func Example() { + // 创建配置 + config := &TcpPoolConfig{ + BufferSize: 2048, + MaxConnections: 100000, + ConnectTimeout: time.Second * 5, + ReadTimeout: time.Second * 30, + WriteTimeout: time.Second * 10, + MaxIdleTime: time.Minute * 5, + } + + // 创建TCP服务器 + server := NewTCPServer("0.0.0.0:8888", config) + + // 设置消息处理函数 + server.SetMessageHandler(func(conn *TcpConnection, msg *TcpMessage) error { + fmt.Printf("Received message from %s: %s\n", conn.Id, string(msg.Data)) + + // 回显消息 + return server.SendTo(conn.Id, []byte(fmt.Sprintf("Echo: %s", msg.Data))) + }) + + // 启动服务器 + if err := server.Start(); err != nil { + fmt.Printf("Failed to start server: %v\n", err) + return + } + + // 运行10秒后停止 + fmt.Println("TCP server started. Running for 10 seconds...") + time.Sleep(time.Second * 10) + + // 停止服务器 + if err := server.Stop(); err != nil { + fmt.Printf("Failed to stop server: %v\n", err) + } + + fmt.Println("TCP server stopped.") +} diff --git a/tcp/tcp.go b/tcp/tcp.go new file mode 100644 index 0000000..73b5bd3 --- /dev/null +++ b/tcp/tcp.go @@ -0,0 +1,280 @@ +package tcp + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gtcp" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" + "github.com/gogf/gf/v2/os/gtime" +) + +// MessageHandler 消息处理函数类型 +type MessageHandler func(conn *TcpConnection, msg *TcpMessage) error + +// TCPServer TCP服务器结构 +type TCPServer struct { + Address string + Config *TcpPoolConfig + Listener *gtcp.Server + Connection *ConnectionPool + Logger *glog.Logger + MessageHandler MessageHandler + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// ConnectionPool 连接池结构 +type ConnectionPool struct { + connections map[string]*TcpConnection + mutex sync.RWMutex + config *TcpPoolConfig + logger *glog.Logger +} + +// NewTCPServer 创建一个新的TCP服务器 +func NewTCPServer(address string, config *TcpPoolConfig) *TCPServer { + logger := g.Log(address) + ctx, cancel := context.WithCancel(context.Background()) + + pool := &ConnectionPool{ + connections: make(map[string]*TcpConnection), + config: config, + logger: logger, + } + + server := &TCPServer{ + Address: address, + Config: config, + Connection: pool, + Logger: logger, + ctx: ctx, + cancel: cancel, + } + + server.Listener = gtcp.NewServer(address, server.handleConnection) + return server +} + +// SetMessageHandler 设置消息处理函数 +func (s *TCPServer) SetMessageHandler(handler MessageHandler) { + s.MessageHandler = handler +} + +// Start 启动TCP服务器 +func (s *TCPServer) Start() error { + s.Logger.Info(s.ctx, fmt.Sprintf("TCP server starting on %s", s.Address)) + go func() { + s.wg.Add(1) + defer s.wg.Done() + if err := s.Listener.Run(); err != nil { + s.Logger.Error(s.ctx, fmt.Sprintf("TCP server stopped with error: %v", err)) + } + }() + return nil +} + +// Stop 停止TCP服务器 +func (s *TCPServer) Stop() error { + s.Logger.Info(s.ctx, "TCP server stopping...") + s.cancel() + s.Listener.Close() + s.wg.Wait() + s.Connection.Clear() + s.Logger.Info(s.ctx, "TCP server stopped") + return nil +} + +// handleConnection 处理新连接 +func (s *TCPServer) handleConnection(conn *gtcp.Conn) { + // 生成连接ID + connID := fmt.Sprintf("%s_%d", conn.RemoteAddr().String(), gtime.TimestampNano()) + + // 创建连接对象 + tcpConn := &TcpConnection{ + Id: connID, + Address: conn.RemoteAddr().String(), + Server: *conn, + IsActive: true, + LastUsed: time.Now(), + CreatedAt: time.Now(), + } + + // 添加到连接池 + s.Connection.Add(tcpConn) + s.Logger.Info(s.ctx, fmt.Sprintf("New connection established: %s", connID)) + + // 启动消息接收协程 + go s.receiveMessages(tcpConn) +} + +// receiveMessages 接收消息 +func (s *TCPServer) receiveMessages(conn *TcpConnection) { + defer func() { + if err := recover(); err != nil { + s.Logger.Error(s.ctx, fmt.Sprintf("Panic in receiveMessages: %v", err)) + } + s.Connection.Remove(conn.Id) + conn.Server.Close() + s.Logger.Info(s.ctx, fmt.Sprintf("Connection closed: %s", conn.Id)) + }() + + buffer := make([]byte, s.Config.BufferSize) + for { + select { + case <-s.ctx.Done(): + return + default: + // 设置读取超时 + conn.Server.SetReadDeadline(time.Now().Add(s.Config.ReadTimeout)) + + // 读取数据 + n, err := conn.Server.Read(buffer) + if err != nil { + s.Logger.Error(s.ctx, fmt.Sprintf("Read error from %s: %v", conn.Id, err)) + return + } + + if n > 0 { + // 更新最后使用时间 + conn.Mutex.Lock() + conn.LastUsed = time.Now() + conn.Mutex.Unlock() + + // 处理消息 + data := make([]byte, n) + copy(data, buffer[:n]) + + msg := &TcpMessage{ + Id: fmt.Sprintf("msg_%d", gtime.TimestampNano()), + ConnId: conn.Id, + Data: data, + Timestamp: time.Now(), + IsSend: false, + } + + // 使用协程池处理消息,避免阻塞 + grpool.AddWithRecover(s.ctx, func(ctx context.Context) { + if s.MessageHandler != nil { + if err := s.MessageHandler(conn, msg); err != nil { + s.Logger.Error(s.ctx, fmt.Sprintf("Message handling error: %v", err)) + } + } + }, func(ctx context.Context, err error) { + s.Logger.Error(ctx, fmt.Sprintf("Message handling error: %v", err)) + }) + } + } + } +} + +// SendTo 发送消息到指定连接 +func (s *TCPServer) SendTo(connID string, data []byte) error { + conn := s.Connection.Get(connID) + if conn == nil { + return fmt.Errorf("connection not found: %s", connID) + } + return s.sendMessage(conn, data) +} + +// SendToAll 发送消息到所有连接 +func (s *TCPServer) SendToAll(data []byte) error { + conns := s.Connection.GetAll() + for _, conn := range conns { + if err := s.sendMessage(conn, data); err != nil { + s.Logger.Error(s.ctx, fmt.Sprintf("Send to %s failed: %v", conn.Id, err)) + // 继续发送给其他连接 + } + } + return nil +} + +// sendMessage 发送消息 +func (s *TCPServer) sendMessage(conn *TcpConnection, data []byte) error { + conn.Mutex.Lock() + defer conn.Mutex.Unlock() + + // 设置写入超时 + conn.Server.SetWriteDeadline(time.Now().Add(s.Config.WriteTimeout)) + + // 发送数据 + _, err := conn.Server.Write(data) + if err != nil { + return err + } + + // 更新最后使用时间 + conn.LastUsed = time.Now() + return nil +} + +// Kick 强制退出客户端 +func (s *TCPServer) Kick(connID string) error { + conn := s.Connection.Get(connID) + if conn == nil { + return fmt.Errorf("connection not found: %s", connID) + } + + // 关闭连接 + conn.Server.Close() + // 从连接池移除 + s.Connection.Remove(connID) + + s.Logger.Info(s.ctx, fmt.Sprintf("Kicked connection: %s", connID)) + return nil +} + +// Add 添加连接到连接池 +func (p *ConnectionPool) Add(conn *TcpConnection) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.connections[conn.Id] = conn +} + +// Get 获取连接 +func (p *ConnectionPool) Get(connID string) *TcpConnection { + p.mutex.RLock() + defer p.mutex.RUnlock() + return p.connections[connID] +} + +// GetAll 获取所有连接 +func (p *ConnectionPool) GetAll() []*TcpConnection { + p.mutex.RLock() + defer p.mutex.RUnlock() + + conns := make([]*TcpConnection, 0, len(p.connections)) + for _, conn := range p.connections { + conns = append(conns, conn) + } + return conns +} + +// Remove 从连接池移除连接 +func (p *ConnectionPool) Remove(connID string) { + p.mutex.Lock() + defer p.mutex.Unlock() + delete(p.connections, connID) +} + +// Clear 清空连接池 +func (p *ConnectionPool) Clear() { + p.mutex.Lock() + defer p.mutex.Unlock() + for connID, conn := range p.connections { + conn.Server.Close() + delete(p.connections, connID) + } +} + +// Count 获取连接数量 +func (p *ConnectionPool) Count() int { + p.mutex.RLock() + defer p.mutex.RUnlock() + return len(p.connections) +} diff --git a/tcp/tcpConfig.go b/tcp/tcpConfig.go new file mode 100644 index 0000000..91f425c --- /dev/null +++ b/tcp/tcpConfig.go @@ -0,0 +1,38 @@ +package tcp + +import ( + "sync" + "time" + + "github.com/gogf/gf/v2/net/gtcp" +) + +// TcpPoolConfig TCP连接池配置 +type TcpPoolConfig struct { + BufferSize int `json:"bufferSize"` // 缓冲区大小 + MaxConnections int `json:"maxConnections"` // 最大连接数 + ConnectTimeout time.Duration `json:"connectTimeout"` // 连接超时时间 + ReadTimeout time.Duration `json:"readTimeout"` // 读取超时时间 + WriteTimeout time.Duration `json:"writeTimeout"` // 写入超时时间 + MaxIdleTime time.Duration `json:"maxIdleTime"` // 最大空闲时间 +} + +// TcpConnection TCP连接结构 +type TcpConnection struct { + Id string `json:"id"` // 连接ID + Address string `json:"address"` // 连接地址 + Server gtcp.Conn `json:"server"` // 实际连接 + IsActive bool `json:"isActive"` // 是否活跃 + LastUsed time.Time `json:"lastUsed"` // 最后使用时间 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + Mutex sync.RWMutex `json:"-"` // 读写锁 +} + +// TcpMessage TCP消息结构 +type TcpMessage struct { + Id string `json:"id"` // 消息ID + ConnId string `json:"connId"` // 连接ID + Data []byte `json:"data"` // 消息数据 + Timestamp time.Time `json:"timestamp"` // 时间戳 + IsSend bool `json:"isSend"` // 是否是发送的消息 +} diff --git a/utils/base64Image.go b/utils/base64Image.go new file mode 100644 index 0000000..bbea588 --- /dev/null +++ b/utils/base64Image.go @@ -0,0 +1,102 @@ +package utils + +import ( + "encoding/base64" + "fmt" + "image" + "image/jpeg" + "image/png" + "io/ioutil" + "os" + "strings" +) + +// Base64ToImage 将Base64编码的数据转换为图片并保存到指定路径 +func Base64ToImage(base64String, outputPath string) error { + // 移除Base64数据URI前缀(如果有) + if strings.Contains(base64String, ",") { + base64String = strings.Split(base64String, ",")[1] + } + + // 解码Base64字符串 + imageData, err := base64.StdEncoding.DecodeString(base64String) + if err != nil { + return fmt.Errorf("解码Base64数据时出错: %v", err) + } + + // 将解码后的数据写入文件 + err = ioutil.WriteFile(outputPath, imageData, 0644) + if err != nil { + return fmt.Errorf("保存图片文件时出错: %v", err) + } + + return nil +} + +// Base64ToImageWithFormat 将Base64编码的数据转换为指定格式的图片并保存 +func Base64ToImageWithFormat(base64String, outputPath, format string) error { + // 移除Base64数据URI前缀(如果有) + if strings.Contains(base64String, ",") { + base64String = strings.Split(base64String, ",")[1] + } + + // 解码Base64字符串 + imageData, err := base64.StdEncoding.DecodeString(base64String) + if err != nil { + return fmt.Errorf("解码Base64数据时出错: %v", err) + } + + // 创建文件 + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("创建图片文件时出错: %v", err) + } + defer file.Close() + + // 根据指定格式编码图片 + switch strings.ToLower(format) { + case "jpeg", "jpg": + // 解码图片 + img, _, err := image.Decode(strings.NewReader(string(imageData))) + if err != nil { + return fmt.Errorf("解码图片时出错: %v", err) + } + + // 编码为JPEG格式 + err = jpeg.Encode(file, img, &jpeg.Options{Quality: 90}) + if err != nil { + return fmt.Errorf("编码JPEG图片时出错: %v", err) + } + case "png": + // 解码图片 + img, _, err := image.Decode(strings.NewReader(string(imageData))) + if err != nil { + return fmt.Errorf("解码图片时出错: %v", err) + } + + // 编码为PNG格式 + err = png.Encode(file, img) + if err != nil { + return fmt.Errorf("编码PNG图片时出错: %v", err) + } + default: + // 直接写入原始数据 + _, err = file.Write(imageData) + if err != nil { + return fmt.Errorf("写入图片数据时出错: %v", err) + } + } + + return nil +} + +// GetImageFormatFromBase64 从Base64数据中获取图片格式 +func GetImageFormatFromBase64(base64String string) string { + // 检查数据URI前缀 + if strings.HasPrefix(base64String, "data:image/") { + // 提取格式信息 + format := strings.Split(strings.Split(base64String, ";")[0], "/")[1] + return format + } + return "unknown" +} diff --git a/utils/common.go b/utils/common.go new file mode 100644 index 0000000..c20f8ae --- /dev/null +++ b/utils/common.go @@ -0,0 +1,32 @@ +package utils + +type NormalRes[T any] struct { + Code int `json:"code" dc:"code"` + Data T `json:"data" dc:"data 可null"` + Msg string `json:"msg" dc:"return msg"` +} + +type ListRes[T any] struct { + Rows []T + Total int `json:"total"` +} + +func NewNormalRes[T any](data T, msg ...string) *NormalRes[T] { + message := "" + if len(msg) == 0 { + message = "操作成功" + } else { + message = msg[0] + } + return &NormalRes[T]{ + Code: 1, + Data: data, + Msg: message, + } +} +func NewListRes[T any](data []T, total int) *ListRes[T] { + return &ListRes[T]{ + Rows: data, + Total: total, + } +} diff --git a/utils/curd.go b/utils/curd.go new file mode 100644 index 0000000..778a433 --- /dev/null +++ b/utils/curd.go @@ -0,0 +1,346 @@ +package utils + +import ( + "context" + + "github.com/gogf/gf/v2/container/gmap" + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" +) + +type ctx = context.Context + +type IDao interface { + DB() gdb.DB + Table() string + Group() string + Ctx(ctx context.Context) *gdb.Model + Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) +} + +type Curd[R any] struct { + Dao IDao +} + +var pageInfo = []string{ + "page", + "size", + "num", + "limit", + "pagesize", + "pageSize", + "page_size", + "pageNum", + "pagenum", + "page_num", +} + +func (c Curd[R]) BuildWhere(req any, changeWhere any, subWhere any, removeFields []string, isSnake ...bool) map[string]any { + // 默认使用小写下划线方式 + caseTypeValue := gstr.Snake + if len(isSnake) > 0 && isSnake[0] == false { + caseTypeValue = gstr.CamelLower + } + + // 转换req为map + reqMap := gconv.Map(req) + + // 清理空值和分页信息 + ctx := gctx.New() + cleanedReq := make(map[string]any) + for k, v := range reqMap { + // 清理空值 + if g.IsEmpty(v) { + glog.Debugf(ctx, "清理空值:%s", k) + continue + } + // 清理分页信息 + if gstr.InArray(pageInfo, k) { + glog.Debugf(ctx, "清理分页信息:%s", k) + continue + } + if len(removeFields) > 0 && gstr.InArray(removeFields, k) { + glog.Debugf(ctx, "清理字段:%s", k) + continue + } + cleanedReq[gstr.CaseConvert(k, caseTypeValue)] = v + } + + // 处理changeWhere + if changeWhere != nil { + changeMap := gconv.Map(changeWhere) + for k, v := range changeMap { + if _, hasKey := cleanedReq[k]; !hasKey { + glog.Debugf(ctx, "处理changeWhere:%s", k) + continue + } + if len(removeFields) > 0 && gstr.InArray(removeFields, k) { + glog.Debugf(ctx, "清理应删除字段:%s", k) + continue + } + // 转换v为map + vMap := gconv.Map(v) + value, hasValue := vMap["value"] + op, hasOp := vMap["op"] + + if hasValue { + glog.Debugf(ctx, "变更字段存在value:%s", k) + // 构建新的键名 + newKey := k + if hasOp && op != "" { + glog.Debugf(ctx, "变更字段存在op:%s", k) + newKey = k + " " + gconv.String(op) + delete(cleanedReq, k) + } + cleanedReq[newKey] = value + } + } + } + + // 变量名切换 + resultMap := make(map[string]any) + for k, v := range cleanedReq { + // 提取原始键名(去掉op部分) + originalKey := k + opStr := "" + if opIndex := gstr.Pos(k, " "); opIndex > 0 { + originalKey = k[:opIndex] + opStr = k[opIndex+1:] + } + + // 转换键名 + convertedKey := originalKey + convertedKey = gstr.CaseConvert(convertedKey, caseTypeValue) + + // 如果有op,重新构建键名 + if opStr != "" { + convertedKey = convertedKey + " " + opStr + } + + resultMap[convertedKey] = v + } + // 合并subWhere + if subWhere != nil { + subMap := gconv.Map(subWhere) + resultM := gmap.NewStrAnyMapFrom(resultMap) + resultM.Merge(gmap.NewStrAnyMapFrom(subMap)) + resultMap = resultM.Map() + } + return resultMap +} +func (c Curd[R]) BuildMap(op string, value any, field ...string) map[string]any { + if len(field) > 0 { + return map[string]any{ + "op": op, + "field": field[0], + "value": value, + } + } + return map[string]any{ + "op": op, + "field": "", + "value": value, + } +} +func (c Curd[R]) Builder(ctx context.Context) *gdb.WhereBuilder { + return c.Dao.Ctx(ctx).Builder() +} +func (c Curd[R]) ClearField(req any, delField []string, subField ...map[string]any) map[string]any { + m := gmap.NewStrAnyMapFrom(gconv.Map(req)) + if delField != nil && len(delField) > 0 { + m.Iterator(func(k string, v any) bool { + if g.IsEmpty(v) { + m.Remove(k) + return true + } + if gstr.InArray(delField, k) { + m.Remove(k) + return true + } + if gstr.InArray(pageInfo, k) { + m.Remove(k) + return true + } + return true + }) + } + if subField != nil && len(subField) > 0 { + m.Merge(gmap.NewStrAnyMapFrom(subField[0])) + } + return m.Map() +} +func (c Curd[R]) ClearFieldPage(ctx ctx, req any, delField []string, where any, page *Paginate, order any, with bool) (items []*R, total int, err error) { + db := c.Dao.Ctx(ctx) + m := c.ClearField(req, delField) + if with { + db = db.WithAll() + } + db = db.Where(m) + if !g.IsNil(where) { + db = db.Where(where) + } + if order != nil { + db = db.Order(order) + } + if !g.IsNil(page) { + db = db.Page(page.Page, page.Limit) + } + err = db.ScanAndCount(&items, &total, false) + return +} +func (c Curd[R]) ClearFieldList(ctx ctx, req any, delField []string, where any, order any, with bool) (items []*R, err error) { + db := c.Dao.Ctx(ctx) + m := c.ClearField(req, delField) + db = db.Where(m) + if !g.IsNil(where) { + db = db.Where(where) + } + if with { + db = db.WithAll() + } + if !g.IsNil(order) { + db = db.Order(order) + } + err = db.Scan(&items) + return +} +func (c Curd[R]) ClearFieldOne(ctx ctx, req any, delField []string, where any, order any, with bool) (items *R, err error) { + db := c.Dao.Ctx(ctx) + m := c.ClearField(req, delField) + db = db.Where(m) + if !g.IsNil(where) { + db = db.Where(where) + } + if with { + db = db.WithAll() + } + if !g.IsNil(order) { + db = db.Order(order) + } + err = db.Scan(&items) + return +} +func (c Curd[R]) Value(ctx ctx, where any, field any) (*gvar.Var, error) { + return c.Dao.Ctx(ctx).Where(where).Fields(field).Value() +} +func (c Curd[R]) DeletePri(ctx ctx, primaryKey any) error { + _, err := c.Dao.Ctx(ctx).WherePri(primaryKey).Delete() + return err +} +func (c Curd[R]) DeleteWhere(ctx ctx, where any) error { + _, err := c.Dao.Ctx(ctx).Where(where).Delete() + return err +} + +func (c Curd[R]) Sum(ctx ctx, where any, field string) (float64, error) { + return c.Dao.Ctx(ctx).Where(where).Sum(field) +} + +func (c Curd[R]) ArrayField(ctx ctx, where any, field any) ([]*gvar.Var, error) { + if field == nil { + field = "*" + } + return c.Dao.Ctx(ctx).Where(where).Fields(field).Array() +} + +func (c Curd[R]) FindPri(ctx ctx, primaryKey any, with bool) (model *R, err error) { + db := c.Dao.Ctx(ctx).WherePri(primaryKey) + if with { + db = db.WithAll() + } + err = db.Scan(&model) + if err != nil { + return + } + return +} + +func (c Curd[R]) First(ctx ctx, where any, order any, with bool) (model *R, err error) { + db := c.Dao.Ctx(ctx).Where(where) + if with { + db = db.WithAll() + } + if !g.IsNil(order) { + db = db.Order(order) + } + err = db.Scan(&model) + if err != nil { + return + } + return +} + +func (c Curd[R]) Exists(ctx ctx, where any) (exists bool, err error) { + return c.Dao.Ctx(ctx).Where(where).Exist() +} + +func (c Curd[R]) All(ctx ctx, where any, order any, with bool) (items []*R, err error) { + db := c.Dao.Ctx(ctx) + if with { + db = db.WithAll() + } + if !g.IsNil(order) { + db = db.Order(order) + } + err = db.Where(where).Scan(&items) + if err != nil { + return nil, err + } + return +} + +func (c Curd[R]) Count(ctx ctx, where any) (count int, err error) { + count, err = c.Dao.Ctx(ctx).Where(where).Count() + return +} + +func (c Curd[R]) Save(ctx ctx, data any) (id int64, err error) { + result, err := c.Dao.Ctx(ctx).Save(data) + if err != nil { + return + } + id, err = result.LastInsertId() + return +} + +func (c Curd[R]) Update(ctx ctx, where any, data any) (count int64, err error) { + result, err := c.Dao.Ctx(ctx).Where(where).Data(data).Update() + if err != nil { + return + } + count, err = result.RowsAffected() + return +} + +func (c Curd[R]) UpdatePri(ctx ctx, primaryKey any, data any) (count int64, err error) { + result, err := c.Dao.Ctx(ctx).WherePri(primaryKey).Data(data).Update() + if err != nil { + return + } + count, err = result.RowsAffected() + return +} + +func (c Curd[R]) Paginate(ctx context.Context, where any, p Paginate, with bool, order any) (items []*R, total int, err error) { + query := c.Dao.Ctx(ctx) + if where != nil { + query = query.Where(where) + } + query = query.Page(p.Page, p.Limit) + if order != nil { + query = query.Order(order) + } + if with == true { + query = query.WithAll() + } + err = query.Order(order).ScanAndCount(&items, &total, false) + if err != nil { + return + } + return +} diff --git a/utils/model.go b/utils/model.go new file mode 100644 index 0000000..519ee0b --- /dev/null +++ b/utils/model.go @@ -0,0 +1,12 @@ +package utils + +type Paginator[I any] struct { + Items []I + Total int + IsSimple bool +} + +type Paginate struct { + Limit int `d:"20" json:"limit" v:"max:50"` + Page int `d:"1" dc:"页码" json:"page"` +} diff --git a/utils/sql.go b/utils/sql.go new file mode 100644 index 0000000..bfcf6e2 --- /dev/null +++ b/utils/sql.go @@ -0,0 +1,102 @@ +package utils + +import ( + "context" + + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/crypto/gmd5" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" +) + +// GetCapitalPass MD5化并转换为大写 +func GetCapitalPass(val string) string { + md5, err := gmd5.Encrypt(val) + if err != nil { + panic(err.Error()) + } + return gstr.CaseCamel(md5) +} + +// Transaction 简单封装事务操作 +func Transaction(function func() error) { + err := g.DB().Transaction(context.TODO(), func(ctx context.Context, tx gdb.TX) error { + return function() + }) + if err != nil { + panic(err.Error()) + } +} + +type SClient[R any] struct { + client *gclient.Client + request any + header map[string]string + url string +} + +func NewClient[R any](request any, url string, header map[string]string) *SClient[R] { + s := &SClient[R]{} + if header != nil { + s.client = g.Client().ContentJson().SetHeaderMap(header) + } else { + s.client = g.Client().ContentJson() + } + s.header = header + s.url = url + s.request = request + return s +} +func (w *SClient[R]) Post(ctx context.Context) (res *R, err error) { + g.Log().Infof(ctx, "请求Url:%s,请求头:%v,请求方法:%s,请求内容:%s", w.url, w.header, "post", w.request) + resp := w.client.PostVar(ctx, w.url, w.request) + err = gconv.Struct(resp, &res) + if err != nil { + g.Log().Errorf(ctx, "解析响应体异常:%s", err) + return nil, err + } + return +} +func (w *SClient[R]) Get(ctx context.Context) (res *R, err error) { + g.Log().Infof(ctx, "请求Url:%s,请求头:%v,请求方法:%s,请求内容:%s", w.url, w.header, "get", w.request) + resp := w.client.GetVar(ctx, w.url, w.request) + err = gconv.Struct(resp, &res) + if err != nil { + g.Log().Errorf(ctx, "解析响应体异常:%s", err) + return nil, err + } + return +} +func (w *SClient[R]) Put(ctx context.Context) (res *R, err error) { + g.Log().Infof(ctx, "请求Url:%s,请求头:%v,请求方法:%s,请求内容:%s", w.url, w.header, "put", w.request) + resp := w.client.PutVar(ctx, w.url, w.request) + err = gconv.Struct(resp, &res) + if err != nil { + g.Log().Errorf(ctx, "解析响应体异常:%s", err) + return nil, err + } + return +} +func (w *SClient[R]) Delete(ctx context.Context) (res *R, err error) { + g.Log().Infof(ctx, "请求Url:%s,请求头:%v,请求方法:%s,请求内容:%s", w.url, w.header, "delete", w.request) + resp := w.client.DeleteVar(ctx, w.url, w.request) + err = gconv.Struct(resp, &res) + if err != nil { + g.Log().Errorf(ctx, "解析响应体异常:%s", err) + return nil, err + } + return +} +func (w *SClient[R]) Patch(ctx context.Context) (res *R, err error) { + g.Log().Infof(ctx, "请求Url:%s,请求头:%v,请求方法:%s,请求内容:%s", w.url, w.header, "patch", w.request) + resp := w.client.PatchVar(ctx, w.url, w.request) + err = gconv.Struct(resp, &res) + if err != nil { + g.Log().Errorf(ctx, "解析响应体异常:%s", err) + return nil, err + } + return +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..3ad3db5 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,197 @@ +package utils + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "image/jpeg" + "math/big" + "os" + + "github.com/gogf/gf/v2/container/garray" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gres" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" + "github.com/nfnt/resize" +) + +// Compress 图片压缩 +/* + * @param filePath string 图片路径 + * @return string + * 压缩图片 + */ +func Compress(filePath string) string { + file, err := os.Open(filePath) + if err != nil { + panic(err.Error()) + } + img, err := jpeg.Decode(file) + if err != nil { + panic(err.Error()) + } + err = file.Close() + if err != nil { + panic(err.Error()) + } + m := resize.Resize(960, 0, img, resize.Lanczos2) + str := gstr.Split(filePath, "/") + sta := gstr.Split(str[len(str)-1], ".") + paths := gfile.Pwd() + "/resource/public/upload/" + sta[0] + "-cop." + sta[1] + out, err := os.Create(paths) + defer out.Close() + err = jpeg.Encode(out, m, nil) + if err != nil { + panic(err.Error()) + } + _ = gfile.RemoveFile(filePath) + return sta[0] + "-cop." + sta[1] +} + +// Sha256 sha256加密 +/* + * @param src string 被加密字符串 + * @return string + * 将字符串进行hash加密 + */ +func Sha256(src string) string { + m := sha256.New() + m.Write([]byte(src)) + res := hex.EncodeToString(m.Sum(nil)) + return res +} + +// InStrArray 判断是否在数组中 +/* + * @param ext string 要判断的字符串 + * @param code int + * 判断是否在字符串数组中 + */ +func InStrArray(ext string, code int) bool { + if code == 1 { + arr := garray.NewStrArrayFrom(g.SliceStr{".jpg", ".jpeg", ".png"}) + return arr.Contains(ext) + } else { + arr := garray.NewStrArrayFrom(g.SliceStr{".xlsx"}) + return arr.Contains(ext) + } +} + +// ResAddFile 添加文件到资源包 +/* + * @param onePath string + * 例:gf pack resource/dist internal/boot/boot_resource.go -n boot + * 需要在boot中 打包引入 并在main.go中引入boot + */ +func ResAddFile(onePath string) { + if gfile.Exists(gfile.Pwd() + gfile.Separator + onePath) { + err := gfile.RemoveFile(onePath) + if err != nil { + panic(err) + } + } + g.Log().Debug(gctx.GetInitCtx(), onePath) + gres.Dump() + if gres.IsEmpty() { + return + } + if gstr.Contains(onePath, "/") { + strs := gstr.Split(onePath, "/") + err := gres.Export(strs[1], strs[0]) + if err != nil { + panic(err) + } + + } else { + err := gres.Export(onePath, onePath) + if err != nil { + panic(err) + } + } +} + +const ( + CharsetLower = "abcdefghijklmnopqrstuvwxyz" + CharsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + CharsetNumber = "0123456789" + CharsetDefault = CharsetLower + CharsetUpper + CharsetNumber +) + +// GenerateString 生成安全随机字符串 +func GenerateString(length int) (str string) { + bytes := make([]byte, length) + charsetLen := big.NewInt(int64(len(CharsetDefault))) + + for i := range bytes { + n, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + panic(err) + } + bytes[i] = CharsetDefault[n.Int64()] + } + str = string(bytes) + return +} + +// GetFileList 获取文件列表 +/* + * 根据传入的路径返回该路径下的所有可访问的列表 + * 要求必须将静态文件访问路径定义为static,且静态文件访问目录为resource + */ +func GetFileList(path string) []string { + if path == "" { + path = "/" + } + filePath := fmt.Sprintf("%s%sresource", gfile.Pwd(), gfile.Separator) + if path != "/" { + filePath += gfile.Separator + path + gfile.Separator + } + paths, _ := gfile.DirNames(filePath) + pathArr := garray.NewStrArray() + for _, v := range paths { + if gstr.Contains(v, ".") { + if path != "/" { + pathArr.Append("/static/" + path + "/" + v) + } else { + pathArr.Append("/static/" + v) + } + } else { + pathArr.Append(v) + } + } + return pathArr.Slice() +} +func Float64Trans(value string) *float64 { + if value == "" { + return nil + } + f := gconv.Float64(value) + return &f +} + +func IntTrans(value string) *int { + if value == "" { + return nil + } + i := gconv.Int(value) + return &i +} +func StrTrans(value string) *string { + if value == "" { + return nil + } + i := gconv.String(value) + return &i +} + +func TimeTrans(value string) *gtime.Time { + if value == "" { + return nil + } + return gconv.GTime(value) +}