diff --git a/.gitignore b/.gitignore index 0ed3f20..79e270e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ test go.sum pkg test.go +.claude diff --git a/cache/redis.go b/cache/redis.go new file mode 100644 index 0000000..8eb4424 --- /dev/null +++ b/cache/redis.go @@ -0,0 +1,23 @@ +package cache + +import "github.com/go-redis/redis" + +var ( + redisClient *redis.Client = nil +) + +func NewRedis() { + addr := "127.0.0.1:6379" + redis.NewClient(&redis.Options{}) + + redisClient = redis.NewClient(&redis.Options{ + Addr: addr, + }) +} + +func GetRedis() *redis.Client { + if redisClient == nil { + NewRedis() + } + return redisClient +} diff --git a/config/form.go b/config/form.go index c545dc1..a314edc 100644 --- a/config/form.go +++ b/config/form.go @@ -24,6 +24,7 @@ type Form struct { Name string `json:"name"` //表单名称 Key string `json:"key"` //表单KEY Value interface{} `json:"value"` //表单值 + Default interface{} `json:"default"` //默认值 Disable bool `json:"disable,omitempty"` //是否禁用 Tips string `json:"tips"` //表单提示 Option []*FormOption `json:"option"` //表单选项,radio和checkbox需要 @@ -50,6 +51,8 @@ func NewFroms(tplConfig, saveConfig string) ([]*Form, error) { for _, form := range forms { if _, ok := cfg[form.Key]; ok { form.Value = cfg[form.Key] + } else { + form.Value = form.Default } } return forms, err diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go new file mode 100644 index 0000000..0c1c65f --- /dev/null +++ b/enterprise/enterprise.go @@ -0,0 +1,19 @@ +package enterprise + +type Enterprise struct { + token string + baseUrl string +} + +func (e *Enterprise) GetHeader() map[string]string { + return map[string]string{ + "x-token": e.token, + } +} + +func (e *Enterprise) GetBaseUrl() string { + if e.baseUrl != "" { + return e.baseUrl + } + return "http://e.batiao8.com" +} diff --git a/enterprise/staff_user.go b/enterprise/staff_user.go new file mode 100644 index 0000000..2f82aee --- /dev/null +++ b/enterprise/staff_user.go @@ -0,0 +1,75 @@ +package enterprise + +import ( + "errors" + "fmt" + + "git.u8t.cn/open/goutil" + "github.com/tidwall/gjson" +) + +var ( + corpId = 1000 +) + +type VerifyCode struct { + Username string `json:"username"` + Timestamp string `json:"timestamp"` +} +type StaffInfo struct { + Username string `json:"username"` + Realname string `json:"realname"` + Phone string `json:"phone"` +} +type StaffUser struct { + Enterprise +} + +func NewStaffUser(baseUrl, token string) *StaffUser { + return &StaffUser{ + Enterprise: Enterprise{ + baseUrl: baseUrl, + token: token, + }, + } +} + +func (e *StaffUser) SendVerifyCode(username, scene string) (*VerifyCode, error) { + var reqBody string + reqBody = fmt.Sprintf(`{"username":"%s","scene":"%s"}`, username, scene) + reqUrl := e.GetBaseUrl() + "/api/staff/verify/code" + + body, err := goutil.HttpPost(reqUrl, e.GetHeader(), []byte(reqBody)) + if err != nil { + return nil, err + } + g := gjson.ParseBytes(body) + if g.Get("code").Int() != 0 { + return nil, errors.New(string(body)) + } + r := new(VerifyCode) + r.Timestamp = g.Get("data.timestamp").String() + r.Username = g.Get("data.username").String() + + return r, nil +} + +func (e *StaffUser) Login(username, timestamp, code string) (*StaffInfo, error) { + reqBody := fmt.Sprintf(`{"username":"%s","timestamp":"%s","code":"%s"}`, username, timestamp, code) + reqUrl := e.GetBaseUrl() + "/api/staff/login" + body, err := goutil.HttpPost(reqUrl, e.GetHeader(), []byte(reqBody)) + if err != nil { + return nil, err + } + g := gjson.ParseBytes(body) + if g.Get("code").Int() != 0 { + return nil, errors.New(string(body)) + } + + r := new(StaffInfo) + r.Realname = g.Get("data.realname").String() + r.Username = g.Get("data.username").String() + r.Phone = g.Get("data.phone").String() + + return r, nil +} diff --git a/go.mod b/go.mod index d9ae7e6..5edad07 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/gin-gonic/gin v1.11.0 + github.com/go-redis/redis v6.15.9+incompatible github.com/gomodule/redigo v1.9.2 + github.com/google/uuid v1.6.0 github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/minio/minio-go v6.0.14+incompatible github.com/qiniu/go-sdk/v7 v7.25.4 @@ -15,7 +17,7 @@ require ( github.com/spf13/cast v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/wechatpay-apiv3/wechatpay-go v0.2.21 - golang.org/x/crypto v0.42.0 + golang.org/x/crypto v0.47.0 gorm.io/gorm v1.31.0 ) @@ -47,6 +49,8 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/onsi/ginkgo v1.16.5 // indirect + github.com/onsi/gomega v1.39.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect @@ -58,12 +62,12 @@ require ( go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/image v0.31.0 // indirect - golang.org/x/mod v0.27.0 // 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 - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.9 // indirect modernc.org/fileutil v1.0.0 // indirect ) diff --git a/qyweixin/app.go b/qyweixin/app.go index ce9a3b3..8b6fff7 100644 --- a/qyweixin/app.go +++ b/qyweixin/app.go @@ -316,6 +316,11 @@ func (a *App) Upload(path, kind string) (string, error) { return cast.ToString(result["media_id"]), nil } +func (a *App) MediaUrl(mediaId string) string { + mUrl := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s", a.GetToken(), mediaId) + return mUrl +} + func (q *App) GetJsapiConfig(url string) (*JsapiConfig, error) { ticket, err := q.getJsapiTicket() if err != nil { diff --git a/qyweixin/app_approve.go b/qyweixin/app_approve.go index f343a23..739ffc0 100644 --- a/qyweixin/app_approve.go +++ b/qyweixin/app_approve.go @@ -3,11 +3,12 @@ package qyweixin import ( "encoding/json" "fmt" + "strings" + "git.u8t.cn/open/gosdk/util" "git.u8t.cn/open/goutil" log "github.com/sirupsen/logrus" "github.com/spf13/cast" - "strings" ) type Applyer struct { @@ -90,57 +91,8 @@ type AppApprove struct { } func (d *ApproveDetail) GetValue(title string) string { - - for _, content := range d.ApplyData.Contents { - - isEqual := false - for _, ti := range content.Title { - if ti.Text == title { - isEqual = true - } - } - - if !isEqual { - continue - } - - var value string - if content.Control == "Selector" { - for _, v := range content.Value.Selector.Options[0].Value { - if v.Text != "" { - value = v.Text - } - } - } else if content.Control == "Text" || content.Control == "Textarea" { - value = content.Value.Text - } else if content.Control == "Date" { - value = content.Value.Date.Timestamp - } else if content.Control == "Money" { - value = content.Value.NewMoney - } else if content.Control == "File" { - value = content.Value.Files[0].FileId - } else if content.Control == "Vacation" { //请假 : 请假类型,开始时间,结束时间,请假时长 - tp := content.Value.Vacation.Selector.Options[0].Value[0].Text - value = tp + "," + cast.ToString(content.Value.Vacation.Attendance.DateRange.NewBegin) + - "," + cast.ToString(content.Value.Vacation.Attendance.DateRange.NewEnd) + - "," + cast.ToString(content.Value.Vacation.Attendance.DateRange.NewDuration) - } else if content.Control == "PunchCorrection" { //补卡:日期,时间,状态 - mp := cast.ToStringMap(content.Value.PunchCorrection) - ddate := cast.ToString(mp["daymonthyear"]) - dtime := cast.ToString(mp["time"]) - if ddate == "" { - ddate = dtime - } - value = ddate + "," + dtime + "," + cast.ToString(mp["state"]) - } else if content.Control == "BankAccount" { - mp := cast.ToStringMap(content.Value.BankAccount) - value = cast.ToString(mp["account_type"]) + "," + cast.ToString(mp["account_name"]) + "," + cast.ToString(mp["account_number"]) - } else if content.Control == "Number" { - value = content.Value.NewNumber - } - return value - } - return "" + data := d.GetData() + return data[title] } func (d *ApproveDetail) GetData() map[string]string { @@ -165,9 +117,11 @@ func (d *ApproveDetail) GetData() map[string]string { } else if content.Control == "Money" { value = content.Value.NewMoney } else if content.Control == "File" { - if len(content.Value.Files) > 0 { - value = content.Value.Files[0].FileId + fileIds := make([]string, 0) + for _, v := range content.Value.Files { + fileIds = append(fileIds, v.FileId) } + value = strings.Join(fileIds, ",") } else if content.Control == "Vacation" { //请假 : 请假类型,开始时间,结束时间,请假时长 tp := content.Value.Vacation.Selector.Options[0].Value[0].Text value = tp + "," + cast.ToString(content.Value.Vacation.Attendance.DateRange.NewBegin) + @@ -220,7 +174,7 @@ func (q *AppApprove) GetDetail(spNo string) (*ApproveDetail, error) { } var rsp ApproveDetailRsp - fmt.Println("spno: %s, detail: %s", spNo, string(rspBody)) + //fmt.Println("spno: %s, detail: %s", spNo, string(rspBody)) if err := json.Unmarshal(rspBody, &rsp); err != nil { log.Errorf("get body[%s] json error :%s", string(rspBody), err.Error()) return nil, err diff --git a/qyweixin/app_hr.go b/qyweixin/app_hr.go index 1adef49..6889c05 100644 --- a/qyweixin/app_hr.go +++ b/qyweixin/app_hr.go @@ -2,11 +2,12 @@ package qyweixin import ( "fmt" - "git.u8t.cn/open/gosdk/util" - log "github.com/sirupsen/logrus" - "git.u8t.cn/open/goutil" - "github.com/spf13/cast" "time" + + "git.u8t.cn/open/gosdk/util" + "git.u8t.cn/open/goutil" + log "github.com/sirupsen/logrus" + "github.com/spf13/cast" ) var ( @@ -14,6 +15,7 @@ var ( urlQyWeixinHrGetStaffInfo = "https://qyapi.weixin.qq.com/cgi-bin/hr/get_staff_info" urlQyWeixinHrGetDepartment = "https://qyapi.weixin.qq.com/cgi-bin/department/list" urlQyWeixinHrGetDepartmentUser = "https://qyapi.weixin.qq.com/cgi-bin/user/list" + urlQyWeixinHrGetContactInfo = "https://qyapi.weixin.qq.com/cgi-bin/user/get" ) type AppHr struct { @@ -22,10 +24,11 @@ type AppHr struct { } type Department struct { - Id int64 `json:"id"` - Pid int64 `json:"pid"` - Name string `json:"name"` - Leader []string `json:"leader"` + Id int64 `json:"id"` + Pid int64 `json:"pid"` + Name string `json:"name"` + Leader []string `json:"leader"` + Childchren []*Department `json:"childchren"` } type StaffInfo struct { @@ -42,12 +45,38 @@ type StaffInfo struct { BankCard string } +type ContactInfo struct { + Realname string + DirectLeader []string + DepartmentId []int64 + IsDepartmentLeader []int +} + func NewAppHr(cfg *AppConfig) *AppHr { return &AppHr{ App: *NewApp(cfg), } } +func (h *AppHr) GetContactInfo(userId string) (*ContactInfo, error) { + reqUrl := fmt.Sprintf("%s?access_token=%s&userid=%s", urlQyWeixinHrGetContactInfo, h.GetToken(), userId) + + rspBody, err := util.HttpGet(reqUrl, nil) + if err != nil { + return nil, err + } + contract := new(ContactInfo) + result, err := h.GetResult(rspBody) + if err != nil { + return nil, err + } + contract.Realname = cast.ToString(result["name"]) + contract.DirectLeader = cast.ToStringSlice(result["direct_leader"]) + contract.DepartmentId = cast.ToInt64Slice(result["department"]) + contract.IsDepartmentLeader = cast.ToIntSlice(result["is_leader_in_dept"]) + return contract, nil +} + func (h *AppHr) GetStaffInfo(userId string) (*StaffInfo, error) { reqUrl := fmt.Sprintf("%s?access_token=%s", urlQyWeixinHrGetStaffInfo, h.GetToken()) reqBody := make(map[string]interface{}) @@ -108,6 +137,7 @@ func (h *AppHr) GetDepartment(id int64) ([]*Department, error) { result := make([]*Department, 0) departments := cast.ToSlice(resp["department"]) + for _, dd := range departments { d := cast.ToStringMap(dd) r := new(Department) @@ -116,7 +146,9 @@ func (h *AppHr) GetDepartment(id int64) ([]*Department, error) { r.Id = cast.ToInt64(d["id"]) r.Pid = cast.ToInt64(d["parentid"]) result = append(result, r) + } + return result, nil } diff --git a/storage/minio.go b/storage/minio.go index df822b8..659df71 100644 --- a/storage/minio.go +++ b/storage/minio.go @@ -3,12 +3,14 @@ package storage import ( "context" "fmt" - "github.com/minio/minio-go" + "net/http" "net/url" "path" "strings" "time" + "github.com/minio/minio-go" + log "github.com/sirupsen/logrus" ) @@ -61,9 +63,13 @@ func (s *Minio) Put(fileName, objectName string, onProcess func(fsize, uploaded objectName = strings.TrimLeft(objectName, "/ ") ext := strings.TrimLeft(path.Ext(fileName), ".") + contentType := ext2ContentType(ext) + if contentType == "" { + contentType = detectContentType(fileName) + } _, err := s.client.FPutObjectWithContext(ctx, s.config.Bucket, objectName, fileName, - minio.PutObjectOptions{ContentType: ext2ContentType(ext)}) + minio.PutObjectOptions{ContentType: contentType}) if err != nil { log.Errorf("upload file to minio error:%s", err.Error()) return err @@ -108,7 +114,7 @@ func (s *Minio) List(objectPrefix string) ([]string, error) { return result, nil } -func (s *Minio) Url(objectName string, expire time.Duration) string { +func (s *Minio) Url(objectName string, expire time.Duration, https ...bool) string { if err := s.Init(); err != nil { return err.Error() } @@ -116,11 +122,19 @@ func (s *Minio) Url(objectName string, expire time.Duration) string { if expire > time.Hour*24*7 || expire == -1 { expire = time.Hour * 24 * 7 } + + baseUrl := s.config.BaseUrl + if len(https) > 0 && https[0] { + if strings.HasPrefix(baseUrl, "http://") { + baseUrl = "https://" + strings.TrimPrefix(baseUrl, "http://") + } + } + var params url.Values u, err := s.client.PresignedGetObject(s.config.Bucket, objectName, expire, params) if err != nil { log.Errorf("error:%s", err.Error()) - return fmt.Sprintf("%s/%s/%s", s.config.BaseUrl, s.config.Bucket, objectName) + return fmt.Sprintf("%s/%s/%s", baseUrl, s.config.Bucket, objectName) } return u.String() @@ -128,9 +142,78 @@ func (s *Minio) Url(objectName string, expire time.Duration) string { } func (s *Minio) Stat(objectName string) (*ObjectInfo, error) { - return nil, nil + if err := s.Init(); err != nil { + return nil, err + } + + objectName = strings.TrimLeft(objectName, "/ ") + stat, err := s.client.StatObject(s.config.Bucket, objectName, minio.StatObjectOptions{}) + if err != nil { + // 文件不存在不算错误,返回 nil, nil + if isMinioNotFound(err) { + return nil, nil + } + log.Errorf("stat object from minio error:%s", err.Error()) + return nil, err + } + + info := &ObjectInfo{ + Size: stat.Size, + Hash: stat.ETag, + MimeType: stat.ContentType, + PutTime: stat.LastModified.Unix(), + } + return info, nil } -func (s *Minio) Fetch(url, objectName string) error { +// isMinioNotFound 检查是否为 MinIO 文件不存在错误 +func isMinioNotFound(err error) bool { + if err == nil { + return false + } + // MinIO 返回 NoSuchKey 表示文件不存在 + if respErr, ok := err.(minio.ErrorResponse); ok { + return respErr.Code == "NoSuchKey" || respErr.Code == "NotFound" + } + return false +} + +func (s *Minio) Fetch(urlStr, objectName string, local ...bool) error { + if err := s.Init(); err != nil { + return err + } + + // 从 URL 下载文件 + resp, err := http.Get(urlStr) + if err != nil { + log.Errorf("fetch file from url error:%s", err.Error()) + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch file from url failed, status: %d", resp.StatusCode) + } + + // 获取文件名和 Content-Type + objectName = strings.TrimLeft(objectName, "/ ") + // 优先根据文件扩展名推断 Content-Type,确保图片能正确预览 + ext := strings.TrimLeft(path.Ext(objectName), ".") + contentType := ext2ContentType(ext) + if contentType == "" { + contentType = resp.Header.Get("Content-Type") + } + + // 上传到 MinIO + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + + _, err = s.client.PutObjectWithContext(ctx, s.config.Bucket, objectName, resp.Body, resp.ContentLength, + minio.PutObjectOptions{ContentType: contentType}) + if err != nil { + log.Errorf("upload fetched file to minio error:%s", err.Error()) + return err + } + return nil } diff --git a/storage/qiniu.go b/storage/qiniu.go index dfb8ae7..997976a 100644 --- a/storage/qiniu.go +++ b/storage/qiniu.go @@ -4,10 +4,13 @@ import ( "context" "errors" "fmt" + "os" + "path" + "strings" + "time" + "github.com/qiniu/go-sdk/v7/auth/qbox" "github.com/qiniu/go-sdk/v7/storage" - "os" - "time" ) type QiniuConfig struct { @@ -152,7 +155,7 @@ func (s *Qiniu) Get(objectName, fileName string) error { return Download(url, fileName) } -func (s *Qiniu) Url(objectName string, expire time.Duration) string { +func (s *Qiniu) Url(objectName string, expire time.Duration, https ...bool) string { mac := qbox.NewMac(s.config.AK, s.config.SK) cfg := storage.Config{ UseHTTPS: false, @@ -166,8 +169,11 @@ func (s *Qiniu) Url(objectName string, expire time.Duration) string { if len(domains) <= 0 { return "" } + domain := "http://" + domains[0].Domain + if len(https) > 0 && https[0] { + domain = "https://" + domains[0].Domain + } - domain := "https://" + domains[0].Domain if expire == 0 { return storage.MakePublicURLv2(domain, objectName) } else { @@ -185,6 +191,10 @@ func (s *Qiniu) Stat(objectName string) (*ObjectInfo, error) { bucketManager := storage.NewBucketManager(mac, &cfg) fileInfo, err := bucketManager.Stat(s.config.Bucket, objectName) if err != nil { + // 文件不存在不算错误,返回 nil, nil + if isQiniuNotFound(err) { + return nil, nil + } return nil, err } @@ -196,7 +206,22 @@ func (s *Qiniu) Stat(objectName string) (*ObjectInfo, error) { return info, nil } -func (s *Qiniu) Fetch(url, objectName string) error { +// isQiniuNotFound 检查是否为七牛云文件不存在错误 +func isQiniuNotFound(err error) bool { + if err == nil { + return false + } + // 七牛云错误码 612 表示文件不存在 + if respErr, ok := err.(*storage.ErrorInfo); ok { + return respErr.Code == 612 + } + return false +} + +func (s *Qiniu) Fetch(url, objectName string, local ...bool) error { + if len(local) > 0 && local[0] == true { + return s.fetchLocal(url, objectName) + } mac := qbox.NewMac(s.config.AK, s.config.SK) cfg := storage.Config{ UseHTTPS: false, @@ -211,3 +236,19 @@ func (s *Qiniu) Fetch(url, objectName string) error { return nil } + +func (s *Qiniu) fetchLocal(url, objectName string) error { + objectName = strings.TrimLeft(objectName, "/ ") + ext := strings.TrimLeft(path.Ext(objectName), ".") + + tmpFile := fmt.Sprintf("%d.%s", time.Now().UnixMilli(), ext) + if err := Download(url, tmpFile); err != nil { + return err + } + defer os.Remove(tmpFile) + + if err := s.Put(tmpFile, objectName, nil); err != nil { + return err + } + return nil +} diff --git a/storage/storage.go b/storage/storage.go index cf5e329..22436bb 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -13,8 +13,8 @@ type Storage interface { Put(fileName, objectName string, onProcess func(fsize, uploaded int64)) error Get(objectName, fileName string) error Del(objectName string) error - Url(objectName string, expire time.Duration) string + Url(objectName string, expire time.Duration, https ...bool) string //https参数是否生成https参数 Stat(objectName string) (*ObjectInfo, error) List(objectPrefix string) ([]string, error) - Fetch(url, objectName string) error + Fetch(url, objectName string, local ...bool) error //local参数决定是否先下载到本地在上传,七牛云下载企业微信的文件需要不能直接下载 } diff --git a/storage/util.go b/storage/util.go index de9ad2c..a99f44f 100644 --- a/storage/util.go +++ b/storage/util.go @@ -21,16 +21,116 @@ func contentType2Ext(contentType string) string { func ext2ContentType(ext string) string { ext = strings.ToLower(ext) - if ext == "jpg" || ext == "jpeg" { + switch ext { + // 图片格式 + case "jpg", "jpeg": return "image/jpeg" - } else if ext == "png" { + case "png": return "image/png" - } else if ext == "mp3" { + case "gif": + return "image/gif" + case "webp": + return "image/webp" + case "bmp": + return "image/bmp" + case "svg", "svgz": + return "image/svg+xml" + case "ico": + return "image/x-icon" + case "tiff", "tif": + return "image/tiff" + case "avif": + return "image/avif" + case "apng": + return "image/apng" + // 音频格式 + case "mp3": return "audio/mpeg" - } else if ext == "mp4" { + case "wav": + return "audio/wav" + case "ogg": + return "audio/ogg" + case "flac": + return "audio/flac" + case "aac": + return "audio/aac" + case "m4a": + return "audio/mp4" + // 视频格式 + case "mp4": return "video/mp4" + case "avi": + return "video/x-msvideo" + case "mov", "qt": + return "video/quicktime" + case "webm": + return "video/webm" + case "flv": + return "video/x-flv" + case "mkv": + return "video/x-matroska" + case "wmv": + return "video/x-ms-wmv" + case "m3u8": + return "application/vnd.apple.mpegurl" + case "ts": + return "video/mp2t" + // 文档格式 + case "pdf": + return "application/pdf" + case "doc": + return "application/msword" + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case "xls": + return "application/vnd.ms-excel" + case "xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case "ppt": + return "application/vnd.ms-powerpoint" + case "pptx": + return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case "txt": + return "text/plain" + case "html", "htm": + return "text/html" + case "css": + return "text/css" + case "js": + return "application/javascript" + case "json": + return "application/json" + case "xml": + return "application/xml" + case "zip": + return "application/zip" + case "rar": + return "application/x-rar-compressed" + case "7z": + return "application/x-7z-compressed" + case "gz": + return "application/gzip" + default: + return "" } - return "" +} + +// detectContentType 从文件内容检测 MIME 类型 +func detectContentType(filePath string) string { + f, err := os.Open(filePath) + if err != nil { + return "" + } + defer f.Close() + + // 读取前 512 字节用于检测 + buf := make([]byte, 512) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return "" + } + + return http.DetectContentType(buf[:n]) } func Download(url, path string) error { diff --git a/unify/pay.go b/unify/pay.go index b854753..25abe72 100644 --- a/unify/pay.go +++ b/unify/pay.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "fmt" - "git.u8t.cn/open/gosdk/util" "net/url" + + "git.u8t.cn/open/gosdk/util" + "git.u8t.cn/open/goutil" ) var ( @@ -177,7 +179,7 @@ func (p *Pay) RefundOrder(req *RefundOrderReq) error { errors.New("outTradeNo is nil") } - reqUrl := fmt.Sprintf("%s/api/pay/order?outTradeNo=%s&reason=%s&refundFee=%d", p.address, req.OutTradeNo, url.QueryEscape(req.Reason), req.RefundFee) + reqUrl := fmt.Sprintf("%s/api/pay/order?outTradeNo=%s&reason=%s&refundFee=%d&refundTarget=%s", p.address, req.OutTradeNo, url.QueryEscape(req.Reason), req.RefundFee, req.RefundTarget) result, err := util.HttpDelete(reqUrl, map[string]string{ "x-token": p.token, }) @@ -219,3 +221,27 @@ func (p *Pay) RefundPartnerOrder(req *RefundOrderReq) error { return nil } + +func (p *Pay) Transfer(req *TransferReq) error { + if err := req.Check(); err != nil { + return err + } + + reqUrl := fmt.Sprintf("%s/api/pay/transfer?", p.address) + result, err := util.HttpPostJson(reqUrl, map[string]string{ + "x-token": p.token, + }, []byte(goutil.EncodeJSON(req))) + + if err != nil { + return err + } + var rsp CommonResponse + if err := json.Unmarshal([]byte(result), &rsp); err != nil { + return err + } + if rsp.Code != 0 { + return fmt.Errorf("%d:%s", rsp.Code, rsp.Message) + } + + return nil +} diff --git a/unify/pay_type.go b/unify/pay_type.go index dbf43b2..89eb630 100644 --- a/unify/pay_type.go +++ b/unify/pay_type.go @@ -1,5 +1,7 @@ package unify +import "errors" + type OrderUser struct { UserId string `json:"userId"` UserName string `json:"userName"` @@ -46,9 +48,19 @@ type CreatePartnerOrderReq struct { } type RefundOrderReq struct { + OutTradeNo string `json:"outTradeNo"` + Reason string `json:"reason,omitempty"` + RefundFee int64 `json:"refundFee,omitempty"` + RefundTarget string `json:"refundTarget,omitempty"` +} + +type TransferReq struct { + PayAmount int64 `json:"payAmount"` + PayType string `json:"payType"` + PayTitle string `json:"payTitle"` + PayChannel string `json:"payChannel"` + UserId string `json:"userId"` OutTradeNo string `json:"outTradeNo"` - Reason string `json:"reason,omitempty"` - RefundFee int64 `json:"refundFee,omitempty"` } type CommonResponse struct { @@ -56,3 +68,19 @@ type CommonResponse struct { Message string `json:"message"` Data map[string]interface{} `json:"data"` } + +func (r *TransferReq) Check() error { + if r.PayTitle == "" { + return errors.New("PayTitle is nil") + } + if r.PayType == "" { + return errors.New("PayType is nil") + } + if r.UserId == "" { + return errors.New("UserId is nil") + } + if r.PayAmount < 0 { + return errors.New("PayAmount is nil") + } + return nil +} diff --git a/util/http.go b/util/http.go index 2b006b2..7194d7f 100644 --- a/util/http.go +++ b/util/http.go @@ -9,26 +9,36 @@ import ( "time" ) +var ( + httpClient *http.Client +) + +func init() { + httpClient = &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 200, + MaxIdleConnsPerHost: 50, + MaxConnsPerHost: 500, + IdleConnTimeout: 90 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } +} + // PostJson 请求 func HttpPostJson(link string, header map[string]string, json []byte) ([]byte, error) { - client := &http.Client{Timeout: 20 * time.Second} - //忽略https的证书 - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - req, err := http.NewRequest("POST", link, bytes.NewBuffer(json)) if err != nil { return nil, err } - if header != nil { - for k, v := range header { - req.Header.Add(k, v) - } + + for k, v := range header { + req.Header.Add(k, v) } req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } @@ -41,24 +51,17 @@ func HttpPostJson(link string, header map[string]string, json []byte) ([]byte, e // PostJson 请求 func HttpPutJson(link string, header map[string]string, json []byte) ([]byte, error) { - client := &http.Client{Timeout: 20 * time.Second} - //忽略https的证书 - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - req, err := http.NewRequest("PUT", link, bytes.NewBuffer(json)) if err != nil { return nil, err } - if header != nil { - for k, v := range header { - req.Header.Add(k, v) - } + + for k, v := range header { + req.Header.Add(k, v) } req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } @@ -71,22 +74,15 @@ func HttpPutJson(link string, header map[string]string, json []byte) ([]byte, er // Get 请求 link:请求url func HttpGet(link string, header map[string]string) ([]byte, error) { - client := &http.Client{Timeout: 20 * time.Second} - //忽略https的证书 - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - req, err := http.NewRequest("GET", link, nil) if err != nil { return nil, err } - if header != nil { - for k, v := range header { - req.Header.Add(k, v) - } + + for k, v := range header { + req.Header.Add(k, v) } - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } @@ -99,22 +95,15 @@ func HttpGet(link string, header map[string]string) ([]byte, error) { // Get 请求 link:请求url func HttpDelete(link string, header map[string]string) ([]byte, error) { - client := &http.Client{Timeout: 20 * time.Second} - //忽略https的证书 - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - req, err := http.NewRequest("DELETE", link, nil) if err != nil { return nil, err } - if header != nil { - for k, v := range header { - req.Header.Add(k, v) - } + + for k, v := range header { + req.Header.Add(k, v) } - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, err } diff --git a/weixin/base.go b/weixin/base.go index 2b561cc..17f4092 100644 --- a/weixin/base.go +++ b/weixin/base.go @@ -2,11 +2,15 @@ package weixin import ( "fmt" - "github.com/tidwall/gjson" "io" "net/http" "sync" "time" + + cache2 "git.u8t.cn/open/gosdk/cache" + "github.com/go-redis/redis" + uuid2 "github.com/google/uuid" + "github.com/tidwall/gjson" ) const ( @@ -87,6 +91,65 @@ func (o *BaseSdk) getAccessToken() (string, error) { return o.accessToken, nil } +// redis share version +func (o *BaseSdk) getAccessToken2() (string, error) { + redix := o.getRedis() + tokenKey := fmt.Sprintf("weixin_oa_access_token_v2_%s", o.appid) + lockKey := fmt.Sprintf("get_access_token_unique_%s", o.appid) + uuid := uuid2.New().String() + + // get token + token := redix.Get(tokenKey).Val() + if token != "" { + return token, nil + } + + // setEX and expire + // if not set key. wait 200ms and get again and retry 5 times + if ok := redix.SetNX(lockKey, uuid, 10*time.Second).Val(); !ok { + for i := range 5 { + time.Sleep(time.Duration(200*(i+2)) * time.Millisecond) + if token = redix.Get(tokenKey).Val(); token != "" { + return token, nil + } + } + return "", fmt.Errorf(" wait 5 times,but still not get token") + } + + defer func() { + if redix.Get(lockKey).Val() == uuid { + redix.Del(lockKey) + } + }() + + // get new token + url := fmt.Sprintf("%s?grant_type=client_credential&appid=%s&secret=%s", accessTokenUrl, o.appid, o.secret) + res, err := http.Get(url) + if err != nil { + return "", err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + g := gjson.ParseBytes(body) + + token = g.Get("access_token").String() + if token == "" { + return "", fmt.Errorf("%d:%s", g.Get("errcode").Int(), g.Get("errmsg").String()) + } + + expire := g.Get("expires_in").Int() - 10 + redix.Set(tokenKey, token, time.Second*time.Duration(expire)) + + return token, nil +} + +func (o *BaseSdk) getRedis() *redis.Client { + return cache2.GetRedis() +} + func (o *BaseSdk) getUserInfoByCode(code string, auth bool) (*UserInfo, error) { url := fmt.Sprintf("%s?appid=%s&secret=%s&code=%s&grant_type=authorization_code", code2AccessTokenUrl, o.appid, o.secret, code) diff --git a/weixin/oa_sdk.go b/weixin/oa_sdk.go index 0adb6fe..7698358 100644 --- a/weixin/oa_sdk.go +++ b/weixin/oa_sdk.go @@ -4,11 +4,12 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/tidwall/gjson" "io" "mime/multipart" "net/http" "time" + + "github.com/tidwall/gjson" ) type OaSdk struct { @@ -35,7 +36,7 @@ func (o *OaSdk) GetQrCode(sceneStr string) (string, error) { } marshal, _ := json.Marshal(params) - accessToken, err := o.getAccessToken() + accessToken, err := o.getAccessToken2() if err != nil { return "", err } @@ -68,7 +69,7 @@ func (o *OaSdk) GetUserInfoByCodeNoAuth(code string) (*UserInfo, error) { } func (o *OaSdk) GetUserInfoByOpenid(openid string) (*UserInfo, error) { - accessToken, err := o.getAccessToken() + accessToken, err := o.getAccessToken2() if err != nil { return nil, err } @@ -115,7 +116,7 @@ func (o *OaSdk) UploadFileByByte(file []byte, ext string) (string, string, error if err != nil { return "", "", err } - token, err := o.getAccessToken() + token, err := o.getAccessToken2() if err != nil { return "", "", err } @@ -155,7 +156,7 @@ func (o *OaSdk) UploadFileByByte(file []byte, ext string) (string, string, error } func (o *OaSdk) CreateMenu(buttons []*OaMenuButton) error { - accessToken, err := o.getAccessToken() + accessToken, err := o.getAccessToken2() if err != nil { return err } @@ -182,7 +183,7 @@ func (o *OaSdk) CreateMenu(buttons []*OaMenuButton) error { } func (o *OaSdk) DeleteMenu() error { - accessToken, err := o.getAccessToken() + accessToken, err := o.getAccessToken2() if err != nil { return err }