├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── common ├── app │ ├── app.go │ ├── convert.go │ ├── form.go │ ├── jwt.go │ ├── pager.go │ ├── pagination.go │ ├── result.go │ ├── sorter.go │ └── time_interval.go ├── delay_queue │ ├── bucket.go │ ├── delay_queue.go │ ├── delay_queue_test.go │ ├── job.go │ ├── ready_queue.go │ ├── redis.go │ └── util.go ├── ecode │ ├── biz.go │ ├── codes.go │ ├── common.go │ ├── ecode.go │ ├── ecode_test.go │ └── example_test.go ├── id_generator │ └── snowflake.go ├── log │ └── log.go ├── redis │ └── redis.go ├── session │ └── session.go ├── storage │ ├── cos.go │ ├── local.go │ ├── oss.go │ ├── oss_test.go │ ├── storage.go │ └── util.go ├── util │ ├── aes.go │ ├── data_structure.go │ ├── encrypt.go │ ├── jwt.go │ ├── security.go │ ├── session.go │ ├── time.go │ ├── util.go │ ├── util_test.go │ └── validate.go └── validator │ └── custom.go ├── conf ├── config.example.yaml └── config.go ├── constants ├── attachment.go ├── cache.go ├── chat_msg.go ├── common.go ├── contact_way.go ├── customer.go ├── customer_event.go ├── customer_info.go ├── customer_statistic.go ├── data_export.go ├── env.go ├── ext_staff_filter.go ├── field.go ├── group_chat.go ├── group_chat_auto_create.go ├── mass_msg.go ├── msg_type.go ├── notifier.go ├── permission.go ├── quick_reply.go ├── role.go ├── seed.go ├── session.go ├── sort.go ├── staff.go ├── storage.go ├── time.go └── topic.go ├── controller └── msg_arch.go ├── docker-compose.yml ├── go.mod ├── lib ├── WeWorkFinanceSdk.dll ├── WeWorkFinanceSdk.lib ├── WeWorkFinanceSdk_C.h ├── libWeWorkFinanceSdk_C.so ├── libcrypto-1_1-x64.dll ├── libcurl-x64.dll ├── libssl-1_1-x64.dll ├── tool_testSdk.cpp └── version.txt ├── main.go ├── models ├── chat_msg.go ├── customer.go ├── customer_staff.go ├── customer_staff_tag.go ├── department.go ├── external_profile.go ├── internal_tag.go ├── model.go └── staff.go ├── pkg ├── client_flags.go └── go-workwx-develop │ ├── apis.go │ ├── app_chat.go │ ├── app_chat_api.go │ ├── app_chat_model.go │ ├── callback.go │ ├── client.go │ ├── client_options.go │ ├── contact_way.go │ ├── contact_way_api.go │ ├── contact_way_model.go │ ├── department_info.go │ ├── department_info_api.go │ ├── department_info_model.go │ ├── errcodes │ └── ecode.go │ ├── errors.go │ ├── external_contact.go │ ├── external_contact_api.go │ ├── external_contact_model.go │ ├── group_chat.go │ ├── group_chat_api.go │ ├── group_chat_model.go │ ├── internal │ ├── apicodegen │ │ ├── api_code.tmpl │ │ └── main.go │ └── lowlevel │ │ ├── doc.go │ │ ├── encryptor │ │ └── mod.go │ │ ├── envelope │ │ ├── ctor_options.go │ │ ├── mod.go │ │ ├── models.go │ │ └── time_source.go │ │ ├── httpapi │ │ ├── echo_test_api.go │ │ ├── event_api.go │ │ └── mod.go │ │ ├── pkcs7 │ │ └── mod.go │ │ └── signature │ │ └── mod.go │ ├── mass_msg.go │ ├── mass_msg_api.go │ ├── mass_msg_model.go │ ├── media.go │ ├── media_api.go │ ├── media_model.go │ ├── message.go │ ├── message_api.go │ ├── message_model.go │ ├── models.go │ ├── msg_audit.go │ ├── msg_audit_api.go │ ├── msg_audit_model.go │ ├── oa.go │ ├── oa_api.go │ ├── oa_model.go │ ├── recipient.go │ ├── rx.go │ ├── rx_msg.go │ ├── rx_msg_extras.go │ ├── rx_msg_model.go │ ├── tag.go │ ├── tag_api.go │ ├── tag_model.go │ ├── token.go │ ├── traits.go │ ├── user_info.go │ ├── user_info_api.go │ ├── user_info_helper.go │ ├── user_info_model.go │ ├── welcome_msg.go │ ├── welcome_msg_api.go │ └── welcome_msg_model.go ├── requests ├── clue_manual.go ├── common.go ├── contact_way.go ├── contact_way_group.go ├── customer.go ├── customer_group.go ├── customer_group_tag.go ├── customer_group_tag_group.go ├── customer_remark.go ├── customer_staff.go ├── customer_statistic.go ├── department.go ├── event_list.go ├── group_chat.go ├── group_chat_mass_msg.go ├── group_chat_welcome_msg.go ├── internal_tag.go ├── js_api.go ├── material_lib_tag.go ├── msg_arch.go ├── query_welcome_msg.go ├── quick_reply.go ├── quick_reply_group.go ├── staff.go ├── storage.go ├── tag.go ├── upload_quick_reply_file.go └── util.go ├── responses ├── msg_arch.go └── resp.go └── services └── msg_arch.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | conf/config.yaml 3 | conf/config.test.yaml 4 | conf/*.key 5 | conf/*.txt 6 | demo 7 | deploy.* 8 | test/my*.go 9 | test/conf/config.yaml 10 | docs/swagger.json 11 | docs/swagger.yaml 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM golang:latest 3 | WORKDIR /data/xjyk 4 | ADD . . 5 | ENV GOPROXY="https://goproxy.cn" 6 | ENV CGO_ENBLED 1 7 | RUN go mod download 8 | ENV LD_LIBRARY_PATH ${LD_LIBRARY_PATH}:/data/xjyk/lib 9 | 10 | RUN go build -ldflags="-s -w" -installsuffix cgo -o msg-arch . 11 | CMD ["./msg-arch"] 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 |

4 | 5 |

6 | 安全,强大,易开发的企业微信SCRM 7 |

8 | 9 | [安装](#如何安装) | 10 | 11 | ### 项目简介 12 | 13 | > 此项目为OpenSCRM **会话存档服务** 项目 14 | 15 | ### 如何安装 16 | ### 重要提示!!! 17 | 由于依赖腾讯官方.so文件,仅支持Linux下编译,windows下可使用wsl2 18 | #### 设置环境变量 19 | ```bash 20 | export LD_LIBRARY_PATH=$(pwd)/lib 21 | export GOPROXY=https://proxy.golang.com.cn,direct 22 | ``` 23 | - 复制粘贴api-server的配置 24 | - 编译 25 | ```bash 26 | CGO_ENABLED=1 go build -o msg-arch-server main.go 27 | ``` 28 | 29 | ### 联系作者 30 | 31 | 32 | 33 | 扫码可加入交流群 34 | 35 | ### 版权声明 36 | 37 | OpenSCRM遵循Apache2.0协议,可免费商用 38 | -------------------------------------------------------------------------------- /common/app/convert.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "strconv" 4 | 5 | type StrTo string 6 | 7 | func (s StrTo) String() string { 8 | return string(s) 9 | } 10 | 11 | func (s StrTo) Int() (int, error) { 12 | v, err := strconv.Atoi(s.String()) 13 | return v, err 14 | } 15 | 16 | func (s StrTo) MustInt() int { 17 | v, _ := s.Int() 18 | return v 19 | } 20 | 21 | func (s StrTo) UInt32() (uint32, error) { 22 | v, err := strconv.Atoi(s.String()) 23 | return uint32(v), err 24 | } 25 | 26 | func (s StrTo) MustUInt32() uint32 { 27 | v, _ := s.UInt32() 28 | return v 29 | } 30 | -------------------------------------------------------------------------------- /common/app/form.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ut "github.com/go-playground/universal-translator" 6 | val "github.com/go-playground/validator/v10" 7 | "github.com/pkg/errors" 8 | "msg/common/ecode" 9 | "msg/common/log" 10 | "msg/pkg/go-workwx-develop" 11 | "net/http" 12 | "strings" 13 | ) 14 | 15 | type ValidError struct { 16 | Key string 17 | Message string 18 | } 19 | 20 | var vt *ut.Translator 21 | 22 | func NewBindingValidator(trans *ut.Translator) { 23 | vt = trans 24 | } 25 | 26 | type ValidErrors []*ValidError 27 | 28 | func (v *ValidError) Error() string { 29 | return v.Message 30 | } 31 | 32 | func (v ValidErrors) Error() string { 33 | return strings.Join(v.Errors(), ",") 34 | } 35 | 36 | func (v ValidErrors) Errors() []string { 37 | var errs []string 38 | for _, err := range v { 39 | errs = append(errs, err.Error()) 40 | } 41 | 42 | return errs 43 | } 44 | 45 | func BindAndValid(c *gin.Context, v interface{}) ValidErrors { 46 | var errs ValidErrors 47 | err := c.ShouldBind(v) 48 | if err != nil { 49 | verrs, ok := err.(val.ValidationErrors) 50 | if !ok { 51 | errs = append(errs, &ValidError{ 52 | Key: "", 53 | Message: err.Error(), 54 | }) 55 | return errs 56 | } 57 | 58 | trans, _ := (*vt).(ut.Translator) 59 | for key, value := range verrs.Translate(trans) { 60 | errs = append(errs, &ValidError{ 61 | Key: key, 62 | Message: value, 63 | }) 64 | } 65 | //return false, errs 66 | return errs 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func ResponseErr(c *gin.Context, err error) { 73 | //检查wrap过的错误 74 | rootErr := errors.Cause(err) //获取根错误 75 | 76 | // 微信返回的错误转换为ecode错误码 77 | if clientError, ok := rootErr.(*workwx.ClientError); ok { 78 | rootErr = ecode.Int(int(clientError.Code)) 79 | } 80 | 81 | //如果根错误是自定义错误码(可控错误) 82 | if e, ok := rootErr.(ecode.Code); ok { 83 | //当根错误是自定义错误时 84 | //自定义内部错误,响应http 500 85 | if e.IsInternalError() { 86 | Response(c, http.StatusInternalServerError, e.Code(), nil, e.Message()) 87 | log.TracedError("InternalServerError", err) 88 | } else { 89 | //自定义非内部错误,响应http 200 90 | Response(c, http.StatusOK, e.Code(), nil, e.Message()) 91 | log.TracedError("BizError", err) 92 | } 93 | return 94 | } 95 | 96 | //如果根错误是系统错误(不可控错误) 97 | if _, ok := rootErr.(error); ok { 98 | Response(c, http.StatusInternalServerError, 500, nil, err.Error()) 99 | log.TracedError("InternalServerError", err) 100 | return 101 | } 102 | 103 | //没有wrap过的系统错误 104 | c.Error(err) 105 | Response(c, http.StatusInternalServerError, ecode.InternalError.Code(), nil, err.Error()) 106 | log.TracedError("InternalServerError", err) 107 | 108 | } 109 | func Response(c *gin.Context, httpCode int, errorCode int, data interface{}, msg string) { 110 | c.Header("Content-Type", "application/json") 111 | c.JSON(httpCode, JSONResult{ 112 | Code: errorCode, 113 | Message: msg, 114 | Data: data, 115 | }) 116 | return 117 | } 118 | func ResponseItems(c *gin.Context, items interface{}, totalRows int64) { 119 | c.JSON(http.StatusOK, JSONResult{ 120 | Code: 0, 121 | Message: "", 122 | Data: ItemsData{ 123 | Items: items, 124 | Pager: Pager{Page: GetPage(c), PageSize: GetPageSize(c), TotalRows: totalRows}, 125 | }}, 126 | ) 127 | } 128 | func ResponseItem(c *gin.Context, item interface{}) { 129 | c.JSON(http.StatusOK, JSONResult{ 130 | Code: 0, 131 | Message: "ok", 132 | Data: item, 133 | }, 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /common/app/jwt.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | // 4 | //import ( 5 | // "time" 6 | // 7 | // "github.com/go-programming-tour-book/blog-service/pkg/util" 8 | // 9 | // "github.com/dgrijalva/jwt-go" 10 | // "github.com/go-programming-tour-book/blog-service/global" 11 | //) 12 | // 13 | //type Claims struct { 14 | // AppKey string `json:"app_key"` 15 | // AppSecret string `json:"app_secret"` 16 | // jwt.StandardClaims 17 | //} 18 | // 19 | //func GetJWTSecret() []byte { 20 | // return []byte(global.JWTSetting.Secret) 21 | //} 22 | // 23 | //func GenerateToken(appKey, appSecret string) (string, error) { 24 | // nowTime := time.Now() 25 | // expireTime := nowTime.Add(global.JWTSetting.Expire) 26 | // claims := Claims{ 27 | // AppKey: util.EncodeMD5(appKey), 28 | // AppSecret: util.EncodeMD5(appSecret), 29 | // StandardClaims: jwt.StandardClaims{ 30 | // ExpiresAt: expireTime.Seconds(), 31 | // Issuer: global.JWTSetting.Issuer, 32 | // }, 33 | // } 34 | // 35 | // tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 36 | // token, err := tokenClaims.SignedString(GetJWTSecret()) 37 | // return token, err 38 | //} 39 | // 40 | //func ParseToken(token string) (*Claims, error) { 41 | // tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) { 42 | // return GetJWTSecret(), nil 43 | // }) 44 | // if err != nil { 45 | // return nil, err 46 | // } 47 | // if tokenClaims != nil { 48 | // claims, ok := tokenClaims.Claims.(*Claims) 49 | // if ok && tokenClaims.Valid { 50 | // return claims, nil 51 | // } 52 | // } 53 | // 54 | // return nil, err 55 | //} 56 | -------------------------------------------------------------------------------- /common/app/pager.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type Pager struct { 4 | // Page 页码 5 | Page int `json:"page" form:"page" validate:"gte=0" gorm:"-"` 6 | // PageSize 每页数量 7 | PageSize int `json:"page_size" form:"page_size" validate:"gte=0" gorm:"-"` 8 | // TotalRows 总行数 9 | TotalRows int64 `json:"total_rows" gorm:"-"` 10 | } 11 | 12 | func (o *Pager) SetDefault() *Pager { 13 | if o.Page <= 0 || o.Page > 1000000 { 14 | o.Page = 1 15 | } 16 | if o.PageSize <= 0 || o.PageSize > 1000000 { 17 | o.PageSize = 10 18 | } 19 | return o 20 | } 21 | 22 | func (o *Pager) GetOffset() int { 23 | return (o.Page - 1) * o.PageSize 24 | } 25 | 26 | func (o *Pager) GetLimit() int { 27 | return o.PageSize 28 | } 29 | -------------------------------------------------------------------------------- /common/app/pagination.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func GetPage(c *gin.Context) int { 8 | page := StrTo(c.Query("page")).MustInt() 9 | if page <= 0 { 10 | return 1 11 | } 12 | 13 | return page 14 | } 15 | 16 | func GetPageSize(c *gin.Context) int { 17 | pageSize := StrTo(c.Query("page_size")).MustInt() 18 | if pageSize <= 0 { 19 | return 10 20 | } 21 | if pageSize > 1000000 { 22 | return 1000000 23 | } 24 | return pageSize 25 | } 26 | 27 | func GetPageOffset(page, pageSize int) int { 28 | result := 0 29 | if page > 0 { 30 | result = (page - 1) * pageSize 31 | } 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /common/app/result.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type JSONResult struct { 4 | // Code 响应状态码 5 | Code int `json:"code"` 6 | // Message 响应消息 7 | Message string `json:"message"` 8 | // Data 响应数据 9 | Data interface{} `json:"data"` 10 | } 11 | 12 | type ItemsData struct { 13 | // Items 数据列表 14 | Items interface{} `json:"items"` 15 | // Pager 列表分页信息 16 | Pager Pager `json:"pager"` 17 | } 18 | -------------------------------------------------------------------------------- /common/app/sorter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "msg/constants" 5 | ) 6 | 7 | type Sorter struct { 8 | // SortField 排序字段,id created_at updated_at sort_weight total today_join_cnt today_drop_out_cnt relation_delete_at relation_create_at 9 | SortField constants.SortField `form:"sort_field" json:"sort_field" gorm:"-" validate:"omitempty,oneof=id created_at updated_at sort_weight add_customer_count total today_join_cnt today_drop_out_cnt createtime customer_delete_staff_at relation_delete_at relation_create_at in_connection_time_range order today_join_member_num today_quit_member_num create_time"` 10 | // SortType 排序类型,asc desc 11 | SortType constants.SortType `form:"sort_type" json:"sort_type" gorm:"-" validate:"omitempty,oneof=asc desc"` 12 | } 13 | 14 | func (o *Sorter) SetDefault() *Sorter { 15 | if o.SortField == "" { 16 | o.SortField = constants.SortFieldID 17 | } 18 | if o.SortType == "" { 19 | o.SortType = constants.SortTypeDesc 20 | } 21 | return o 22 | } 23 | -------------------------------------------------------------------------------- /common/app/time_interval.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type TimeInterval struct { 4 | // 开始时间 5 | StartTime int64 `json:"start_time" form:"start_time" validate:"omitempty,gt=0"` 6 | // 结束时间 7 | EndTime int64 `json:"end_time" form:"end_time" validate:"omitempty,gtefield=StartTime"` 8 | } 9 | -------------------------------------------------------------------------------- /common/delay_queue/bucket.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v8" 6 | ) 7 | 8 | // BucketItem bucket中的元素 9 | type BucketItem struct { 10 | timestamp int64 11 | jobID string 12 | } 13 | 14 | // 添加JobId到bucket中 15 | func pushToBucket(key string, timestamp int64, jobId string) error { 16 | z := redis.Z{ 17 | Score: float64(timestamp), 18 | Member: jobId, 19 | } 20 | return Rdb.ZAdd(context.TODO(), key, &z).Err() 21 | } 22 | 23 | // 从bucket中获取延迟时间最小的JobId 24 | func getFromBucket(key string) (*BucketItem, error) { 25 | value, err := Rdb.ZRangeWithScores(context.Background(), key, 0, 0).Result() 26 | if err != nil { 27 | return nil, err 28 | } 29 | if value == nil || len(value) == 0 { 30 | return nil, nil 31 | } 32 | 33 | item := &BucketItem{} 34 | item.timestamp = int64(value[0].Score) 35 | item.jobID = (value[0].Member).(string) 36 | return item, nil 37 | } 38 | 39 | // 从bucket中删除JobId 40 | func removeFromBucket(bucket string, jobId string) error { 41 | return Rdb.ZRem(context.TODO(), bucket, jobId).Err() 42 | } 43 | -------------------------------------------------------------------------------- /common/delay_queue/delay_queue_test.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "log" 7 | "testing" 8 | "time" 9 | "xjyk/app/constants" 10 | setting "xjyk/conf" 11 | ) 12 | 13 | const topic = "test" 14 | const delayTime = 3 15 | 16 | func TestPush(t *testing.T) { 17 | setting.SetupSetting() 18 | NewRedisClient() 19 | SetupDelayQueue() 20 | job := Job{ 21 | Topic: topic, 22 | ID: "hao123", 23 | ExecuteAt: delayTime + time.Now().In(constants.PRCLocation).Unix(), 24 | TTR: 3, 25 | Body: "hi", 26 | } 27 | fmt.Println("b") 28 | //job.ExecuteAt = job.ExecuteAt + time.Now().Seconds() 29 | err := Add(job) 30 | fmt.Println("a") 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | time.Sleep(5 * time.Second) 35 | 36 | receivedJob, err := Listen(topic) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | assert.ObjectsAreEqual(job, receivedJob) 41 | } 42 | -------------------------------------------------------------------------------- /common/delay_queue/job.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "xjyk/app/constants" 7 | "xjyk/common/util" 8 | ) 9 | 10 | type Job struct { 11 | Topic constants.Topic `json:"topic"` 12 | ID string `json:"id"` // job唯一标识ID 13 | ExecuteAt int64 `json:"execute_at"` // 预定执行时间 14 | TTR int64 `json:"ttr"` // 轮询间隔 15 | FailedCount int64 `json:"failed_count"` // 失败次数 16 | Body string `json:"body"` 17 | } 18 | 19 | // 获取Job 20 | func getJob(key string) (job Job, err error) { 21 | value, err := Rdb.Get(context.TODO(), key).Result() 22 | if err != nil { 23 | return 24 | } 25 | err = json.Unmarshal([]byte(value), &job) 26 | if err != nil { 27 | return 28 | } 29 | 30 | return 31 | } 32 | 33 | // 添加Job 34 | func putJob(key string, job Job) error { 35 | err := Rdb.Do(context.TODO(), "set", key, util.JsonEncode(job)).Err() 36 | return err 37 | } 38 | 39 | // 更新Job 40 | func setJob(key string, job Job) error { 41 | err := Rdb.Do(context.TODO(), "setnx", key, util.JsonEncode(job)).Err() 42 | return err 43 | } 44 | 45 | // 删除Job 46 | func removeJob(key string) error { 47 | return Rdb.Del(context.Background(), key).Err() 48 | } 49 | -------------------------------------------------------------------------------- /common/delay_queue/ready_queue.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "msg/conf" 7 | "time" 8 | ) 9 | 10 | // 添加JobId到队列中 11 | func pushToReadyQueue(queueName string, jobId string) error { 12 | queueName = fmt.Sprintf(conf.Settings.DelayQueue.QueueName, queueName) 13 | return Rdb.RPush(context.TODO(), queueName, jobId).Err() 14 | } 15 | 16 | // 从队列中阻塞获取JobId 17 | func blockPopFromReadyQueue(queues []string, timeout int) (string, error) { 18 | var args []string 19 | for _, queue := range queues { 20 | queue = fmt.Sprintf(conf.Settings.DelayQueue.QueueName, queue) 21 | args = append(args, queue) 22 | } 23 | value, err := Rdb.BLPop(context.Background(), time.Duration(timeout)*time.Second, args...).Result() 24 | if err != nil { 25 | return "", err 26 | } 27 | if value == nil { 28 | return "", nil 29 | } 30 | if len(value) == 0 { 31 | return "", nil 32 | } 33 | element := value[1] 34 | 35 | return element, nil 36 | } 37 | -------------------------------------------------------------------------------- /common/delay_queue/redis.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v8" 6 | "xjyk/conf" 7 | ) 8 | 9 | var Rdb *redis.Client 10 | 11 | func NewRedisClient() { 12 | Rdb = redis.NewClient(&redis.Options{ 13 | Addr: conf.Settings.Redis.Host, 14 | Password: conf.Settings.Redis.Password, // no password set 15 | DB: 0, // use default DB 16 | ReadTimeout: conf.Settings.Redis.ReadTimeout, 17 | }) 18 | } 19 | 20 | // 执行redis命令, 执行完成后连接自动放回连接池 21 | func execRedisCommand(command string, args ...interface{}) (interface{}, error) { 22 | err := Rdb.Do(context.TODO(), command, args).Err() 23 | return nil, err 24 | } 25 | -------------------------------------------------------------------------------- /common/delay_queue/util.go: -------------------------------------------------------------------------------- 1 | package delay_queue 2 | 3 | import "msg/constants" 4 | 5 | func topicsToStrings(topics []constants.Topic) []string { 6 | strings := make([]string, 0) 7 | for _, topic := range topics { 8 | strings = append(strings, string(topic)) 9 | } 10 | return strings 11 | } 12 | -------------------------------------------------------------------------------- /common/ecode/biz.go: -------------------------------------------------------------------------------- 1 | package ecode 2 | 3 | // 定义业务错误 20000000 - 30000000 4 | var ( 5 | DuplicatedPhoneError = add(20000001) //重复手机号 6 | InvalidLoginError = add(20000002) //账号或密码错误 7 | DisabledUserError = add(20000003) //用户被禁用 8 | ForbiddenError = add(20000004) //无权访问 9 | DuplicatedCorpIDError = add(20000005) //重复CorpID 10 | DoNotDeleteYourSelfError = add(20000006) //不要删除自己 11 | InvalidSignError = add(20000007) //非法签名 12 | ExpiredSignError = add(20000008) //签名已过期 13 | InvalidPathError = add(20000009) //非法路径 14 | DoNotUpdateDefaultRoleError = add(20000010) //禁止修改默认角色 15 | InvalidCorpConfError = add(20000011) //不正确的企业配置信息 16 | 17 | DuplicateQuickReplyGroupNameError = add(20000100) //话术库组名重复,话术库业务错误 20000100 - 20000199 18 | 19 | NotifyTypeError = add(20000200) //删人通知提醒,通知提醒错误 20 | 21 | DuplicateTagError = add(20000300) // 重复标签, 标签错误 20000300 - 20000399 22 | DuplicateTagGroupError = add(20000301) // 重复标签组 23 | UnsupportedMsgError = add(20000400) // 不支持的消息类型, 群消息错误 20000400 - 20000499 24 | EarlierThanNowError = add(20000401) // 不支持的消息类型, 群消息错误 20000400 - 20000499 25 | TimedMsgUnchangeableError = add(20000402) 26 | UnsupportedFileTypeError = add(20000403) // 不支持的上传文件类型 27 | InfoFieldDuplicateError = add(20000500) // 客户信息字段重复, 客户信息错误 20000500 - 20000599 28 | DuplicateRemarkNameError = add(20000600) // 自定义客户信息字段名重复, 客户自定义信息错误 20000600 - 20000699 29 | GroupChatNotExistsError = add(20000700) // 自动拉群 20000700 30 | CheckSignFailed = add(20000800) // 内部服务调用错误码 31 | NoStaffError = add(20000900) // 内部服务调用错误码 32 | TimedMsgEarlierThanNowErr = add(20000901) // 定时发送消息不能比当前时间早 33 | IllegalURL = add(20001000) 34 | ParseFileUrlErr = add(20001001) 35 | FileNotExistsErr = add(20001002) 36 | NotImageFile = add(20002000) 37 | ) 38 | 39 | func init() { 40 | _commonMessage := map[int]Message{ 41 | DuplicatedPhoneError.Code(): { 42 | Msg: "重复手机号", 43 | }, 44 | InvalidLoginError.Code(): { 45 | Msg: "账号或密码错误", 46 | }, 47 | DisabledUserError.Code(): { 48 | Msg: "用户被禁用", 49 | }, 50 | ForbiddenError.Code(): { 51 | Msg: "无权访问", 52 | }, 53 | DuplicatedCorpIDError.Code(): { 54 | Msg: "系统已存在此CorpID", 55 | }, 56 | DoNotDeleteYourSelfError.Code(): { 57 | Msg: "不要删除自己", 58 | }, 59 | InvalidSignError.Code(): { 60 | Msg: "非法签名", 61 | }, 62 | ExpiredSignError.Code(): { 63 | Msg: "签名已过期", 64 | }, 65 | InvalidPathError.Code(): { 66 | Msg: "非法路径", 67 | }, 68 | DoNotUpdateDefaultRoleError.Code(): { 69 | Msg: "禁止修改默认角色", 70 | }, 71 | InvalidCorpConfError.Code(): { 72 | Msg: "不正确的企业配置信息", 73 | }, 74 | NotifyTypeError.Code(): { 75 | Msg: "通知时间类型错误", 76 | }, 77 | DuplicateTagGroupError.Code(): { 78 | Msg: "标签组重复", 79 | }, 80 | DuplicateTagError.Code(): { 81 | Msg: "标签重复", 82 | }, 83 | UnsupportedMsgError.Code(): { 84 | Msg: "不支持的消息类型", 85 | }, 86 | InfoFieldDuplicateError.Code(): { 87 | Msg: "取消展示/确认展示 包含重复字段", 88 | }, 89 | DuplicateRemarkNameError.Code(): { 90 | Msg: "自定义字段名重复", 91 | }, 92 | GroupChatNotExistsError.Code(): { 93 | Msg: "自动拉群分组不存在", 94 | }, 95 | CheckSignFailed.Code(): { 96 | Msg: "验签失败", 97 | }, 98 | NoStaffError.Code(): { 99 | Msg: "员工列表或者员工分组列表至少需要一个不为空", 100 | }, 101 | IllegalURL.Code(): { 102 | Msg: "URL 不正确", 103 | }, 104 | NotImageFile.Code(): { 105 | Msg: "文件不是图片格式", 106 | }, 107 | EarlierThanNowError.Code(): { 108 | Msg: "延迟发送时间不能比当前时间早", 109 | }, 110 | ParseFileUrlErr.Code(): { 111 | Msg: "上传url解析错误", 112 | }, 113 | FileNotExistsErr.Code(): { 114 | Msg: "文件不存在", 115 | }, 116 | TimedMsgUnchangeableError.Code(): { 117 | Msg: "立即发送的消息不支持修改", 118 | }, 119 | UnsupportedFileTypeError.Code(): { 120 | Msg: "不支持的上传文件类型", 121 | }, 122 | } 123 | 124 | for code, message := range _commonMessage { 125 | _messages[code] = message 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /common/ecode/common.go: -------------------------------------------------------------------------------- 1 | package ecode 2 | 3 | var ( 4 | // 定义系统错误 0 - 5000 5 | 6 | OK = add(0) // 正确 7 | InternalError = add(500) //内部错误 8 | UnknownError = add(404) //未知错误 9 | 10 | // 定义通用错误 10000000 - 20000000 11 | InternalServiceInvalidSignError = add(10000001) //内部服务验签失败 12 | MissingUserIDError = add(10000002) //缺失用户ID 13 | UserBusyError = add(10000003) //此用户正忙 14 | AccountNotFound = add(10000004) //账户未找到 15 | InvalidParams = add(10000005) //非法参数 16 | BadRequest = add(10000400) //非法请求 17 | NoPermissionError = add(10000401) //无权访问 18 | TokDetailExpiredError = add(10000402) //tokDetail过期 19 | InvalidTokDetailError = add(10000403) //非法tokDetail 20 | TooManyRequests = add(10000429) //请求过多 21 | SessExpiredError = add(10000501) //会话过期 22 | InvalidCipherError = add(10000502) //无效密文 23 | NoFollowError = add(10000503) //未关注公众号 24 | TooManyRequestsError = add(10000504) //请求过于频繁 25 | TokDetailRequiredError = add(10000404) //无效token 26 | ItemNotFoundError = add(10000886) //条目未找到 27 | InvalidSessionError = add(10000887) //无效会话 28 | ) 29 | 30 | func init() { 31 | _commonMessage := map[int]Message{ 32 | OK.Code(): { 33 | Msg: "成功", 34 | Detail: "success", 35 | }, 36 | InternalError.Code(): { 37 | Msg: "内部错误", 38 | Detail: "Internal Error", 39 | }, 40 | UnknownError.Code(): { 41 | Msg: "未知错误", 42 | Detail: "Unknown Error", 43 | }, 44 | InternalServiceInvalidSignError.Code(): { 45 | Msg: "内部服务验签失败", 46 | Detail: "Internal Service Invalid Signature Error", 47 | }, 48 | MissingUserIDError.Code(): { 49 | Msg: "缺失用户ID", 50 | Detail: "Missing UserID Error", 51 | }, 52 | UserBusyError.Code(): { 53 | Msg: "此用户正忙", 54 | Detail: "User Busy Error", 55 | }, 56 | UserBusyError.Code(): { 57 | Msg: "账户未找到", 58 | Detail: "Account Not Found Error", 59 | }, 60 | BadRequest.Code(): { 61 | Msg: "非法请求", 62 | Detail: "Bad Request", 63 | }, 64 | NoPermissionError.Code(): { 65 | Msg: "无权访问", 66 | Detail: "ForbiddDetail", 67 | }, 68 | InvalidParams.Code(): { 69 | Msg: "非法参数", 70 | Detail: "Invalid Params", 71 | }, 72 | TokDetailExpiredError.Code(): { 73 | Msg: "TokDetail过期", 74 | Detail: "TokDetail Expired", 75 | }, 76 | InvalidTokDetailError.Code(): { 77 | Msg: "无效TokDetail", 78 | Detail: "Invalid TokDetail", 79 | }, 80 | SessExpiredError.Code(): { 81 | Msg: "会话已过期", 82 | Detail: "Session Expired", 83 | }, 84 | InvalidCipherError.Code(): { 85 | Msg: "无效密文", 86 | Detail: "Invalid Cipher", 87 | }, 88 | NoFollowError.Code(): { 89 | Msg: "请先关注公众号", 90 | Detail: "No Follow Error", 91 | }, 92 | TooManyRequestsError.Code(): { 93 | Msg: "您的请求过于频繁,请休息一会儿", 94 | Detail: "Too Many Requests Error", 95 | }, 96 | ItemNotFoundError.Code(): { 97 | Msg: "未找到指定条目", 98 | }, 99 | TokDetailRequiredError.Code(): { 100 | Msg: "无效token", 101 | }, 102 | InvalidSessionError.Code(): { 103 | Msg: "无效会话", 104 | }, 105 | } 106 | 107 | for code, message := range _commonMessage { 108 | _messages[code] = message 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /common/ecode/ecode.go: -------------------------------------------------------------------------------- 1 | package ecode 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "net/http" 7 | "strconv" 8 | "sync" 9 | ) 10 | 11 | type Message struct { 12 | Msg string 13 | Detail string 14 | } 15 | 16 | var ( 17 | _codes = map[int]struct{}{} 18 | _mutex sync.Mutex 19 | _internalErrorCodeLimit = 5000 20 | Zh = "zh-CN" 21 | En = "en" 22 | ) 23 | 24 | //GetMessages 获取所有错误码消息 25 | func GetMessages() map[int]Message { 26 | return _messages 27 | } 28 | 29 | //RegisterMessages 注册错误码对应消息 30 | func RegisterMessages(messages map[int]Message) { 31 | _mutex.Lock() 32 | defer _mutex.Unlock() 33 | for k, v := range messages { 34 | _messages[k] = v 35 | } 36 | } 37 | 38 | //New 创建一个错误 39 | func New(e int) Code { 40 | if e <= _internalErrorCodeLimit { 41 | panic(fmt.Sprintf("business ecode must greater than %d", _internalErrorCodeLimit)) 42 | } 43 | return add(e) 44 | } 45 | 46 | //add 添加一个内部错误 47 | func add(e int) Code { 48 | if _, ok := _codes[e]; ok { 49 | panic(fmt.Sprintf("ecode: %d already exist", e)) 50 | } 51 | _codes[e] = struct{}{} 52 | return Int(e) 53 | } 54 | 55 | type Codes interface { 56 | Error() string 57 | Code() int 58 | Message() string 59 | Detail() string 60 | LocalizedMessage(lang string) string 61 | StatusCode() int 62 | } 63 | 64 | func (e Code) Detail() string { 65 | if m, ok := _messages[e.Code()]; ok { 66 | if m.Detail != "" { 67 | return m.Detail 68 | } 69 | } 70 | return strconv.FormatInt(int64(e.Code()), 10) 71 | } 72 | 73 | func (e Code) StatusCode() int { 74 | switch e.Code() { 75 | case OK.Code(): 76 | return http.StatusOK 77 | case InternalError.Code(): 78 | return http.StatusInternalServerError 79 | case InvalidParams.Code(): 80 | return http.StatusBadRequest 81 | case NoPermissionError.Code(): 82 | fallthrough 83 | case TokDetailExpiredError.Code(): 84 | fallthrough 85 | case InvalidTokDetailError.Code(): 86 | fallthrough 87 | case TokDetailRequiredError.Code(): 88 | return http.StatusUnauthorized 89 | case TooManyRequests.Code(): 90 | return http.StatusTooManyRequests 91 | } 92 | 93 | // 重试可解决的返回500 94 | // 需要更新请求再重试的200和具体业务error code 95 | return http.StatusOK 96 | } 97 | 98 | type Code int 99 | 100 | //Error 返回错误信息 101 | func (e Code) Error() string { 102 | return e.Message() 103 | } 104 | 105 | //Code 返回错误码 106 | func (e Code) Code() int { return int(e) } 107 | 108 | //LocalizedMessage 返回本地化的错误消息 109 | func (e Code) LocalizedMessage(lang string) string { 110 | if m, ok := _messages[e.Code()]; ok { 111 | if lang == Zh && m.Msg != "" { 112 | return m.Msg 113 | } 114 | return m.Msg 115 | } 116 | return strconv.FormatInt(int64(e.Code()), 10) 117 | } 118 | 119 | //Message 返回英文错误消息,或错误码 120 | func (e Code) Message() string { 121 | return e.LocalizedMessage(Zh) 122 | } 123 | 124 | //IsInternalError 检查是否是内部错误 125 | func (e Code) IsInternalError() bool { 126 | if e.Code() <= _internalErrorCodeLimit && e.Code() > 0 { 127 | return true 128 | } 129 | return false 130 | } 131 | 132 | //Int 使用int类型创建错误码 133 | func Int(i int) Code { return Code(i) } 134 | 135 | //String 使用string类型创建错误码 136 | func String(e string) Code { 137 | if e == "" { 138 | return OK 139 | } 140 | // try error string 141 | i, err := strconv.Atoi(e) 142 | if err != nil { 143 | return InternalError 144 | } 145 | return Code(i) 146 | } 147 | 148 | // Cause cause from error to ecode. 149 | func Cause(e error) Codes { 150 | if e == nil { 151 | return OK 152 | } 153 | ec, ok := errors.Cause(e).(Codes) 154 | if ok { 155 | return ec 156 | } 157 | return UnknownError 158 | } 159 | 160 | // Equal equal a and b by code int. 161 | func Equal(a, b Codes) bool { 162 | if a == nil { 163 | a = OK 164 | } 165 | if b == nil { 166 | b = OK 167 | } 168 | return a.Code() == b.Code() 169 | } 170 | 171 | // EqualError equal error 172 | func EqualError(code Codes, err error) bool { 173 | return Cause(err).Code() == code.Code() 174 | } 175 | -------------------------------------------------------------------------------- /common/ecode/ecode_test.go: -------------------------------------------------------------------------------- 1 | package ecode 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | defer func() { 12 | errStr := recover() 13 | if errStr != "ecode: 2810001 already exist" { 14 | t.Logf("New duplicate ecode should cause panic") 15 | t.FailNow() 16 | } 17 | }() 18 | var _ error = New(2810001) 19 | var _ error = New(2810002) 20 | var _ error = New(2810001) 21 | } 22 | 23 | func TestErrMessage(t *testing.T) { 24 | e1 := New(2810003) 25 | assert.Equal(t, 2810003, e1.Code()) 26 | assert.Equal(t, "2810003", e1.Message()) 27 | RegisterMessages(map[int]Message{2810003: Message{ 28 | Msg: "testErr", 29 | Detail: "测试错误", 30 | }}) 31 | assert.Equal(t, "testErr", e1.LocalizedMessage(En)) 32 | assert.Equal(t, "测试错误", e1.LocalizedMessage(Zh)) 33 | } 34 | 35 | func TestCause(t *testing.T) { 36 | e1 := New(2810004) 37 | err := errors.Wrap(e1, "wrap error") 38 | e2 := Cause(err) 39 | assert.Error(t, e1, e2) 40 | } 41 | 42 | func TestMessage(t *testing.T) { 43 | for code, msg := range _messages { 44 | fmt.Printf("%d %s %s\n", code, msg.Msg, msg.Detail) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /common/ecode/example_test.go: -------------------------------------------------------------------------------- 1 | package ecode_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | ecode2 "xjyk/common/ecode" 7 | ) 8 | 9 | func Example_ecode_Message() { 10 | _ = ecode2.OK.Message() 11 | } 12 | 13 | func Example_ecode_Code() { 14 | _ = ecode2.OK.Code() 15 | } 16 | 17 | func Example_ecode_Error() { 18 | _ = ecode2.OK.Error() 19 | } 20 | 21 | func ExampleCause() { 22 | err := errors.WithStack(ecode2.InternalError) 23 | ecode2.Cause(err) 24 | } 25 | 26 | func ExampleInt() { 27 | err := ecode2.Int(500) 28 | fmt.Println(err) 29 | // Output: 30 | // 500 31 | } 32 | 33 | func ExampleString() { 34 | ecode2.String("500") 35 | } 36 | 37 | // ExampleStack package error with stack. 38 | func Example() { 39 | err := errors.New("dao error") 40 | errors.Wrap(err, "some message") 41 | // package ecode with stack. 42 | errCode := ecode2.InternalError 43 | err = errors.Wrap(errCode, "some message") 44 | 45 | //get ecode from package error 46 | code := errors.Cause(err).(ecode2.Codes) 47 | fmt.Printf("%d: %s\n", code.Code(), code.Message()) 48 | } 49 | -------------------------------------------------------------------------------- /common/id_generator/snowflake.go: -------------------------------------------------------------------------------- 1 | package id_generator 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bwmarrin/snowflake" 6 | ) 7 | 8 | var Node *snowflake.Node 9 | 10 | func SetupIDGenerator() { 11 | var err error 12 | if Node, err = snowflake.NewNode(1); err != nil { 13 | panic(err) 14 | } 15 | } 16 | 17 | func ID() int64 { 18 | return Node.Generate().Int64() 19 | } 20 | 21 | func StringID() string { 22 | return fmt.Sprintf("%d", Node.Generate().Int64()) 23 | } 24 | -------------------------------------------------------------------------------- /common/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "go.uber.org/zap" 6 | "msg/constants" 7 | ) 8 | 9 | var Sugar *zap.SugaredLogger 10 | var Logger *zap.Logger 11 | var Env string 12 | 13 | func SetupLogger(env string) { 14 | Env = env 15 | Logger, _ = zap.NewDevelopment() 16 | if env == constants.PROD { 17 | Logger, _ = zap.NewProduction() 18 | } 19 | defer Logger.Sync() // flushes buffer, if any 20 | Sugar = Logger.Sugar() 21 | } 22 | 23 | // TracedError 打印错误,线上环境固定打Json格式,其他环境打Console格式 24 | func TracedError(msg string, err error) { 25 | if Env == constants.PROD { 26 | Sugar.Errorw(msg, "err", err) 27 | return 28 | } else { 29 | fmt.Printf("%s %+v", msg, err) 30 | return 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /common/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v8" 6 | "github.com/jinzhu/copier" 7 | jsoniter "github.com/json-iterator/go" 8 | "time" 9 | ) 10 | 11 | var RedisClient *redis.Client 12 | 13 | func Setup(host string, pw string, db int) { 14 | RedisClient = redis.NewClient(&redis.Options{ 15 | Addr: host, 16 | Password: pw, 17 | DB: db, 18 | }) 19 | } 20 | 21 | // GetOrSetFunc 获取或设置缓存 22 | // result 接收反序列化的值 23 | func GetOrSetFunc(key string, f func() (interface{}, error), duration time.Duration, result interface{}) error { 24 | jsonData := RedisClient.Get(context.Background(), key).Val() 25 | if jsonData == "" { 26 | value, err := f() 27 | if err != nil { 28 | return err 29 | } 30 | if value == nil { 31 | return nil 32 | } 33 | 34 | err = copier.Copy(result, value) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | jsonData, err = jsoniter.MarshalToString(value) 40 | if err != nil { 41 | return err 42 | } 43 | return RedisClient.Set(context.Background(), key, jsonData, duration).Err() 44 | } 45 | 46 | return jsoniter.UnmarshalFromString(jsonData, &result) 47 | } 48 | 49 | func Delete(keys ...string) error { 50 | return RedisClient.Del(context.Background(), keys...).Err() 51 | } 52 | -------------------------------------------------------------------------------- /common/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/gin-contrib/sessions" 5 | "github.com/gin-contrib/sessions/redis" 6 | "xjyk/common/log" 7 | ) 8 | 9 | var Store sessions.Store 10 | 11 | func Setup(redisHost, redisPassword, aesKey string) { 12 | var err error 13 | Store, err = redis.NewStore(10, "tcp", redisHost, redisPassword, []byte(aesKey)) 14 | if err != nil { 15 | log.Sugar.Fatalw("setup session failed", "err", err) 16 | return 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common/storage/cos.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/tencentyun/cos-go-sdk-v5" 6 | "golang.org/x/net/context" 7 | "io" 8 | "mime" 9 | setting "msg/conf" 10 | "msg/constants" 11 | "net/http" 12 | "net/url" 13 | "path/filepath" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type COSStorage struct { 19 | Client *cos.Client 20 | Config setting.StorageConfig 21 | } 22 | 23 | func NewCOS(conf setting.StorageConfig) (storage COSStorage, err error) { 24 | u, err := url.Parse(conf.BucketURL) 25 | if err != nil { 26 | err = errors.Wrap(err, "invalid BucketURL") 27 | return 28 | } 29 | 30 | b := &cos.BaseURL{BucketURL: u} 31 | storage.Client = cos.NewClient(b, &http.Client{ 32 | //设置超时时间 33 | Timeout: 100 * time.Second, 34 | Transport: &cos.AuthorizationTransport{ 35 | //如实填写账号和密钥,也可以设置为环境变量 36 | SecretID: conf.SecretID, 37 | SecretKey: conf.SecretKey, 38 | }, 39 | }) 40 | 41 | storage.Config = conf 42 | 43 | return 44 | } 45 | 46 | func (o COSStorage) SignURL(objectKey string, method constants.HTTPMethod, expiredInSec int64) (signedURL string, err error) { 47 | contentType, err := GetContentType(objectKey) 48 | if err != nil { 49 | err = errors.Wrap(err, "GetContentType failed") 50 | return 51 | } 52 | 53 | opt := &cos.PresignedURLOptions{ 54 | Header: &http.Header{}, 55 | } 56 | opt.Header.Set("Content-Type", contentType) 57 | 58 | u, err := o.Client.Object.GetPresignedURL( 59 | context.Background(), 60 | string(method), 61 | objectKey, 62 | o.Config.SecretID, 63 | o.Config.SecretKey, 64 | time.Duration(expiredInSec)*time.Second, 65 | nil, 66 | ) 67 | if err != nil { 68 | err = errors.Wrap(err, "GetPresignedURL failed") 69 | return 70 | } 71 | 72 | if o.Config.CdnURL != "" { 73 | cdnURL, err := url.Parse(o.Config.CdnURL) 74 | if err != nil { 75 | err = errors.Wrap(err, "url.ParseLink failed") 76 | return signedURL, err 77 | } 78 | 79 | u.Host = cdnURL.Host 80 | u.Scheme = cdnURL.Scheme 81 | } 82 | 83 | signedURL = u.String() 84 | 85 | return 86 | } 87 | 88 | func (o COSStorage) Get(objectKey string) (content io.ReadCloser, err error) { 89 | resp, err := o.Client.Object.Get(context.Background(), objectKey, nil) 90 | if err != nil { 91 | err = errors.Wrap(err, "GetObject failed") 92 | return 93 | } 94 | 95 | return resp.Body, nil 96 | } 97 | 98 | func (o COSStorage) Put(objectKey string, reader io.Reader) (err error) { 99 | contentType, err := GetContentType(objectKey) 100 | if err != nil { 101 | err = errors.Wrap(err, "GetContentType failed") 102 | return 103 | } 104 | 105 | opt := &cos.ObjectPutOptions{ 106 | ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{ 107 | ContentType: contentType, 108 | }, 109 | ACLHeaderOptions: &cos.ACLHeaderOptions{ 110 | XCosACL: "private", 111 | }, 112 | } 113 | _, err = o.Client.Object.Put(context.Background(), objectKey, reader, opt) 114 | if err != nil { 115 | err = errors.Wrap(err, "PutObject failed") 116 | return 117 | } 118 | 119 | return 120 | } 121 | 122 | func (o COSStorage) IsExist(objectKey string) (ok bool, err error) { 123 | _, err = o.Client.Object.Head(context.Background(), objectKey, nil) 124 | if err != nil { 125 | err = errors.Wrap(err, "Head failed") 126 | return 127 | } 128 | return 129 | } 130 | 131 | func (o COSStorage) PutFromFile(objectKey string, filePath string) (err error) { 132 | ext := strings.ToLower(filepath.Ext(filePath)) 133 | if ext == "" { 134 | err = errors.New("file ext is required") 135 | return 136 | } 137 | 138 | contentType := mime.TypeByExtension(ext) 139 | if contentType == "" { 140 | err = errors.New("invalid file ext") 141 | return 142 | } 143 | 144 | opt := &cos.ObjectPutOptions{ 145 | ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{ 146 | ContentType: contentType, 147 | }, 148 | ACLHeaderOptions: &cos.ACLHeaderOptions{ 149 | XCosACL: "private", 150 | }, 151 | } 152 | 153 | _, err = o.Client.Object.PutFromFile(context.Background(), objectKey, filePath, opt) 154 | if err != nil { 155 | err = errors.Wrap(err, "PutFromFile failed") 156 | return 157 | } 158 | 159 | return 160 | } 161 | 162 | func (o COSStorage) Delete(objectKeys ...string) (deletedObjects []string, err error) { 163 | objects := make([]cos.Object, 0) 164 | for _, key := range objectKeys { 165 | objects = append(objects, cos.Object{ 166 | Key: key, 167 | }) 168 | } 169 | opt := &cos.ObjectDeleteMultiOptions{ 170 | Objects: objects, 171 | } 172 | 173 | result, _, err := o.Client.Object.DeleteMulti(context.Background(), opt) 174 | if err != nil { 175 | err = errors.Wrap(err, "DeleteMulti failed") 176 | return 177 | } 178 | 179 | for _, object := range result.DeletedObjects { 180 | deletedObjects = append(deletedObjects, object.Key) 181 | } 182 | 183 | return 184 | } 185 | -------------------------------------------------------------------------------- /common/storage/oss.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/aliyun/aliyun-oss-go-sdk/oss" 5 | "github.com/pkg/errors" 6 | "io" 7 | setting "msg/conf" 8 | "msg/constants" 9 | "net/url" 10 | ) 11 | 12 | type OSSStorage struct { 13 | Client *oss.Client 14 | Bucket *oss.Bucket 15 | Config setting.StorageConfig 16 | } 17 | 18 | func NewOSS(conf setting.StorageConfig) (ossStorage OSSStorage, err error) { 19 | ossStorage.Client, err = oss.New(conf.EndPoint, conf.AccessKeyId, conf.AccessKeySecret) 20 | if err != nil { 21 | err = errors.WithStack(err) 22 | return 23 | } 24 | ossStorage.Bucket, err = ossStorage.Client.Bucket(conf.Bucket) 25 | if err != nil { 26 | err = errors.WithStack(err) 27 | return 28 | } 29 | 30 | ossStorage.Config = conf 31 | 32 | return 33 | } 34 | 35 | func (o OSSStorage) SignURL(objectKey string, method constants.HTTPMethod, expiredInSec int64) (signedURL string, err error) { 36 | contentType, err := GetContentType(objectKey) 37 | if err != nil { 38 | err = errors.Wrap(err, "GetContentType failed") 39 | return 40 | } 41 | options := []oss.Option{ 42 | oss.ContentType(contentType), 43 | } 44 | if method == constants.HTTPGet { 45 | options = nil 46 | } 47 | 48 | signedURL, err = o.Bucket.SignURL(objectKey, oss.HTTPMethod(method), expiredInSec, options...) 49 | if err != nil { 50 | err = errors.Wrap(err, "Bucket.SignURL failed") 51 | return 52 | } 53 | 54 | if o.Config.CdnURL != "" { 55 | fileURL, err := url.Parse(signedURL) 56 | if err != nil { 57 | err = errors.Wrap(err, "url.ParseLink failed") 58 | return signedURL, err 59 | } 60 | 61 | cdnURL, err := url.Parse(o.Config.CdnURL) 62 | if err != nil { 63 | err = errors.Wrap(err, "url.ParseLink failed") 64 | return signedURL, err 65 | } 66 | 67 | fileURL.Host = cdnURL.Host 68 | fileURL.Scheme = cdnURL.Scheme 69 | signedURL = fileURL.String() 70 | } 71 | 72 | return 73 | } 74 | 75 | func (o OSSStorage) Get(objectKey string) (content io.ReadCloser, err error) { 76 | content, err = o.Bucket.GetObject(objectKey) 77 | if err != nil { 78 | err = errors.Wrap(err, "GetObject failed") 79 | return 80 | } 81 | 82 | return 83 | } 84 | 85 | func (o OSSStorage) Put(objectKey string, reader io.Reader) (err error) { 86 | contentType, err := GetContentType(objectKey) 87 | if err != nil { 88 | err = errors.Wrap(err, "GetContentType failed") 89 | return 90 | } 91 | 92 | options := []oss.Option{ 93 | oss.ContentType(contentType), 94 | } 95 | 96 | err = o.Bucket.PutObject(objectKey, reader, options...) 97 | if err != nil { 98 | err = errors.Wrap(err, "PutObject failed") 99 | return 100 | } 101 | 102 | return 103 | } 104 | 105 | func (o OSSStorage) IsExist(objectKey string) (ok bool, err error) { 106 | ok, err = o.Bucket.IsObjectExist(objectKey) 107 | if err != nil { 108 | err = errors.Wrap(err, "IsObjectExist failed") 109 | return 110 | } 111 | 112 | return 113 | } 114 | 115 | func (o OSSStorage) PutFromFile(objectKey string, filePath string) (err error) { 116 | err = o.Bucket.PutObjectFromFile(objectKey, filePath) 117 | if err != nil { 118 | err = errors.Wrap(err, "PutObjectFromFile failed") 119 | return 120 | } 121 | 122 | return 123 | } 124 | 125 | func (o OSSStorage) Delete(objectKeys ...string) (deletedObjects []string, err error) { 126 | result, err := o.Bucket.DeleteObjects(objectKeys) 127 | if err != nil { 128 | err = errors.Wrap(err, "DeleteObjects failed") 129 | return 130 | } 131 | 132 | deletedObjects = result.DeletedObjects 133 | return 134 | } 135 | -------------------------------------------------------------------------------- /common/storage/oss_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "msg/conf" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestOSSStorage_SignURL(t *testing.T) { 10 | err := conf.SetupSetting() 11 | if err != nil { 12 | t.Failed() 13 | } 14 | Setup(conf.Settings.Storage) 15 | signedURL, err := FileStorage.SignURL("wwacfbf964143dc303/quick_reply/admin/4566456.jpg", http.MethodPut, 3600*24*365) 16 | if err != nil { 17 | t.Failed() 18 | } 19 | println(signedURL) 20 | 21 | } 22 | -------------------------------------------------------------------------------- /common/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "msg/common/log" 6 | setting "msg/conf" 7 | "msg/constants" 8 | "os" 9 | ) 10 | 11 | var FileStorage FileStorageInterface 12 | 13 | type FileStorageInterface interface { 14 | SignURL(objectKey string, method constants.HTTPMethod, expiredInSec int64) (signedURL string, err error) 15 | Get(objectKey string) (content io.ReadCloser, err error) 16 | Put(objectKey string, reader io.Reader) (err error) 17 | IsExist(objectKey string) (ok bool, err error) 18 | PutFromFile(objectKey string, filePath string) (err error) 19 | Delete(objectKeys ...string) (deletedObjects []string, err error) 20 | } 21 | 22 | func Setup(conf setting.StorageConfig) { 23 | var err error 24 | if conf.Type == string(constants.AliyunStorage) { 25 | FileStorage, err = NewOSS(conf) 26 | if err != nil { 27 | log.TracedError("NewOSS failed", err) 28 | os.Exit(1) 29 | return 30 | } 31 | } 32 | 33 | if conf.Type == string(constants.QcloudStorage) { 34 | FileStorage, err = NewCOS(conf) 35 | if err != nil { 36 | log.TracedError("NewCOS failed", err) 37 | os.Exit(1) 38 | return 39 | } 40 | } 41 | 42 | //if conf.Type == string(constants.LocalStorage) { 43 | // FileStorage, err = NewLocalStorage(conf) 44 | // if err != nil { 45 | // log.TracedError("NewLocalStorage failed", err) 46 | // os.Exit(1) 47 | // return 48 | // } 49 | //} 50 | 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /common/storage/util.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "mime" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | func GetContentType(filePath string) (contentType string, err error) { 12 | ext := strings.ToLower(filepath.Ext(filePath)) 13 | if ext == "" { 14 | err = errors.New("file ext is required") 15 | return 16 | } 17 | 18 | contentType = mime.TypeByExtension(ext) 19 | if contentType == "" { 20 | err = errors.New("invalid file ext") 21 | return 22 | } 23 | 24 | return 25 | } 26 | 27 | func IsValidObjectKey(objectKey string) bool { 28 | return regexp.MustCompile("^[a-zA-Z0-9/\\-_]+\\.[a-zA-Z0-9]+$").MatchString(objectKey) 29 | } 30 | -------------------------------------------------------------------------------- /common/util/aes.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "encoding/base64" 9 | "errors" 10 | "io" 11 | "msg/conf" 12 | "strings" 13 | ) 14 | 15 | func addBase64Padding(value string) string { 16 | m := len(value) % 4 17 | if m != 0 { 18 | value += strings.Repeat("=", 4-m) 19 | } 20 | 21 | return value 22 | } 23 | 24 | func removeBase64Padding(value string) string { 25 | return strings.Replace(value, "=", "", -1) 26 | } 27 | 28 | func Pad(src []byte) []byte { 29 | padding := aes.BlockSize - len(src)%aes.BlockSize 30 | padtext := bytes.Repeat([]byte{byte(padding)}, padding) 31 | return append(src, padtext...) 32 | } 33 | 34 | func Unpad(src []byte) ([]byte, error) { 35 | length := len(src) 36 | unpadding := int(src[length-1]) 37 | 38 | if unpadding > length { 39 | return nil, errors.New("unpad error. This could happen when incorrect encryption key is used") 40 | } 41 | 42 | return src[:(length - unpadding)], nil 43 | } 44 | 45 | func Encrypt(key []byte, text string) (string, error) { 46 | block, err := aes.NewCipher(key) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | msg := Pad([]byte(text)) 52 | ciphertext := make([]byte, aes.BlockSize+len(msg)) 53 | iv := ciphertext[:aes.BlockSize] 54 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 55 | return "", err 56 | } 57 | 58 | cfb := cipher.NewCFBEncrypter(block, iv) 59 | cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(msg)) 60 | finalMsg := removeBase64Padding(base64.URLEncoding.EncodeToString(ciphertext)) 61 | return finalMsg, nil 62 | } 63 | 64 | func Decrypt(key []byte, text string) (string, error) { 65 | block, err := aes.NewCipher(key) 66 | if err != nil { 67 | return "", err 68 | } 69 | 70 | decodedMsg, err := base64.URLEncoding.DecodeString(addBase64Padding(text)) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | if (len(decodedMsg) % aes.BlockSize) != 0 { 76 | return "", errors.New("blocksize must be multipe of decoded message length") 77 | } 78 | 79 | iv := decodedMsg[:aes.BlockSize] 80 | msg := decodedMsg[aes.BlockSize:] 81 | 82 | cfb := cipher.NewCFBDecrypter(block, iv) 83 | cfb.XORKeyStream(msg, msg) 84 | 85 | unpadMsg, err := Unpad(msg) 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | return string(unpadMsg), nil 91 | } 92 | 93 | func APPKey() []byte { 94 | key, err := base64.StdEncoding.DecodeString(conf.Settings.App.Key) 95 | if err != nil { 96 | panic(err) 97 | } 98 | return key 99 | } 100 | 101 | func AESEncode(sid string) (string, error) { 102 | return Encrypt(APPKey()[:32], sid) 103 | } 104 | 105 | func AESDecode(sid string) (string, error) { 106 | return Decrypt(APPKey()[:32], sid) 107 | } 108 | -------------------------------------------------------------------------------- /common/util/data_structure.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | //func PluckStrings(objs interface{}, column string) []string { 4 | // results, err := collection.NewObjCollection(objs).Pluck(column).ToStrings() 5 | // if err != nil { 6 | // log.Sugar.Errorw("Pluck failed", "err", err) 7 | // return []string{} 8 | // } 9 | // return results 10 | //} 11 | -------------------------------------------------------------------------------- /common/util/encrypt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha512" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "math/rand" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func reverse(s string) (result string) { 14 | for _, v := range s { 15 | result = string(v) + result 16 | } 17 | return 18 | } 19 | 20 | func GenerateMappingString(seed []byte) (source, target, random string) { 21 | chars := []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 22 | tmpKey := sha512.Sum512(seed) 23 | total := int(tmpKey[0] + tmpKey[len(tmpKey)-1]) 24 | for i := 0; i < total; i++ { 25 | if i > 50 { 26 | tmpKey = sha512.Sum512(tmpKey[0:64]) 27 | } else { 28 | tmpKey = sha512.Sum512(tmpKey[0 : i+10]) 29 | } 30 | } 31 | source = hex.EncodeToString(tmpKey[0:64]) 32 | seedInt := int64(tmpKey[2] + tmpKey[0] + tmpKey[2] + tmpKey[1] + tmpKey[0] + tmpKey[7] + tmpKey[3] + tmpKey[0]) 33 | rand.Seed(seedInt) 34 | rand.Shuffle(len(chars), func(i, j int) { 35 | chars[i], chars[j] = chars[j], chars[i] 36 | }) 37 | 38 | source = string(chars) 39 | rand.Shuffle(len(chars), func(i, j int) { 40 | chars[i], chars[j] = chars[j], chars[i] 41 | }) 42 | target = string(chars) 43 | 44 | rand.Seed(time.Now().UnixNano() + seedInt) 45 | rand.Shuffle(len(chars), func(i, j int) { 46 | chars[i], chars[j] = chars[j], chars[i] 47 | }) 48 | random = string(chars) 49 | return 50 | } 51 | 52 | func translate(text string, sourceDict string, targetDict string) string { 53 | chars := []byte(text) 54 | for index, val := range chars { 55 | sourceIndex := strings.IndexByte(sourceDict, val) 56 | if sourceIndex >= 0 { 57 | chars[index] = targetDict[sourceIndex] 58 | } 59 | } 60 | return string(chars) 61 | } 62 | 63 | func SimpleEncrypt(data []byte, seed []byte) (ciphertext string, random string) { 64 | var source, target string 65 | plaintext := base64.StdEncoding.EncodeToString(data) 66 | source, target, random = GenerateMappingString(seed) 67 | random1 := random[0:10] 68 | random2 := random[10:20] 69 | random3 := random[20:30] 70 | random4 := random[30:40] 71 | random5 := random[40:50] 72 | random6 := random[50:62] 73 | realRandom := random3 + reverse(random1) + random6 + reverse(random4) + random2 + random5 74 | realSource := translate(source, realRandom, target) 75 | realTarget := translate(target, realRandom, source) 76 | ciphertext = translate(plaintext, realSource, realTarget) 77 | //fmt.Println("source", source) 78 | //fmt.Println("target", target) 79 | //fmt.Println("random", random) 80 | //fmt.Println("realRandom", realRandom) 81 | //fmt.Println("realSource", realSource) 82 | //fmt.Println("realTarget", realTarget) 83 | //fmt.Println("plaintext", plaintext) 84 | //fmt.Println("ciphertext", ciphertext) 85 | return 86 | } 87 | 88 | func TestSimpleEncrypt(t *testing.T) { 89 | plaintext := "测试中文" 90 | ciphertext, random := SimpleEncrypt([]byte(plaintext), []byte("20210731!@#$%)%^&%^&&*(&*")) 91 | t.Log(plaintext, ciphertext, random) 92 | } 93 | -------------------------------------------------------------------------------- /common/util/jwt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/base64" 5 | "msg/conf" 6 | 7 | "github.com/golang-jwt/jwt" 8 | ) 9 | 10 | type Claims struct { 11 | UID string `json:"uid"` 12 | Role string `json:"role"` 13 | jwt.StandardClaims 14 | } 15 | 16 | // GenerateToken generate tokens used for auth 17 | func GenerateToken(uid string, role string, expireAt int64) (string, error) { 18 | claims := Claims{ 19 | uid, 20 | role, 21 | jwt.StandardClaims{ 22 | ExpiresAt: expireAt, 23 | Issuer: conf.Settings.App.Name, 24 | }, 25 | } 26 | 27 | tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 28 | secret, err := base64.StdEncoding.DecodeString(conf.Settings.App.Key) 29 | if err != nil { 30 | return "", err 31 | } 32 | token, err := tokenClaims.SignedString(secret) 33 | if err != nil { 34 | return "", err 35 | } 36 | 37 | return token, err 38 | } 39 | 40 | // ParseToken parsing token 41 | func ParseToken(token string) (*Claims, error) { 42 | tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) { 43 | return base64.StdEncoding.DecodeString(conf.Settings.App.Key) 44 | }) 45 | 46 | if tokenClaims != nil { 47 | if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid { 48 | return claims, nil 49 | } 50 | } 51 | 52 | return nil, err 53 | } 54 | -------------------------------------------------------------------------------- /common/util/security.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "github.com/gogf/gf/crypto/gmd5" 4 | 5 | func Password(raw string, salt string) string { 6 | return gmd5.MustEncryptString(gmd5.MustEncryptString(salt) + salt + gmd5.MustEncryptString(raw)) 7 | } 8 | -------------------------------------------------------------------------------- /common/util/session.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-contrib/sessions" 6 | "github.com/pkg/errors" 7 | "msg/constants" 8 | ) 9 | 10 | // GetInt64FromSession 从指定Session中获取int64字段 11 | func GetInt64FromSession(sess sessions.Session, fieldName constants.SessionField) (val int64, err error) { 12 | id, ok := sess.Get(string(fieldName)).(string) 13 | if !ok { 14 | err = errors.New("invalid " + string(fieldName)) 15 | return 16 | } 17 | 18 | val, err = ShouldInt64ID(id) 19 | if err != nil { 20 | err = errors.Wrap(err, "ShouldInt64ID failed") 21 | return 22 | } 23 | 24 | return 25 | } 26 | 27 | // GetInt64IDFromSession 从指定Session中获取int64ID 28 | func GetInt64IDFromSession(sess sessions.Session, fieldName constants.SessionField) (id string, err error) { 29 | val, err := GetInt64FromSession(sess, fieldName) 30 | if err != nil { 31 | err = errors.Wrap(err, "GetInt64FromSession failed") 32 | return 33 | } 34 | 35 | id = fmt.Sprintf("%d", val) 36 | 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /common/util/time.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "msg/constants" 5 | "time" 6 | ) 7 | 8 | // Today 获取今天0点时间 9 | func Today() time.Time { 10 | return time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, constants.PRCLocation) 11 | } 12 | 13 | func Now() time.Time { 14 | return time.Now().In(constants.PRCLocation) 15 | } 16 | -------------------------------------------------------------------------------- /common/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestGenSortedColumn(t *testing.T) { 9 | type T1 struct { 10 | Name string 11 | Age int 12 | } 13 | 14 | t1 := T1{ 15 | Name: "asdf", 16 | Age: 121, 17 | } 18 | bytes, err := GenBytesOrderByColumn(t1) 19 | if err != nil { 20 | t.Failed() 21 | } 22 | assert.Equal(t, string(bytes), "Age=121;Name=asdf;") 23 | } 24 | -------------------------------------------------------------------------------- /common/util/validate.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "strconv" 6 | ) 7 | 8 | // ShouldInt64ID 验证并转换int64字符串ID 9 | func ShouldInt64ID(id string) (int64ID int64, err error) { 10 | if id == "" { 11 | err = errors.New("id required") 12 | return 13 | } 14 | 15 | int64ID, err = strconv.ParseInt(id, 10, 64) 16 | if err != nil { 17 | err = errors.Wrap(err, "invalid int64 id") 18 | return 19 | } 20 | 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /conf/config.example.yaml: -------------------------------------------------------------------------------- 1 | # 应用基本配置 2 | App: 3 | Name: openscrm 4 | # * 运行环境 DEV,TEST,PROD;开发和测试环境会开放调试API,生产环境请使用PROD; 5 | Env: DEV 6 | # * 应用秘钥 64位,生成命令:openssl rand -base64 64 7 | Key: todo 8 | # * 超级管理员手机号,自动设置此处手机号匹配到的员工为超级管理员权限 9 | SuperAdminPhone: 10 | - 13108329522 11 | # 是否开启Models自动迁移,修改Model定义自动同步Mysql表结构 12 | AutoMigration: true 13 | 14 | # API服务配置 15 | Server: 16 | #debug or release 17 | RunMode: debug 18 | # 主服务监听Host 19 | HttpHost: 0.0.0.0 20 | # 主服务监听端口 21 | HttpPort: 9001 22 | # 会话存档服务监听端口 23 | MsgArchHttpPort: 9002 24 | ReadTimeout: 60 25 | WriteTimeout: 60 26 | # 会话存档服务访问地址 27 | MsgArchSrvHost: host.docker.internal 28 | 29 | # Mysql数据库配置 30 | DB: 31 | User: root 32 | Name: open_scrm_demo 33 | Host: host.docker.internal:9306 34 | Password: NWVj5IowIGk0dZlBCSF 35 | 36 | # redis服务器配置 37 | Redis: 38 | Host: host.docker.internal:9379 39 | Password: XOvqH8qXoWE4RgFScSZ 40 | DBNumber: 0 41 | IdleTimeout: 5 42 | ReadTimeout: 3 43 | DialTimeout: 5 44 | 45 | # 存储配置 46 | Storage: 47 | # * 存储类型, 可配置aliyun, qcloud;分别对应阿里云OSS, 腾讯云COS 48 | Type: aliyun 49 | # * 阿里云OSS相关配置,请使用子账户凭据,且仅授权oss访问权限 50 | AccessKeyId: todo 51 | AccessKeySecret: todo 52 | Endpoint: todo 53 | Bucket: todo 54 | 55 | # * 腾讯云OSS相关配置,请使用子账户凭据,且仅授权cos访问权限 56 | SecretID: 57 | SecretKey: 58 | BucketURL: 59 | 60 | # 企业微信配置 61 | WeWork: 62 | # * 企业ID,https://work.weixin.qq.com/wework_admin/frame#profile 63 | ExtCorpID: ww2d3e2957190c6e4c 64 | # * 企业微信通讯录API Secret https://work.weixin.qq.com/wework_admin/frame#apps/contactsApi 65 | ContactSecret: todo 66 | # * 企业微信客户联系API Secret https://work.weixin.qq.com/wework_admin/frame#customer/analysis 67 | CustomerSecret: todo 68 | # * 企业自建主应用ID https://work.weixin.qq.com/wework_admin/frame#apps 69 | MainAgentID: 10004 70 | # * 企业自建主应用Secret 71 | MainAgentSecret: todo 72 | # * 同步通讯录回调地址的token,客户联系共用此配置,https://work.weixin.qq.com/wework_admin/frame#apps/contactsApi,https://work.weixin.qq.com/wework_admin/frame#customer/analysis 73 | CallbackToken: todo 74 | # * 同步通讯录回调地址的AesKey, 客户联系共用此配置 75 | CallbackAesKey: todo 76 | # * 会话存档服务私钥,企业微信需开通此功能并设置好对应公钥,https://work.weixin.qq.com/wework_admin/frame#financial/corpEncryptData 77 | PriKeyPath: /conf/private.key 78 | 79 | # 延迟队列设置(通常无需改动) 80 | DelayQueue: 81 | # bucket数量 82 | BucketSize: 3 83 | # bucket在redis中的键名, %d必须保留 84 | BucketName: dq_bucket_%d 85 | # ready queue在redis中的键名, %s必须保留 86 | QueueName: dq_queue_%s 87 | # 调用blpop阻塞超时时间, 单位秒, 必须小于redis.read_timeout, 修改此项, redis.read_timeout需做相应调整 88 | QueueBlockTimeout: 2 -------------------------------------------------------------------------------- /constants/attachment.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | ImageMsgType AttachmentType = "image" 5 | LinkMsgType AttachmentType = "link" 6 | MiniProgramMsgType AttachmentType = "miniprogram" 7 | VideoMsgType AttachmentType = "video" 8 | ) 9 | 10 | type AttachmentType string 11 | -------------------------------------------------------------------------------- /constants/cache.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // CacheKey 缓存key 4 | const ( 5 | CachedStaffKey string = "cached_model:staff:%s" 6 | CachedRoleKey string = "cached_model:role:%s" 7 | 8 | CacheMainStaffInfoKey string = "cached_model:corp:%s:dept:%s:offset:%d:limit:%d" 9 | CacheMainStaffInfoKeyPrefix string = "cached_model:corp:%s*" 10 | 11 | StaffIDConverterKey string = "staff_id_converter" 12 | ) 13 | 14 | const ( 15 | DelCacheMainStaffInfoKeyScripts string = ` 16 | local key_list = redis.call(KEYS[1], ARGV[1]) 17 | if #key_list > 0 then 18 | return (redis.call('DEL', unpack(key_list))) 19 | else 20 | return nil 21 | end` 22 | ) 23 | -------------------------------------------------------------------------------- /constants/chat_msg.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type ChatSessionType string 4 | 5 | const ( 6 | ChatSessionTypeGroup ChatSessionType = "group" 7 | ChatSessionTypeInternal ChatSessionType = "internal" 8 | ChatSessionTypeExternal ChatSessionType = "external" 9 | ) 10 | -------------------------------------------------------------------------------- /constants/common.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // Boolean 为避免系统中出现零值,这里定义了一个自定义Boolean类型 4 | // 1, true 5 | // 2, false 6 | type Boolean int 7 | 8 | const ( 9 | Enable Boolean = 1 10 | Disable Boolean = 2 11 | True Boolean = 1 12 | False Boolean = 2 13 | ) 14 | 15 | func (o Boolean) Bool() bool { 16 | return o == True 17 | } 18 | 19 | type AsyncTaskStatus string 20 | 21 | const ( 22 | AsyncTaskStatusCreating AsyncTaskStatus = "creating" 23 | AsyncTaskStatusSuccess AsyncTaskStatus = "success" 24 | AsyncTaskStatusFailed AsyncTaskStatus = "failed" 25 | ) 26 | 27 | const ( 28 | WeWorkPicHost = "wework.qpic.cn" 29 | WxPicHost = "wx.qlogo.cn" 30 | ) 31 | 32 | const ( 33 | MsgArchSrvPathSync = "/chat-msg/sync" 34 | MsgArchSrvPathSessions = "/chat-msg/sessions" 35 | MsgArchSrvPathMsgs = "/chat-msg/session-msgs" 36 | MsgArchSrvSearchMsgs = "/chat-msg/search" 37 | ) 38 | 39 | type LogicalCondition string 40 | 41 | const ( 42 | LogicalConditionAND string = "and" 43 | LogicalConditionOR string = "or" 44 | LogicalConditionNone string = "none" 45 | ) 46 | 47 | type JsonResult struct { 48 | Code int64 `json:"code"` 49 | Message string `json:"message"` 50 | Data interface{} `json:"data"` 51 | } 52 | -------------------------------------------------------------------------------- /constants/contact_way.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // ContactWayScene 渠道码场景 4 | // 1-在小程序中联系 5 | // 2-通过二维码联系 6 | type ContactWayScene int 7 | 8 | const ( 9 | // ContactWaySceneMicroApp 在小程序中联系 10 | ContactWaySceneMicroApp ContactWayScene = 1 11 | // ContactWaySceneQrcode 通过二维码联系 12 | ContactWaySceneQrcode ContactWayScene = 2 13 | ) 14 | 15 | // ContactWayType 渠道码联系方式 16 | // 1-单人 17 | // 2-多人 18 | type ContactWayType int 19 | 20 | const ( 21 | // ContactWayTypeSingle 单人 22 | ContactWayTypeSingle ContactWayType = 1 23 | // ContactWayTypeMultiple 多人 24 | ContactWayTypeMultiple ContactWayType = 2 25 | ) 26 | 27 | // ContactWayAutoReplyType 渠道码欢迎语类型 28 | // 1, 渠道欢迎语 29 | // 2, 渠道默认欢迎语 30 | // 3, 不发送欢迎语 31 | type ContactWayAutoReplyType int 32 | 33 | const ( 34 | // ContactWayAutoReplyTypeCustom 渠道欢迎语 35 | ContactWayAutoReplyTypeCustom ContactWayAutoReplyType = 1 36 | // ContactWayAutoReplyTypeDefault 渠道默认欢迎语 37 | ContactWayAutoReplyTypeDefault ContactWayAutoReplyType = 2 38 | // ContactWayAutoReplyTypeDisable 不发送欢迎语 39 | ContactWayAutoReplyTypeDisable ContactWayAutoReplyType = 3 40 | ) 41 | 42 | const ContactWayStatePrefix = "ixj:" 43 | -------------------------------------------------------------------------------- /constants/customer.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // FollowUserTagType 该成员添加此外部联系人所打标签类型 4 | // 1-企业设置 5 | // 2-用户自定义 6 | type FollowUserTagType int 7 | -------------------------------------------------------------------------------- /constants/customer_info.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | type CustomerRemarkField []CustomerRemarkContent 9 | 10 | // CustomerRemarkContent CustomerRemark的字段内容,json格式,放在CustomerInfo中 11 | type CustomerRemarkContent struct { 12 | RemarkID string `json:"remark_id"` 13 | //RemarkOptionID string `json:"remark_option_id"` 14 | RemarkType string `json:"remark_type"` 15 | // 多选-optionID 时间-字符串 text-字符串 16 | RemarkValue string `json:"remark_value"` 17 | } 18 | 19 | func (o CustomerRemarkField) Value() (driver.Value, error) { 20 | b, err := json.Marshal(o) 21 | return string(b), err 22 | } 23 | 24 | func (o *CustomerRemarkField) Scan(input interface{}) error { 25 | return json.Unmarshal(input.([]byte), o) 26 | } 27 | 28 | func (o CustomerRemarkField) GormDataType() string { 29 | return "json" 30 | } 31 | -------------------------------------------------------------------------------- /constants/customer_statistic.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type StatisticType string 4 | 5 | const ( 6 | StatisticTypeTotal string = "total" 7 | StatisticTypeIncrease string = "increase" 8 | StatisticTypeDecrease string = "decrease" 9 | StatisticTypeNetIncrease string = "net_increase" 10 | ) 11 | -------------------------------------------------------------------------------- /constants/data_export.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type DataExportType string 4 | 5 | const ( 6 | DataExportTypeGroupChat DataExportType = "group_chat_list" 7 | DataExportTypeCustomer DataExportType = "customer_list" 8 | DataExportTypeDeleteStaffWarning DataExportType = "delete_staff_warning" 9 | DataExportTypeDeleteCustomerWarning DataExportType = "delete_customer_warning" 10 | ) 11 | 12 | const ( 13 | DataExportGroupCustomerListPrefix = "xjyk-CustomerList" 14 | DataExportGroupChatListPrefix = "xjyk-GroupChatList" //"小橘有客-群聊列表" 15 | DataExportDeleteCustomerFilenamePrefix = "xjyk-DeleteCustomerList" //"小橘有客-删人提醒" 16 | DataExportDeleteStaffFilenamePrefix = "xjyk-DeleteStaffList" //"小橘有客-客户流失提醒提醒" 17 | ) 18 | 19 | const ( 20 | DataExportCustomerListSheetName = "客户列表" //"小橘有客-客户列表" 21 | DataExportGroupChatListSheetName = "客户群列表" //"小橘有客-群聊列表" 22 | DataExportDeleteCustomerListSheetName = "删人提醒列表" //"小橘有客-删人提醒" 23 | DataExportDeleteStaffListSheetName = "流失提醒列表" //"小橘有客-客户流失提醒提醒" 24 | ) 25 | -------------------------------------------------------------------------------- /constants/env.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | PROD = "PROD" 5 | DEV = "DEV" 6 | TEST = "TEST" 7 | ) 8 | -------------------------------------------------------------------------------- /constants/ext_staff_filter.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | type ExtCustomerFilter struct { 9 | // 客户性别 1-男 2-女 3-未知 10 | Gender UserGender `json:"gender" form:"gender" validate:"omitempty,oneof=0 1 2 3"` 11 | // 外部组ID 12 | ExtGroupChatIDs StringArrayField `json:"ext_group_chat_ids" form:"ext_group_chat_ids" validate:"omitempty"` 13 | // 客户标签 14 | ExtTagIDs StringArrayField `json:"ext_tag_ids" form:"ext_tag_ids" validate:"omitempty"` 15 | // 使用ExtTagIDs的逻辑条件 and-且 or-或 none-无标签客户 16 | TagLogicalCondition string `json:"tag_logical_condition" form:"tag_logical_condition" validate:"omitempty,oneof=and or none"` 17 | // 排除客户标签 18 | ExcludeExtTagIDs StringArrayField `json:"exclude_ext_tag_ids" form:"exclude_ext_tag_ids" validate:"omitempty"` 19 | // 添加好友,开始时间 20 | StartTime DateField `json:"start_time" form:"start_time"` 21 | // 添加好友,结束时间 22 | EndTime DateField `json:"end_time" form:"end_time"` 23 | } 24 | 25 | func (o ExtCustomerFilter) Value() (driver.Value, error) { 26 | b, err := json.Marshal(o) 27 | return string(b), err 28 | } 29 | 30 | func (o *ExtCustomerFilter) Scan(input interface{}) error { 31 | return json.Unmarshal(input.([]byte), o) 32 | } 33 | 34 | func (o ExtCustomerFilter) GormDataType() string { 35 | return "json" 36 | } 37 | -------------------------------------------------------------------------------- /constants/group_chat.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type GroupChatStatus uint8 4 | 5 | const ( 6 | GroupChatStatusNotDismissed = 1 7 | GroupChatStatusIsDismissed = 2 8 | ) 9 | -------------------------------------------------------------------------------- /constants/group_chat_auto_create.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type GroupChatAutoCreateType uint8 4 | 5 | const ( 6 | GroupChatAutoCreateTypeGroupQRCode = 1 7 | GroupChatAutoCreateTypeLiveCode = 2 8 | ) 9 | 10 | // GroupChatAutoCreateCodeScene 自动拉群码场景 11 | // 1-在小程序中联系 12 | // 2-通过二维码联系 13 | type GroupChatAutoCreateCodeScene int 14 | 15 | const ( 16 | // GroupChatAutoCreateCodeSceneMicroApp 在小程序中联系 17 | GroupChatAutoCreateCodeSceneMicroApp GroupChatAutoCreateCodeScene = 1 18 | // GroupChatAutoCreateCodeSceneQrcode 通过二维码联系 19 | GroupChatAutoCreateCodeSceneQrcode GroupChatAutoCreateCodeScene = 2 20 | ) 21 | 22 | // GroupChatAutoCreateCodeType 自动拉群码联系方式 23 | // 1-单人 24 | // 2-多人 25 | type GroupChatAutoCreateCodeType int 26 | 27 | const ( 28 | // GroupChatAutoCreateCodeTypeSingle 单人 29 | GroupChatAutoCreateCodeTypeSingle GroupChatAutoCreateCodeType = 1 30 | // GroupChatAutoCreateCodeTypeMultiple 多人 31 | GroupChatAutoCreateCodeTypeMultiple GroupChatAutoCreateCodeType = 2 32 | ) 33 | 34 | // GroupChatAutoCreateCodeAutoReplyType 自动拉群码欢迎语类型 35 | // 1, 自动拉群欢迎语 36 | // 2, 自动拉群默认欢迎语 37 | // 3, 不发送欢迎语 38 | type GroupChatAutoCreateCodeAutoReplyType int 39 | 40 | const ( 41 | // GroupChatAutoCreateCodeAutoReplyTypeCustom 自动拉群欢迎语 42 | GroupChatAutoCreateCodeAutoReplyTypeCustom GroupChatAutoCreateCodeAutoReplyType = 1 43 | // GroupChatAutoCreateCodeAutoReplyTypeDefault 自动拉群默认欢迎语 44 | GroupChatAutoCreateCodeAutoReplyTypeDefault GroupChatAutoCreateCodeAutoReplyType = 2 45 | // GroupChatAutoCreateCodeAutoReplyTypeDisable 不发送欢迎语 46 | GroupChatAutoCreateCodeAutoReplyTypeDisable GroupChatAutoCreateCodeAutoReplyType = 3 47 | ) 48 | 49 | const GroupChatAutoCreateCodeStatePrefix = "join_group:" 50 | 51 | type GroupChatAutoJoinQRCodeStatus int 52 | 53 | const ( 54 | GroupChatAutoJoinQRCodeStatusInEffect = 1 55 | GroupChatAutoJoinQRCodeStatusTerminated = 2 56 | ) 57 | -------------------------------------------------------------------------------- /constants/mass_msg.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const NotifyStaffSendMassMsg = `【管理员】提醒你发送群发任务 4 | 任务创建于%s,将群发给%s等%d个客户,可前往【客户联系】中确认发送` 5 | 6 | // ChatType 创建企业群发消息的发送类型 7 | type ChatType string 8 | 9 | const ( 10 | // Single 表示发送给客户 11 | Single ChatType = "single" 12 | // Group 表示发送给客户群 13 | Group ChatType = "group" 14 | ) 15 | 16 | // SendMassMsgType 创建企业群发消息的发送时间类型 17 | type SendMassMsgType uint8 18 | 19 | const ( 20 | // Instant 立即发送 21 | Instant SendMassMsgType = 1 22 | // Timed 定时发送 23 | Timed SendMassMsgType = 2 24 | ) 25 | 26 | // 客户信息info字段 27 | const ( 28 | PhoneNumber = "phone_number" 29 | Age = "age" 30 | Email = "email" 31 | Birthday = "birthday" 32 | Weibo = "weibo" 33 | Address = "address" 34 | Description = "description" 35 | QQ = "qq" 36 | ) 37 | 38 | // QuickReplyContentType 话术库/快速恢复的类型 39 | type QuickReplyContentType uint8 40 | 41 | const ( 42 | //QuickReplyContentTypeText = 1 43 | //QuickReplyContentTypePic = 2 44 | //QuickReplyContentTypeNews = 3 // website,we_work:news 45 | //QuickReplyContentTypePDF = 4 46 | //QuickReplyContentTypeVideo = 5 47 | ) 48 | 49 | type SendMassMsgStatus uint8 50 | 51 | const ( 52 | NotActive SendMassMsgStatus = 1 // 定时发送,尚未到指定时间 53 | Sending SendMassMsgStatus = 2 // 发送中, 已经提交给微信,部分员工已发送,部分员工还没发送 54 | Sent SendMassMsgStatus = 3 // 发送成功,所有员工均已发送 55 | Deleted SendMassMsgStatus = 4 // 任务已取消 56 | Failed SendMassMsgStatus = 5 // 提交给微信时失败 57 | ) 58 | -------------------------------------------------------------------------------- /constants/msg_type.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | MsgTypeTextMsg = "text" 5 | MsgTypeRevoke = "revoke" 6 | MsgTypeVoice = "voice" 7 | MsgTypeCard = "card" 8 | MsgTypeLocation = "location" 9 | MsgTypeEmotion = "emotion" 10 | MsgTypeFile = "file" 11 | MsgTypeLink = "link" 12 | MsgTypeChatRecord = "chatrecord" 13 | MsgTypeWeApp = "weapp" 14 | MsgTypeDocMsg = "docmsg" 15 | MsgTypeTodo = "todo" 16 | MsgTypeMarkdown = "markdown" 17 | MsgTypeMeeting = "meeting" 18 | MsgTypeCollect = "collect" 19 | MsgTypeVote = "vote" 20 | MsgTypeNews = "news" 21 | MsgTypeCalendar = "calendar" 22 | MsgTypeImage = "image" 23 | ) 24 | 25 | const ( 26 | ModuleNameMsgArch = "msgarch" 27 | ) 28 | -------------------------------------------------------------------------------- /constants/notifier.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type EventNotifyStatus int 4 | 5 | const ( 6 | EventNotifyStatusOn = 1 // 打开通知 7 | EventNotifyStatusOff = 2 // 关闭通知 8 | ) 9 | 10 | type EventNotifyTime int 11 | 12 | const ( 13 | EventNotifyTimeRealTime = 1 14 | EventNotifyTimeTimed = 2 // 8:00 am 15 | ) 16 | 17 | type EventNotifyType int 18 | 19 | const ( 20 | EventNotifyTypeStaffDeleteCustomer = 1 //员工删客户 21 | EventNotifyTypeCustomerDeleteStaff = 2 //客户删员工 22 | ) 23 | 24 | type IsNotified int 25 | 26 | const ( 27 | NotifiedAdmin = 1 28 | NotNotifiedAdmin = 2 29 | ) 30 | 31 | type EventName string 32 | 33 | const ( 34 | EventNameStaffDeleteCustomer = "staff_delete_customer" 35 | EventNameCustomerDeleteStaff = "customer_delete_staff" 36 | ) 37 | 38 | type TimedNotifyAdminMsg struct { 39 | ExtStaffID string `json:"ext_staff_id"` 40 | ExtCustomerID string `json:"ext_customer_id"` 41 | Content string `json:"content"` 42 | AdminIDs []string `json:"admin_ids"` 43 | } 44 | -------------------------------------------------------------------------------- /constants/quick_reply.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | type QuickReplyType int64 9 | 10 | const ( 11 | QuickReplyTypeCollection QuickReplyType = 1 12 | QuickReplyTypeText QuickReplyType = 2 13 | QuickReplyTypePic QuickReplyType = 3 14 | QuickReplyTypeNews QuickReplyType = 4 // website 15 | QuickReplyTypePDF QuickReplyType = 5 16 | QuickReplyTypeVideo QuickReplyType = 6 17 | ) 18 | 19 | const ( 20 | QuickReplyModuleName = "quick-reply" 21 | MassMsgModuleName = "mass-msg" 22 | WelcomeMsgModuleName = "welcome-msg" 23 | ) 24 | 25 | func (o QuickReplyField) Value() (driver.Value, error) { 26 | b, err := json.Marshal(o) 27 | return string(b), err 28 | } 29 | 30 | func (o *QuickReplyField) Scan(input interface{}) error { 31 | return json.Unmarshal(input.([]byte), o) 32 | } 33 | 34 | func (o QuickReplyField) GormDataType() string { 35 | return "json" 36 | } 37 | 38 | // QuickReplyField 欢迎语/群发消息的附件 39 | type QuickReplyField struct { 40 | MsgType string `json:"msg_type"` 41 | // 图片 42 | Image Img `json:"image" validate:"omitempty,required_if=FieldType image"` 43 | // 链接 44 | Link Link `json:"link" validate:"omitempty,required_if=FieldType link"` 45 | // 视频 46 | Video Vid `json:"video" validate:"omitempty,required_if=FieldType video"` 47 | // PDF 48 | Pdf PDF `json:"pdf" validate:"omitempty,required_if=FieldType video"` 49 | // text 50 | Text Text `json:"text"` 51 | } 52 | 53 | type Text struct { 54 | Content string `json:"content"` 55 | } 56 | 57 | // Img 图片 58 | type Img struct { 59 | Title string `json:"title"` 60 | Size string `json:"size"` 61 | // 用获取到的signd URL 62 | PicUrl string `json:"picurl"` 63 | MediaID string `json:"media_id"` 64 | } 65 | 66 | // Vid 视频 67 | type Vid struct { 68 | Title string `json:"title"` 69 | Size string `json:"size"` 70 | // 用获取到的signd URL 71 | PicUrl string `json:"picurl"` 72 | MediaID string `json:"media_id"` 73 | } 74 | 75 | type PDF struct { 76 | Title string `json:"title"` 77 | Size string `json:"size"` 78 | FileURL string `json:"fileurl"` 79 | MediaID string `json:"media_id"` 80 | } 81 | -------------------------------------------------------------------------------- /constants/role.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // RoleType 角色类型 4 | type RoleType string 5 | 6 | const ( 7 | // RoleTypeSuperAdmin 超级管理员 8 | RoleTypeSuperAdmin RoleType = "superAdmin" 9 | // RoleTypeAdmin 企业普通管理员 10 | RoleTypeAdmin RoleType = "admin" 11 | // RoleTypeDepartmentAdmin 企业部门管理员 12 | RoleTypeDepartmentAdmin RoleType = "departmentAdmin" 13 | // RoleTypeStaff 企业普通员工 14 | RoleTypeStaff RoleType = "staff" 15 | ) 16 | -------------------------------------------------------------------------------- /constants/seed.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // SeedID 初始化数据ID 4 | type SeedID string 5 | 6 | const ( 7 | DefaultID SeedID = "1310832952200000000" 8 | DefaultCorpID SeedID = "1310832952200000999" 9 | DefaultCorpAdminID SeedID = "1310832952200000001" 10 | DefaultCorpAdminRoleID SeedID = "1310832952200000103" 11 | DefaultCorpDepartmentAdminRoleID SeedID = "1310832952200000104" 12 | DefaultCorpStaffRoleID SeedID = "1310832952200000105" 13 | DefaultCorpSuperAdminRoleID SeedID = "1310832952200000106" 14 | 15 | DefaultContactWayGroupID SeedID = "1310832952100000001" 16 | ) 17 | -------------------------------------------------------------------------------- /constants/session.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type SessionName string 4 | 5 | const ( 6 | // CustomerSessionName 前台用户Session 7 | CustomerSessionName SessionName = "OpenSCRMCustomerSession" 8 | // StaffSessionName 前台员工Session 9 | StaffSessionName SessionName = "OpenSCRMStaffSession" 10 | // StaffAdminSessionName 企业员工后台session 11 | StaffAdminSessionName SessionName = "OpenSCRMStaffAdminSession" 12 | // CorpAdminSessionName 企业超级管理员session 13 | CorpAdminSessionName SessionName = "OpenSCRMCorpAdminSession" 14 | // SaasAdminSessionName Saas管理员Session 15 | SaasAdminSessionName SessionName = "OpenSCRMSaasAdminSession" 16 | ) 17 | 18 | type SessionField string 19 | 20 | const ( 21 | // StaffID 员工ID 22 | StaffID SessionField = "ExtStaffID" 23 | // CustomerID 客户ID 24 | CustomerID SessionField = "CustomerID" 25 | // ExtCorpID 外部企业ID 26 | ExtCorpID SessionField = "ExtCorpID" 27 | // ExtStaffID 外部员工ID 28 | ExtStaffID SessionField = "ExtStaffID" 29 | // ExtCustomerID 外部客户ID 30 | ExtCustomerID SessionField = "ExtCustomerID" 31 | // StaffInfo 会话中的员工信息 32 | StaffInfo SessionField = "StaffInfo" 33 | // CustomerInfo 会话中的客户信息 34 | CustomerInfo SessionField = "CustomerInfo" 35 | // QrcodeAuthState 扫码登录state 36 | QrcodeAuthState SessionField = "QrcodeAuthState" 37 | ) 38 | -------------------------------------------------------------------------------- /constants/sort.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // SortField 排序字段 4 | // id 5 | // created_at 6 | // updated_at 7 | // sort_weight 8 | type SortField string 9 | 10 | const ( 11 | SortFieldID SortField = "id" 12 | SortFieldCreatedAt SortField = "created_at" 13 | SortFieldUpdatedAt SortField = "updated_at" 14 | SortFieldSortWeight SortField = "sort_weight" 15 | ) 16 | 17 | // SortType 排序方式,升序,降序,默认降序 18 | // asc 19 | // desc 20 | type SortType string 21 | 22 | const ( 23 | SortTypeAsc SortType = "asc" 24 | SortTypeDesc SortType = "desc" 25 | ) 26 | -------------------------------------------------------------------------------- /constants/staff.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // UserGender 用户性别 4 | type UserGender int 5 | 6 | const ( 7 | // UserGenderUnspecified 性别未定义 8 | UserGenderUnspecified UserGender = 0 9 | // UserGenderMale 男性 10 | UserGenderMale UserGender = 1 11 | // UserGenderFemale 女性 12 | UserGenderFemale UserGender = 2 13 | // UserGenderUnknown 未知 14 | UserGenderUnknown UserGender = 3 15 | ) 16 | 17 | // UserStatus 用户激活信息 18 | // 19 | // 已激活代表已激活企业微信或已关注微工作台(原企业号)。 20 | // 未激活代表既未激活企业微信又未关注微工作台(原企业号)。 21 | type UserStatus int 22 | 23 | const ( 24 | // UserStatusActivated 已激活 25 | UserStatusActivated UserStatus = 1 26 | // UserStatusDeactivated 已禁用 27 | UserStatusDeactivated UserStatus = 2 28 | // UserStatusUnactivated 未激活 29 | UserStatusUnactivated UserStatus = 4 30 | ) 31 | 32 | // FollowUserAddWay 该成员添加此客户的来源 33 | // 34 | // 具体含义详见[来源定义](https://work.weixin.qq.com/api/doc/90000/90135/92114#13878/%E6%9D%A5%E6%BA%90%E5%AE%9A%E4%B9%89) 35 | type FollowUserAddWay int 36 | 37 | const ( 38 | // FollowUserAddWayUnknown 未知来源 39 | FollowUserAddWayUnknown FollowUserAddWay = 0 40 | // FollowUserAddWayQRCode 扫描二维码 41 | FollowUserAddWayQRCode FollowUserAddWay = 1 42 | // FollowUserAddWayMobile 搜索手机号 43 | FollowUserAddWayMobile FollowUserAddWay = 2 44 | // FollowUserAddWayCard 名片分享 45 | FollowUserAddWayCard FollowUserAddWay = 3 46 | // FollowUserAddWayGroupChat 群聊 47 | FollowUserAddWayGroupChat FollowUserAddWay = 4 48 | // FollowUserAddWayAddressBook 手机通讯录 49 | FollowUserAddWayAddressBook FollowUserAddWay = 5 50 | // FollowUserAddWayWeChatContact 微信联系人 51 | FollowUserAddWayWeChatContact FollowUserAddWay = 6 52 | // FollowUserAddWayWeChatFriendApply 来自微信的添加好友申请 53 | FollowUserAddWayWeChatFriendApply FollowUserAddWay = 7 54 | // FollowUserAddWayThirdParty 安装第三方应用时自动添加的客服人员 55 | FollowUserAddWayThirdParty FollowUserAddWay = 8 56 | // FollowUserAddWayEmail 搜索邮箱 57 | FollowUserAddWayEmail FollowUserAddWay = 9 58 | // FollowUserAddWayInternalShare 内部成员共享 59 | FollowUserAddWayInternalShare FollowUserAddWay = 201 60 | // FollowUserAddWayAdmin 管理员/负责人分配 61 | FollowUserAddWayAdmin FollowUserAddWay = 202 62 | ) 63 | -------------------------------------------------------------------------------- /constants/storage.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type StorageType string 4 | 5 | const ( 6 | AliyunStorage StorageType = "aliyun" 7 | QcloudStorage StorageType = "qcloud" 8 | LocalStorage StorageType = "local" 9 | ) 10 | 11 | // HTTPMethod HTTP request method 12 | type HTTPMethod string 13 | 14 | const ( 15 | // HTTPGet HTTP GET 16 | HTTPGet HTTPMethod = "GET" 17 | 18 | // HTTPPut HTTP PUT 19 | HTTPPut HTTPMethod = "PUT" 20 | 21 | // HTTPHead HTTP HEAD 22 | HTTPHead HTTPMethod = "HEAD" 23 | 24 | // HTTPPost HTTP POST 25 | HTTPPost HTTPMethod = "POST" 26 | 27 | // HTTPDelete HTTP DELETE 28 | HTTPDelete HTTPMethod = "DELETE" 29 | ) 30 | -------------------------------------------------------------------------------- /constants/time.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "time" 4 | 5 | var BeiJinTime = time.FixedZone("Beijing Time", int((8 * time.Hour).Seconds())) 6 | 7 | var PRCLocation = BeiJinTime 8 | 9 | const ( 10 | TimeLayout = "15:04:05" 11 | DateLayout = "2006-01-02" 12 | DateTimeLayout = "2006-01-02 15:04:05" 13 | ) 14 | 15 | var WeekdayMap = map[string]int{ 16 | "周一": 1, 17 | "周二": 2, 18 | "周三": 3, 19 | "周四": 4, 20 | "周五": 5, 21 | "周六": 6, 22 | "周日": 0, 23 | } 24 | -------------------------------------------------------------------------------- /constants/topic.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type Topic string 4 | 5 | func (o Topic) String() string { 6 | return string(o) 7 | } 8 | 9 | const ( 10 | TagAdminTopic Topic = "topic:TagAdminTopic" 11 | DataExportTopic Topic = "topic:DataExportTopic" 12 | RemainderTopic Topic = "topic:RemainderTopic" 13 | MassMsgTopic Topic = "topic:MassMsgTopic" 14 | GroupChatMassMsgTopic Topic = "topic:GroupChatMassMsgTopic" 15 | SyncCustomerDataTopic Topic = "topic:SyncCustomerDataTopic" 16 | RefreshContactWayTopic Topic = "topic:RefreshContactWayTopic" 17 | ) 18 | 19 | type JobPrefix string 20 | 21 | func (o JobPrefix) String() string { 22 | return string(o) 23 | } 24 | 25 | const ( 26 | ContactWayJobPrefix JobPrefix = "job:contactWay:" 27 | ) 28 | -------------------------------------------------------------------------------- /controller/msg_arch.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "github.com/gin-gonic/gin" 8 | "github.com/pkg/errors" 9 | "msg/common/app" 10 | "msg/common/ecode" 11 | "msg/common/log" 12 | "msg/common/util" 13 | "msg/conf" 14 | "msg/requests" 15 | "msg/responses" 16 | "msg/services" 17 | ) 18 | 19 | type MsgArch struct { 20 | srv *services.MsgArch 21 | } 22 | 23 | func CheckMAC(message []byte, messageMAC string, key []byte) bool { 24 | mac := hmac.New(sha256.New, key) 25 | mac.Write(message) 26 | expectedMAC := mac.Sum(nil) 27 | log.Sugar.Debugw("Isigned", ">>", hex.EncodeToString(expectedMAC)) 28 | return hex.EncodeToString(expectedMAC) == messageMAC 29 | } 30 | 31 | // Sync 同步聊天记录 32 | func (o MsgArch) Sync(c *gin.Context) { 33 | handler := app.NewDummyHandler(c) 34 | req := requests.SyncReq{} 35 | ok, err := handler.BindAndValidateReq(&req) 36 | if !ok { 37 | handler.ResponseBadRequestError(errors.WithStack(err)) 38 | return 39 | } 40 | 41 | if !CheckMAC([]byte(req.ExtCorpID), req.Signature, []byte(conf.Settings.App.InnerSrvAppCode)) { 42 | err := ecode.CheckSignFailed 43 | log.TracedError("QuerySessions failed", err) 44 | handler.ResponseError(err) 45 | return 46 | } 47 | 48 | err = o.srv.Sync(req.ExtCorpID) 49 | if err != nil { 50 | log.TracedError("QuerySessions failed", err) 51 | handler.ResponseError(err) 52 | return 53 | } 54 | 55 | handler.ResponseItem(nil) 56 | } 57 | 58 | // QuerySessions 59 | // 查询会话 60 | func (o MsgArch) QuerySessions(c *gin.Context) { 61 | req := requests.InnerQuerySessionsReq{} 62 | if err := app.BindAndValid(c, &req); err != nil { 63 | app.ResponseErr(c, err) 64 | return 65 | } 66 | needSignBytes, err := util.GenBytesOrderByColumn(req.QuerySessionReq) 67 | if err != nil { 68 | app.ResponseErr(c, err) 69 | return 70 | } 71 | log.Sugar.Debugw("req", ">>", req) 72 | log.Sugar.Debugw("Signature", ">>", req.Signature) 73 | if !CheckMAC(needSignBytes, req.Signature, []byte(conf.Settings.App.InnerSrvAppCode)) { 74 | err := ecode.CheckSignFailed 75 | log.TracedError("CheckSignFailed", err) 76 | app.ResponseErr(c, err) 77 | return 78 | } 79 | 80 | sessionItems, total, err := o.srv.QuerySessions(req.QuerySessionReq, req.ExtCorpID) 81 | if err != nil { 82 | log.TracedError("QuerySessions failed", err) 83 | app.ResponseErr(c, err) 84 | return 85 | } 86 | 87 | app.ResponseItems(c, sessionItems, total) 88 | } 89 | 90 | // QueryChatMsgs 91 | // 查询某个会话聊天记录 92 | func (o MsgArch) QueryChatMsgs(c *gin.Context) { 93 | req := requests.InnerQueryMsgsReq{} 94 | if err := app.BindAndValid(c, &req); err != nil { 95 | app.ResponseErr(c, errors.WithStack(err)) 96 | return 97 | } 98 | 99 | bytes, err := util.GenBytesOrderByColumn(req.QueryChatMsgReq) 100 | if err != nil { 101 | app.ResponseErr(c, err) 102 | return 103 | } 104 | if !CheckMAC(bytes, req.Signature, []byte(conf.Settings.App.InnerSrvAppCode)) { 105 | err := ecode.CheckSignFailed 106 | log.TracedError("Query msgs failed", err) 107 | app.ResponseErr(c, err) 108 | return 109 | } 110 | 111 | Msgs, total, err := o.srv.QueryMsgs(req.QueryChatMsgReq, req.ExtCorpID) 112 | if err != nil { 113 | log.TracedError("Query msgs failed", err) 114 | app.ResponseErr(c, err) 115 | return 116 | } 117 | 118 | app.ResponseItem(c, responses.InnerMsgArchSerMsgResp{Items: Msgs, Total: total}) 119 | } 120 | 121 | // SearchMsgs 122 | // 搜索文字内容 123 | func (o MsgArch) SearchMsgs(c *gin.Context) { 124 | req := requests.InnerSearchMsgReq{} 125 | if err := app.BindAndValid(c, &req); err != nil { 126 | app.ResponseErr(c, errors.WithStack(err)) 127 | return 128 | } 129 | 130 | bytes, err := util.GenBytesOrderByColumn(req.SearchMsgReq) 131 | if err != nil { 132 | app.ResponseErr(c, err) 133 | return 134 | } 135 | if !CheckMAC(bytes, req.Signature, []byte(conf.Settings.App.InnerSrvAppCode)) { 136 | err = ecode.CheckSignFailed 137 | log.TracedError("CheckMAC failed", err) 138 | app.ResponseErr(c, err) 139 | return 140 | } 141 | 142 | Msgs, total, err := o.srv.SearchMsgs(req.SearchMsgReq, req.ExtCorpID) 143 | if err != nil { 144 | log.TracedError("Search msgs failed", err) 145 | app.ResponseErr(c, err) 146 | return 147 | } 148 | 149 | app.ResponseItem(c, responses.InnerMsgArchSerMsgResp{Items: Msgs, Total: total}) 150 | 151 | } 152 | 153 | func NewMsgArch() *MsgArch { 154 | return &MsgArch{srv: services.NewMsgArch()} 155 | } 156 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # yaml 配置实例 2 | version: '3' 3 | services: 4 | web: 5 | image: docker.io/acethan/msg_arch 6 | volumes: 7 | - /conf/config.yaml:/data/xjyk/conf/config.yaml 8 | ports: 9 | - "8080:8080" 10 | links: 11 | - mysql 12 | mysql: 13 | image: mysql:8.0 14 | ports: 15 | - "3306:3306" 16 | environment: 17 | MYSQL_ROOT_PASSWORD: NWVj5IowIGk0dZlBCSF 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module msg 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/ChimeraCoder/gojson v1.1.0 7 | github.com/PuerkitoBio/goquery v1.7.1 8 | github.com/aliyun/aliyun-oss-go-sdk v2.2.7+incompatible 9 | github.com/bwmarrin/snowflake v0.3.0 10 | github.com/cenkalti/backoff/v4 v4.1.1 11 | github.com/gin-contrib/sessions v0.0.3 12 | github.com/gin-gonic/gin v1.8.1 13 | github.com/go-playground/locales v0.14.0 14 | github.com/go-playground/universal-translator v0.18.0 15 | github.com/go-playground/validator/v10 v10.10.0 16 | github.com/go-redis/redis/v8 v8.11.2 17 | github.com/go-resty/resty/v2 v2.6.0 18 | github.com/gogf/gf v1.16.5 19 | github.com/golang-jwt/jwt v3.2.2+incompatible 20 | github.com/iancoleman/strcase v0.2.0 21 | github.com/jinzhu/copier v0.3.2 22 | github.com/json-iterator/go v1.1.12 23 | github.com/pkg/errors v0.9.1 24 | github.com/spf13/viper v1.8.1 25 | github.com/stretchr/testify v1.7.1 26 | github.com/tencentyun/cos-go-sdk-v5 v0.7.29 27 | github.com/thoas/go-funk v0.9.0 28 | github.com/urfave/cli/v2 v2.3.0 29 | go.uber.org/zap v1.19.0 30 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e 31 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 32 | gopkg.in/guregu/null.v4 v4.0.0 33 | gorm.io/driver/mysql v1.5.0 34 | gorm.io/gorm v1.25.0 35 | ) 36 | -------------------------------------------------------------------------------- /lib/WeWorkFinanceSdk.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/WeWorkFinanceSdk.dll -------------------------------------------------------------------------------- /lib/WeWorkFinanceSdk.lib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/WeWorkFinanceSdk.lib -------------------------------------------------------------------------------- /lib/WeWorkFinanceSdk_C.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/WeWorkFinanceSdk_C.h -------------------------------------------------------------------------------- /lib/libWeWorkFinanceSdk_C.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/libWeWorkFinanceSdk_C.so -------------------------------------------------------------------------------- /lib/libcrypto-1_1-x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/libcrypto-1_1-x64.dll -------------------------------------------------------------------------------- /lib/libcurl-x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/libcurl-x64.dll -------------------------------------------------------------------------------- /lib/libssl-1_1-x64.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscrm/msg-server/ce7e55e32c307133ba62523ff7df3a6ae0f981c7/lib/libssl-1_1-x64.dll -------------------------------------------------------------------------------- /lib/version.txt: -------------------------------------------------------------------------------- 1 | 200215 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | #cgo CFLAGS: -I ./ -I./lib 5 | #cgo CXXFLAGS: -I./ 6 | #cgo LDFLAGS: -L./lib -lWeWorkFinanceSdk_C -ldl 7 | 8 | #include "./lib/WeWorkFinanceSdk_C.h" 9 | #include 10 | */ 11 | import "C" 12 | import ( 13 | "fmt" 14 | "github.com/gin-gonic/gin" 15 | val "github.com/go-playground/validator/v10" 16 | _ "github.com/pkg/errors" 17 | "golang.org/x/net/context" 18 | "msg/common/id_generator" 19 | "msg/common/log" 20 | "msg/common/storage" 21 | "msg/common/validator" 22 | "msg/conf" 23 | "msg/constants" 24 | "msg/controller" 25 | "msg/models" 26 | "msg/services" 27 | "net/http" 28 | "os" 29 | "os/signal" 30 | "syscall" 31 | "time" 32 | ) 33 | 34 | // 调用微信的动态链接库,出现无法链接的错误需要手动添加库路径 35 | // export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}: 36 | 37 | func validateConfig(c interface{}) { 38 | if err := validator.NewCustomValidator().ValidateStruct(c); err != nil { 39 | panic(err.(val.ValidationErrors)) 40 | } 41 | } 42 | 43 | func init() { 44 | err := conf.SetupSetting() 45 | if err != nil { 46 | panic(err) 47 | } 48 | log.SetupLogger(conf.Settings.App.Env) 49 | validateConfig(conf.Settings) 50 | models.DB = models.InitDB(conf.Settings.DB) 51 | id_generator.SetupIDGenerator() 52 | storage.Setup(conf.Settings.Storage) 53 | } 54 | 55 | func main() { 56 | arch := services.NewMsgArch() 57 | arch.Init() 58 | err := arch.Sync(conf.Settings.WeWork.ExtCorpID) 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | msgArch := controller.NewMsgArch() 64 | r := gin.New() 65 | 66 | apiV1 := r.Group("/api/v1") 67 | apiV1.POST(constants.MsgArchSrvPathSync, msgArch.Sync) 68 | apiV1.GET(constants.MsgArchSrvPathSessions, msgArch.QuerySessions) 69 | apiV1.GET(constants.MsgArchSrvPathMsgs, msgArch.QueryChatMsgs) 70 | apiV1.GET(constants.MsgArchSrvSearchMsgs, msgArch.SearchMsgs) 71 | 72 | s := &http.Server{ 73 | Addr: fmt.Sprintf(":%d", conf.Settings.Server.MsgArchHttpPort), 74 | Handler: r, 75 | ReadTimeout: conf.Settings.Server.ReadTimeout, 76 | WriteTimeout: conf.Settings.Server.WriteTimeout, 77 | MaxHeaderBytes: 1 << 20, 78 | } 79 | 80 | go func() { 81 | if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 82 | log.Sugar.Fatalf("s.ListenAndServe err: %v", err) 83 | } 84 | }() 85 | 86 | quit := make(chan os.Signal) 87 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 88 | <-quit 89 | fmt.Println("Shutting down server...") 90 | 91 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 92 | defer cancel() 93 | if err := s.Shutdown(ctx); err != nil { 94 | log.Sugar.Fatalf("server forced to shutdown: %v", err) 95 | } 96 | 97 | log.Sugar.Info("Server exited") 98 | } 99 | -------------------------------------------------------------------------------- /models/customer.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "gorm.io/gorm/clause" 6 | "msg/common/app" 7 | "msg/requests" 8 | ) 9 | 10 | type UserGender int 11 | 12 | type Customer struct { 13 | ExtCorpModel 14 | // 微信定义的客户ID 15 | ExtID string `gorm:"type:char(32);uniqueIndex:idx_ext_customer_id;comment:微信定义的userID" json:"ext_customer_id"` 16 | // 微信用户对应微信昵称;企业微信用户,则为联系人或管理员设置的昵称、认证的实名和账号名称 17 | Name string `gorm:"type:varchar(255);comment:名称,微信用户对应微信昵称;企业微信用户,则为联系人或管理员设置的昵称、认证的实名和账号名称" json:"name"` 18 | // 职位,客户为企业微信时使用 19 | Position string `gorm:"varchar(255);comment:职位,客户为企业微信时使用" json:"position"` 20 | // 客户的公司名称,仅当客户ID为企业微信ID时存在 21 | CorpName string `gorm:"type:varchar(255);comment:客户的公司名称,仅当客户ID为企业微信ID时存在" json:"corp_name"` 22 | // 头像 23 | Avatar string `gorm:"type:varchar(255);comment:头像" json:"avatar"` 24 | // 客户类型 1-微信用户, 2-企业微信用户 25 | Type int `gorm:"type:tinyint(1);index;comment:类型,1-微信用户, 2-企业微信用户" json:"type"` 26 | // 0-未知 1-男性 2-女性 27 | Gender int `gorm:"type:tinyint;comment:性别,0-未知 1-男性 2-女性" json:"gender"` 28 | Unionid string `gorm:"type:varchar(128);comment:微信开放平台的唯一身份标识(微信unionID)" json:"unionid"` 29 | // 仅当联系人类型是企业微信用户时有此字段 30 | ExternalProfile ExternalProfile `gorm:"type:json;comment:仅当联系人类型是企业微信用户时有此字段" json:"external_profile"` 31 | // 所属员工 32 | Staffs []CustomerStaff `gorm:"foreignKey:ExtCustomerID;references:ExtID" json:"staff_relations"` 33 | // 所属员工 34 | Timestamp 35 | } 36 | 37 | func (cs Customer) Upsert(customer Customer) error { 38 | updateFields := map[string]interface{}{ 39 | "name": customer.Name, 40 | "position": customer.Position, 41 | "corp_name": customer.CorpName, 42 | "avatar": customer.Avatar, 43 | "type": customer.Type, 44 | "gender": customer.Gender, 45 | "unionid": customer.Unionid, 46 | "external_profile": customer.ExternalProfile, 47 | } 48 | return DB.Clauses(clause.OnConflict{ 49 | Columns: []clause.Column{{Name: "ext_id"}}, 50 | DoUpdates: clause.Assignments(updateFields), 51 | }).Create(&customer).Error 52 | 53 | } 54 | 55 | func (cs Customer) Get(ID string, extCorpID string, withStaffRelation bool) (*Customer, error) { 56 | customer := Customer{} 57 | db := DB.Model(&Customer{}).Where("id = ? and ext_corp_id = ?", ID, extCorpID) 58 | if withStaffRelation { 59 | db = db.Preload("Staffs").Preload("Staffs.CustomerStaffTags") 60 | } 61 | err := db.Find(&customer).Error 62 | if err != nil { 63 | err = errors.Wrap(err, "Get customer by id failed") 64 | return &customer, err 65 | } 66 | return &customer, nil 67 | } 68 | 69 | func (cs Customer) Query( 70 | req requests.QueryCustomerReq, extCorpID string, pager *app.Pager) ([]*Customer, int64, error) { 71 | 72 | var customers []*Customer 73 | 74 | db := DB.Table("customer"). 75 | Joins("left join customer_staff cs on customer.ext_id = cs.ext_customer_id"). 76 | Joins("left join customer_staff_tag cst on cst.customer_staff_id = cs.id"). 77 | Where("cs.ext_corp_id = ?", extCorpID) 78 | 79 | if req.Name != "" { 80 | db = db.Where("customer.name like ?", req.Name+"%") 81 | } 82 | if req.Gender != 0 { 83 | db = db.Where("customer.gender = ?", req.Gender) 84 | } 85 | if req.Type != 0 { 86 | db = db.Where("customer.type = ?", req.Type) 87 | } 88 | if len(req.ExtStaffIDs) > 0 { 89 | db = db.Where("cs.ext_staff_id in (?)", req.ExtStaffIDs) 90 | } 91 | if req.StartTime != "" { 92 | db = db.Where("createtime between ? and ?", req.StartTime, req.EndTime) 93 | } 94 | if len(req.ExtTagIDs) > 0 { 95 | //db = db.Where("json_contains(cs.ext_tag_ids, json_array(?))", customerStaff.ExtTagIDs) 96 | db = db.Where("cst.ext_tag_id in (?)", req.ExtTagIDs) 97 | } 98 | if req.ChannelType > 0 { 99 | db = db.Where("cs.add_way = ?", req.ChannelType) 100 | } 101 | 102 | var total int64 103 | if err := db.Distinct("customer.id").Count(&total).Error; err != nil { 104 | return nil, 0, err 105 | } 106 | 107 | pageOffset := app.GetPageOffset(pager.Page, pager.PageSize) 108 | if pageOffset >= 0 && pager.PageSize > 0 { 109 | db = db.Offset(pageOffset).Limit(pager.PageSize) 110 | } 111 | if err := db.Preload("Staffs").Preload("Staffs.CustomerStaffTags").Select("customer.*").Group("customer.ext_id").Find(&customers).Error; err != nil { 112 | return nil, 0, err 113 | } 114 | return customers, total, nil 115 | } 116 | 117 | func (cs Customer) GetByExtID( 118 | ExtCustomerID string, extStaffIDs []string, withStaffRelation bool) (customer Customer, err error) { 119 | 120 | db := DB.Model(&Customer{}) 121 | if withStaffRelation { 122 | db = db.Preload("Staffs", "ext_staff_id IN (?)", extStaffIDs).Preload("Staffs.CustomerStaffTags") 123 | } 124 | err = db.Where("ext_id = ? ", ExtCustomerID).First(&customer).Error 125 | if err != nil { 126 | err = errors.Wrap(err, "Get customer by id failed") 127 | return customer, err 128 | } 129 | return customer, nil 130 | } 131 | -------------------------------------------------------------------------------- /models/customer_staff_tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gorm.io/gorm/clause" 5 | "msg/constants" 6 | ) 7 | 8 | type CustomerStaffTag struct { 9 | ExtCorpModel 10 | CustomerStaffID string `json:"customer_staff_id" gorm:"type:bigint;index;"` 11 | // TagID 标签id 12 | ExtTagID string `json:"ext_tag_id"` 13 | // GroupName 该成员添加此外部联系人所打标签的分组名称(标签功能需要企业微信升级到2.7.5及以上版本) 14 | GroupName string `json:"group_name"` 15 | // TagName 该成员添加此外部联系人所打标签名称 16 | TagName string `json:"tag_name"` 17 | // Type 该成员添加此外部联系人所打标签类型, 1-企业设置, 2-用户自定义 18 | Type constants.FollowUserTagType `gorm:"type:tinyint" json:"type"` 19 | Timestamp 20 | } 21 | 22 | // CreateInBatches 批量创建 23 | 24 | func (c CustomerStaffTag) CreateInBatches(customerStaffTags []CustomerStaffTag) error { 25 | return DB.CreateInBatches(customerStaffTags, len(customerStaffTags)).Error 26 | } 27 | 28 | func (c CustomerStaffTag) Delete(customerStaffId string, extTagsIDs []string) error { 29 | return DB.Where("customer_staff_id = ?", customerStaffId). 30 | Where("tag_id in (?)", extTagsIDs). 31 | Delete(&CustomerStaffTag{}).Error 32 | } 33 | 34 | func (c CustomerStaffTag) Upsert(tag []CustomerStaffTag) error { 35 | return DB.Clauses(clause.OnConflict{ 36 | Columns: []clause.Column{{Name: "customer_staff_id"}}, 37 | DoUpdates: clause.AssignmentColumns( 38 | []string{"group_name", "ext_tag_id", "type", "tag_name"}, 39 | ), 40 | }).CreateInBatches(&tag, len(tag)).Error 41 | } 42 | -------------------------------------------------------------------------------- /models/external_profile.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | func (o ExternalProfile) Value() (driver.Value, error) { 9 | b, err := json.Marshal(o) 10 | return string(b), err 11 | } 12 | 13 | func (o *ExternalProfile) Scan(input interface{}) error { 14 | return json.Unmarshal(input.([]byte), o) 15 | } 16 | 17 | func (o ExternalProfile) GormDataType() string { 18 | return "json" 19 | } 20 | 21 | // ExternalProfile 使gorm支持 ExternalProfile 转为json读取 22 | type ExternalProfile struct { 23 | ExternalCorpName string `json:"external_corp_name"` 24 | ExternalAttr []ExternalAttr `json:"external_attr"` 25 | } 26 | 27 | // 28 | //type Text struct { 29 | // Value string `json:"value"` 30 | //} 31 | type Web struct { 32 | Url string `json:"url"` 33 | Title string `json:"title"` 34 | } 35 | type Miniprogram struct { 36 | Appid string `json:"appid"` 37 | Pagepath string `json:"pagepath"` 38 | Title string `json:"title"` 39 | } 40 | type ExternalAttr struct { 41 | Type int `json:"type"` 42 | Name string `json:"name"` 43 | Text Text `json:"text,omitempty"` 44 | Web Web `json:"web,omitempty"` 45 | Miniprogram Miniprogram `json:"miniprogram,omitempty"` 46 | } 47 | -------------------------------------------------------------------------------- /models/internal_tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "gorm.io/gorm/clause" 6 | "msg/common/app" 7 | "msg/constants" 8 | ) 9 | 10 | type InternalTag struct { 11 | ExtCorpModel 12 | ExtStaffID string `gorm:"index;type:char(32)" json:"ext_staff_id"` 13 | Name string `gorm:"type:char(32)" json:"name"` 14 | Timestamp 15 | } 16 | 17 | func (o InternalTag) Query(tag InternalTag, sorter *app.Sorter, pager *app.Pager) ([]InternalTag, int64, error) { 18 | items := make([]InternalTag, 0) 19 | db := DB.Model(&InternalTag{}).Where("ext_corp_id = ?", tag.ExtCorpID) 20 | total := int64(0) 21 | err := db.Count(&total).Error 22 | if err != nil || total == 0 { 23 | err = errors.Wrap(err, "Count InternalTag failed") 24 | return nil, 0, err 25 | } 26 | 27 | sorter.SetDefault() 28 | db = db.Order(clause.OrderByColumn{Column: clause.Column{Name: string(sorter.SortField)}, Desc: sorter.SortType == constants.SortTypeDesc}) 29 | 30 | pager.SetDefault() 31 | db = db.Offset(pager.GetOffset()).Limit(pager.GetLimit()) 32 | 33 | err = db.Find(&items).Error 34 | if err != nil { 35 | err = errors.Wrap(err, "Find InternalTag failed") 36 | return nil, 0, err 37 | } 38 | 39 | return items, 0, err 40 | } 41 | 42 | func (o InternalTag) Create(tag InternalTag) error { 43 | return DB.Create(&tag).Error 44 | } 45 | 46 | func (o InternalTag) Delete(ids []string, extCorpID string) (int64, error) { 47 | res := DB.Where("ext_corp_id = ?", extCorpID).Where("id in (?)", ids).Delete(&InternalTag{}) 48 | return res.RowsAffected, res.Error 49 | } 50 | 51 | func (o InternalTag) GetByIDs(ids []string) (tags []InternalTag, err error) { 52 | err = DB.Model(&InternalTag{}).Where("id in (?)", ids).Find(&tags).Error 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /models/model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/driver/mysql" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/logger" 8 | "gorm.io/gorm/schema" 9 | "msg/common/log" 10 | "msg/conf" 11 | "msg/constants" 12 | "os" 13 | "time" 14 | ) 15 | 16 | var DB *gorm.DB 17 | 18 | type Timestamp struct { 19 | CreatedAt time.Time `sql:"index" gorm:"comment:'创建时间'" json:"created_at"` 20 | UpdatedAt time.Time `sql:"index" gorm:"comment:'更新时间'" json:"updated_at"` 21 | DeletedAt gorm.DeletedAt `sql:"index" gorm:"comment:'删除时间'" json:"deleted_at" swaggerignore:"true"` 22 | } 23 | 24 | type Model struct { 25 | ID string `gorm:"primaryKey;type:bigint AUTO_INCREMENT;comment:'ID'" json:"id" validate:"int64"` 26 | } 27 | 28 | type ExtCorpModel struct { 29 | // ID 30 | ID string `json:"id" gorm:"primaryKey;type:bigint;comment:'ID'" validate:"int64"` 31 | // ExtCorpID 外部企业ID 32 | ExtCorpID string `json:"ext_corp_id" gorm:"index;type:char(18);comment:外部企业ID" validate:"ext_corp_id"` 33 | // ExtCreatorID 创建者外部员工ID 34 | ExtCreatorID string `json:"ext_creator_id" gorm:"index;type:char(32);comment:创建者外部员工ID" validate:"word"` 35 | } 36 | 37 | // RefModel 关联表基本模型,ID仅用做唯一键,使用组合字段作为主键,方便去重,可实现Association replace保留原纪录 38 | type RefModel struct { 39 | // ID 40 | ID string `json:"id" gorm:"unique;type:bigint;comment:'ID'" validate:"int64"` 41 | // ExtCorpID 外部企业ID 42 | ExtCorpID string `json:"ext_corp_id" gorm:"index;type:char(18);comment:外部企业ID" validate:"ext_corp_id"` 43 | } 44 | 45 | //InitDB 初始化数据库连接 46 | func InitDB(c conf.DBConfig) (db *gorm.DB) { 47 | var err error 48 | 49 | gormLogLevel := logger.Warn 50 | if conf.Settings.App.Env == constants.DEV { 51 | gormLogLevel = logger.Info 52 | } 53 | 54 | db, err = gorm.Open( 55 | mysql.Open(fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Asia%%2FShanghai", 56 | c.User, 57 | c.Password, 58 | c.Host, 59 | c.Name)), 60 | &gorm.Config{ 61 | SkipDefaultTransaction: false, 62 | Logger: logger.Default.LogMode(gormLogLevel), 63 | NamingStrategy: schema.NamingStrategy{ 64 | SingularTable: true, // use singular table name, table for `User` would be `user` with this option enabled 65 | }}, 66 | ) 67 | if err != nil { 68 | log.Sugar.Error("models.Setup failed", "err", err, "conf", c) 69 | os.Exit(1) 70 | } 71 | if conf.Settings.App.AutoMigration { 72 | err = db.AutoMigrate(&ChatMsgContent{}, ChatMsg{}) 73 | if err != nil { 74 | log.Sugar.Error("model auto migrate failed", "err", err, "conf", c) 75 | os.Exit(1) 76 | } 77 | } 78 | 79 | return db 80 | } 81 | -------------------------------------------------------------------------------- /pkg/client_flags.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "github.com/urfave/cli/v2" 7 | "golang.org/x/net/http2" 8 | "io" 9 | "msg/pkg/go-workwx-develop" 10 | "net" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | const ( 17 | flagCorpID = "corpid" 18 | flagCorpSecret = "corpsecret" 19 | flagAgentID = "agentid" 20 | flagQyapiHostOverride = "qyapi-host-override" 21 | flagTLSKeyLogFile = "tls-key-logfile" 22 | 23 | flagMessageType = "message-type" 24 | flagSafe = "safe" 25 | flagToUser = "to-user" 26 | flagToUserShort = "u" 27 | flagToParty = "to-party" 28 | flagToPartyShort = "p" 29 | flagToTag = "to-tag" 30 | flagToTagShort = "t" 31 | flagToChat = "to-chat" 32 | flagToChatShort = "c" 33 | 34 | flagMediaID = "media-id" 35 | flagThumbMediaID = "thumb-media-id" 36 | flagDescription = "desc" 37 | flagTitle = "title" 38 | flagAuthor = "author" 39 | flagURL = "url" 40 | flagPicURL = "pic-url" 41 | flagButtonText = "button-text" 42 | flagSourceContentURL = "source-content-url" 43 | flagDigest = "digest" 44 | 45 | flagMediaType = "media-type" 46 | ) 47 | 48 | type CliOptions struct { 49 | CorpID string 50 | CorpSecret string 51 | AgentID int64 52 | QYAPIHostOverride string 53 | TLSKeyLogFile string 54 | } 55 | 56 | func mustGetConfig(c *cli.Context) *CliOptions { 57 | if !c.IsSet(flagCorpID) { 58 | panic("corpid must be set") 59 | } 60 | 61 | if !c.IsSet(flagCorpSecret) { 62 | panic("corpsecret must be set") 63 | } 64 | 65 | if !c.IsSet(flagAgentID) { 66 | panic("agentid must be set (for now; may later lift the restriction)") 67 | } 68 | 69 | return &CliOptions{ 70 | CorpID: c.String(flagCorpID), 71 | CorpSecret: c.String(flagCorpSecret), 72 | AgentID: c.Int64(flagAgentID), 73 | 74 | QYAPIHostOverride: c.String(flagQyapiHostOverride), 75 | TLSKeyLogFile: c.String(flagTLSKeyLogFile), 76 | } 77 | } 78 | 79 | // 80 | // impl CliOptions 81 | // 82 | 83 | func (c *CliOptions) makeHTTPClient() *http.Client { 84 | if c.TLSKeyLogFile == "" { 85 | return http.DefaultClient 86 | } 87 | 88 | f, err := os.OpenFile(c.TLSKeyLogFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 89 | if err != nil { 90 | fmt.Printf("can't open TLS key log file for writing: %+v\n", err) 91 | panic(err) 92 | } 93 | 94 | fmt.Fprintf(f, "# SSL/TLS secrets log file, generated by go\n") 95 | 96 | return &http.Client{ 97 | Transport: newTransportWithKeyLog(f), 98 | } 99 | } 100 | 101 | func (c *CliOptions) makeWorkwxClient() *workwx.WorkWX { 102 | httpClient := c.makeHTTPClient() 103 | if c.QYAPIHostOverride != "" { 104 | // wtf think of a way to change this 105 | return workwx.New(c.CorpID, 106 | workwx.WithQYAPIHost(c.QYAPIHostOverride), 107 | workwx.WithHTTPClient(httpClient), 108 | ) 109 | } 110 | return workwx.New(c.CorpID, workwx.WithHTTPClient(httpClient)) 111 | } 112 | 113 | func (c *CliOptions) MakeWorkwxApp() *workwx.App { 114 | return c.makeWorkwxClient().WithApp(c.CorpSecret, c.AgentID) 115 | } 116 | 117 | // newTransportWithKeyLog initializes a HTTP Transport with KeyLogWriter 118 | func newTransportWithKeyLog(keyLog io.Writer) *http.Transport { 119 | transport := &http.Transport{ 120 | //nolint: gosec // this transport is delibrately made to be a side channel 121 | TLSClientConfig: &tls.Config{KeyLogWriter: keyLog, InsecureSkipVerify: true}, 122 | 123 | // Copy of http.DefaultTransport 124 | Proxy: http.ProxyFromEnvironment, 125 | DialContext: (&net.Dialer{ 126 | Timeout: 30 * time.Second, 127 | KeepAlive: 30 * time.Second, 128 | DualStack: true, 129 | }).DialContext, 130 | MaxIdleConns: 100, 131 | IdleConnTimeout: 90 * time.Second, 132 | TLSHandshakeTimeout: 10 * time.Second, 133 | ExpectContinueTimeout: 1 * time.Second, 134 | } 135 | if err := http2.ConfigureTransport(transport); err != nil { 136 | panic(err) 137 | } 138 | return transport 139 | } 140 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/apis.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // execGetAccessToken 获取access_token 4 | func (c *App) execGetAccessToken(req accessTokenReq) (accessTokenResp, error) { 5 | var resp accessTokenResp 6 | err := c.executeWXApiGet("/cgi-bin/gettoken", req, &resp, false) 7 | if err != nil { 8 | return accessTokenResp{}, err 9 | } 10 | if bizErr := resp.TryIntoErr(); bizErr != nil { 11 | return accessTokenResp{}, bizErr 12 | } 13 | 14 | return resp, nil 15 | } 16 | 17 | // execGetJSAPITicket 获取企业的jsapi_ticket 18 | func (c *App) execGetJSAPITicket(req jsAPITicketReq) (jsAPITicketResp, error) { 19 | var resp jsAPITicketResp 20 | err := c.executeWXApiGet("/cgi-bin/get_jsapi_ticket", req, &resp, true) 21 | if err != nil { 22 | return jsAPITicketResp{}, err 23 | } 24 | if bizErr := resp.TryIntoErr(); bizErr != nil { 25 | return jsAPITicketResp{}, bizErr 26 | } 27 | 28 | return resp, nil 29 | } 30 | 31 | // execGetJSAPITicketAgentConfig 获取应用的jsapi_ticket 32 | func (c *App) execGetJSAPITicketAgentConfig(req jsAPITicketAgentConfigReq) (jsAPITicketResp, error) { 33 | var resp jsAPITicketResp 34 | err := c.executeWXApiGet("/cgi-bin/ticket/get", req, &resp, true) 35 | if err != nil { 36 | return jsAPITicketResp{}, err 37 | } 38 | if bizErr := resp.TryIntoErr(); bizErr != nil { 39 | return jsAPITicketResp{}, bizErr 40 | } 41 | 42 | return resp, nil 43 | } 44 | 45 | // execJSCode2Session 临时登录凭证校验code2Session 46 | func (c *App) execJSCode2Session(req jsCode2SessionReq) (jsCode2SessionResp, error) { 47 | var resp jsCode2SessionResp 48 | err := c.executeWXApiGet("/cgi-bin/miniprogram/jscode2session", req, &resp, true) 49 | if err != nil { 50 | return jsCode2SessionResp{}, err 51 | } 52 | if bizErr := resp.TryIntoErr(); bizErr != nil { 53 | return jsCode2SessionResp{}, bizErr 54 | } 55 | 56 | return resp, nil 57 | } 58 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/app_chat.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // CreateAppChat 创建群聊会话 4 | func (c *App) CreateAppChat(chatInfo *ChatInfo) (chatid string, err error) { 5 | resp, err := c.execAppChatCreate(appChatCreateReq{ 6 | ChatInfo: chatInfo, 7 | }) 8 | if err != nil { 9 | return "", err 10 | } 11 | return resp.ChatID, nil 12 | } 13 | 14 | // GetAppChat 获取群聊会话 15 | func (c *App) GetAppChat(chatID string) (*ChatInfo, error) { 16 | resp, err := c.execAppChatGet(appChatGetReq{ 17 | ChatID: chatID, 18 | }) 19 | if err != nil { 20 | return nil, err 21 | } 22 | obj := resp.ChatInfo 23 | return obj, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/app_chat_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | ) 7 | 8 | // appChatGetResp 获取群聊会话响应 9 | type appChatGetResp struct { 10 | CommonResp 11 | 12 | ChatInfo *ChatInfo `json:"chat_info"` 13 | } 14 | 15 | // appChatGetReq 获取群聊会话请求 16 | type appChatGetReq struct { 17 | ChatID string 18 | } 19 | 20 | var _ urlValuer = appChatGetReq{} 21 | 22 | func (x appChatGetReq) intoURLValues() url.Values { 23 | return url.Values{ 24 | "chatid": {x.ChatID}, 25 | } 26 | } 27 | 28 | // appChatCreateReq 创建群聊会话请求 29 | type appChatCreateReq struct { 30 | ChatInfo *ChatInfo 31 | } 32 | 33 | var _ bodyer = appChatCreateReq{} 34 | 35 | func (x appChatCreateReq) intoBody() ([]byte, error) { 36 | result, err := json.Marshal(x.ChatInfo) 37 | if err != nil { 38 | // should never happen unless OOM or similar bad things 39 | // TODO: error_chain 40 | return nil, err 41 | } 42 | 43 | return result, nil 44 | } 45 | 46 | // appChatCreateResp 创建群聊会话响应 47 | type appChatCreateResp struct { 48 | CommonResp 49 | ChatID string `json:"chatid"` 50 | } 51 | 52 | // execAppChatCreate 创建群聊会话 53 | func (c *App) execAppChatCreate(req appChatCreateReq) (appChatCreateResp, error) { 54 | var resp appChatCreateResp 55 | err := c.executeWXApiJSONPost("/cgi-bin/appchat/create", req, &resp, true) 56 | if err != nil { 57 | return appChatCreateResp{}, err 58 | } 59 | if bizErr := resp.TryIntoErr(); bizErr != nil { 60 | return appChatCreateResp{}, bizErr 61 | } 62 | 63 | return resp, nil 64 | } 65 | 66 | // execAppChatGet 获取群聊会话 67 | func (c *App) execAppChatGet(req appChatGetReq) (appChatGetResp, error) { 68 | var resp appChatGetResp 69 | err := c.executeWXApiGet("/cgi-bin/appchat/get", req, &resp, true) 70 | if err != nil { 71 | return appChatGetResp{}, err 72 | } 73 | if bizErr := resp.TryIntoErr(); bizErr != nil { 74 | return appChatGetResp{}, bizErr 75 | } 76 | 77 | return resp, nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/app_chat_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // ChatInfo 群聊信息 4 | type ChatInfo struct { 5 | // ChatID 群聊唯一标志 6 | ChatID string `json:"chatid"` 7 | // Name 群聊名 8 | Name string `json:"name"` 9 | // OwnerUserID 群主id 10 | OwnerUserID string `json:"owner"` 11 | // MemberUserIDs 群成员id列表 12 | MemberUserIDs []string `json:"userlist"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/callback.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "io/ioutil" 5 | "msg/pkg/go-workwx-develop/internal/lowlevel/encryptor" 6 | "msg/pkg/go-workwx-develop/internal/lowlevel/envelope" 7 | "msg/pkg/go-workwx-develop/internal/lowlevel/httpapi" 8 | "msg/pkg/go-workwx-develop/internal/lowlevel/signature" 9 | "net/http" 10 | ) 11 | 12 | type CallBackHandler struct { 13 | token string 14 | encryptor *encryptor.WorkWXEncryptor 15 | ep *envelope.Processor 16 | } 17 | 18 | func NewCBHandler(token string, encodingAESKey string) (*CallBackHandler, error) { 19 | enc, err := encryptor.NewWorkWXEncryptor(encodingAESKey) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | ep, err := envelope.NewProcessor(token, encodingAESKey) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &CallBackHandler{token: token, encryptor: enc, ep: ep}, nil 30 | } 31 | 32 | func (cb *CallBackHandler) GetCallBackMsg(r *http.Request) (*RxMessage, error) { 33 | defer r.Body.Close() 34 | body, err := ioutil.ReadAll(r.Body) 35 | if err != nil { 36 | //rw.WriteHeader(http.StatusInternalServerError) 37 | return nil, err 38 | } 39 | 40 | // 验签 41 | // 解析Xml 42 | ev, err := cb.ep.HandleIncomingMsg(r.URL, body) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | message, err := fromEnvelope(ev.Msg) 48 | if err != nil { 49 | return nil, err 50 | } 51 | return message, nil 52 | } 53 | 54 | // EchoTestHandler 55 | // wx后台配置服务器ip,回显 56 | func (cb *CallBackHandler) EchoTestHandler(rw http.ResponseWriter, r *http.Request) { 57 | url := r.URL 58 | 59 | if !signature.VerifyHTTPRequestSignature(cb.token, url, "") { 60 | rw.WriteHeader(http.StatusBadRequest) 61 | return 62 | } 63 | 64 | adapter := httpapi.URLValuesForEchoTestAPI(url.Query()) 65 | args, err := adapter.ToEchoTestAPIArgs() 66 | if err != nil { 67 | rw.WriteHeader(http.StatusBadRequest) 68 | return 69 | } 70 | 71 | payload, err := cb.encryptor.Decrypt([]byte(args.EchoStr)) 72 | if err != nil { 73 | rw.WriteHeader(http.StatusBadRequest) 74 | return 75 | } 76 | 77 | rw.WriteHeader(http.StatusOK) 78 | _, _ = rw.Write(payload.Msg) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/client_options.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "github.com/go-resty/resty/v2" 5 | "net/http" 6 | ) 7 | 8 | // DefaultQYAPIHost 默认企业微信 API Host 9 | const DefaultQYAPIHost = "https://qyapi.weixin.qq.com" 10 | 11 | type options struct { 12 | WxAPIHost string 13 | HTTP *http.Client 14 | restyCli *resty.Client 15 | } 16 | 17 | // CtorOption 客户端对象构造参数 18 | type CtorOption interface { 19 | applyTo(*options) 20 | } 21 | 22 | // impl Default for options 23 | func defaultOptions() (opt options) { 24 | opt = options{ 25 | WxAPIHost: DefaultQYAPIHost, 26 | HTTP: &http.Client{}, 27 | } 28 | opt.restyCli = resty.NewWithClient(opt.HTTP) 29 | return 30 | } 31 | 32 | type withQYAPIHost struct { 33 | x string 34 | } 35 | 36 | // WithQYAPIHost 覆盖默认企业微信 API 域名 37 | func WithQYAPIHost(host string) CtorOption { 38 | return &withQYAPIHost{x: host} 39 | } 40 | 41 | var _ CtorOption = (*withQYAPIHost)(nil) 42 | 43 | func (x *withQYAPIHost) applyTo(y *options) { 44 | y.WxAPIHost = x.x 45 | } 46 | 47 | type withHTTPClient struct { 48 | x *http.Client 49 | } 50 | 51 | // WithHTTPClient 使用给定的 http.Client 作为 HTTP 客户端 52 | func WithHTTPClient(client *http.Client) CtorOption { 53 | return &withHTTPClient{x: client} 54 | } 55 | 56 | var _ CtorOption = (*withHTTPClient)(nil) 57 | 58 | func (x *withHTTPClient) applyTo(y *options) { 59 | y.HTTP = x.x 60 | y.restyCli = resty.NewWithClient(x.x) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/contact_way.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // AddContactWay 配置客户联系「联系我」方式 4 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92572#配置客户联系「联系我」方式 5 | func (c *App) AddContactWay(req AddContactWay) (configID string, err error) { 6 | var resp addContactWayResp 7 | resp, err = c.execAddContactWay(req) 8 | if err != nil { 9 | return configID, err 10 | } 11 | configID = resp.ConfigID 12 | return 13 | } 14 | 15 | // GetContactWay 获取企业已配置的「联系我」方式 16 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92572#获取企业已配置的「联系我」方式 17 | func (c *App) GetContactWay(configID string) (contactWay ContactWay, err error) { 18 | var resp getContactWayResp 19 | resp, err = c.execGetContactWay(getContactWayReq{ConfigID: configID}) 20 | if err != nil { 21 | return 22 | } 23 | contactWay = resp.ContactWay 24 | return 25 | } 26 | 27 | // UpdateContactWay 更新企业已配置的「联系我」方式 28 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92572#更新企业已配置的「联系我」方式 29 | func (c *App) UpdateContactWay(req UpdateContactWay) (ok bool, err error) { 30 | var resp updateContactWayResp 31 | resp, err = c.execUpdateContactWay(req) 32 | if err != nil { 33 | return false, err 34 | } 35 | ok = resp.IsOK() 36 | return ok, err 37 | } 38 | 39 | // DelContactWay 删除企业已配置的「联系我」方式 40 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92572#删除企业已配置的「联系我」方式 41 | func (c *App) DelContactWay(configID string) (ok bool, err error) { 42 | var resp delContactWayResp 43 | resp, err = c.execDelContactWay(delContactWayReq{ 44 | ConfigID: configID, 45 | }) 46 | if err != nil { 47 | return false, err 48 | } 49 | ok = resp.IsOK() 50 | return ok, err 51 | } 52 | 53 | // CloseTempChat 结束临时会话 54 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92572#结束临时会话 55 | func (c *App) CloseTempChat(externalUserid string, userid string) (ok bool, err error) { 56 | var resp closeTempChatResp 57 | resp, err = c.execCloseTempChat(closeTempChatReq{ 58 | ExternalUserid: externalUserid, 59 | Userid: userid, 60 | }) 61 | if err != nil { 62 | return false, err 63 | } 64 | ok = resp.IsOK() 65 | return ok, err 66 | } 67 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/department_info.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // ListAllDepartments 获取全量组织架构。 4 | func (c *App) ListAllDepartments() ([]*DeptInfo, error) { 5 | resp, err := c.execDeptList(deptListReq{ 6 | HaveID: false, 7 | ID: 0, 8 | }) 9 | if err != nil { 10 | return nil, err 11 | } 12 | 13 | return resp.Department, nil 14 | } 15 | 16 | // ListDepartments 获取指定部门及其下的子部门。 17 | func (c *App) ListDepartments(id int64) ([]*DeptInfo, error) { 18 | resp, err := c.execDeptList(deptListReq{ 19 | HaveID: true, 20 | ID: id, 21 | }) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return resp.Department, nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/department_info_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // deptListResp 部门列表响应 4 | type deptListResp struct { 5 | CommonResp 6 | Department []*DeptInfo `json:"department"` 7 | } 8 | 9 | // execDeptList 获取部门列表 10 | func (c *App) execDeptList(req deptListReq) (deptListResp, error) { 11 | var resp deptListResp 12 | err := c.executeWXApiGet("/cgi-bin/department/list", req, &resp, true) 13 | if err != nil { 14 | return deptListResp{}, err 15 | } 16 | if bizErr := resp.TryIntoErr(); bizErr != nil { 17 | return deptListResp{}, bizErr 18 | } 19 | 20 | return resp, nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/department_info_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // DeptInfo 部门信息 4 | type DeptInfo struct { 5 | // ID 部门 ID 6 | ID int64 `json:"id"` 7 | // Name 部门名称 8 | Name string `json:"name"` 9 | // ParentID 父亲部门id。根部门为1 10 | ParentID int64 `json:"parentid"` 11 | // Order 在父部门中的次序值。order值大的排序靠前。值范围是[0, 2^32) 12 | Order uint32 `json:"order"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/errors.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "fmt" 5 | "msg/pkg/go-workwx-develop/errcodes" 6 | ) 7 | 8 | // ClientError 企业微信客户端 SDK 的响应错误 9 | type ClientError struct { 10 | // Code 错误码,0表示成功,非0表示调用失败。 11 | // 开发者需根据errcode是否为0判断是否调用成功(errcode意义请见全局错误码)。 12 | Code errcodes.ErrCode 13 | // Msg 错误信息,调用失败会有相关的错误信息返回。 14 | // 仅作参考,后续可能会有变动,因此不可作为是否调用成功的判据。 15 | Msg string 16 | } 17 | 18 | var _ error = (*ClientError)(nil) 19 | 20 | func (e *ClientError) Error() string { 21 | return fmt.Sprintf( 22 | "ClientError { Code: %d, Msg: %#v }", 23 | e.Code, 24 | e.Msg, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/group_chat.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // ListGroupChat 获取客户群列表 4 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92120#获取客户群列表 5 | func (c *App) ListGroupChat(req ListGroupChatReq) (ListGroupChatResp, error) { 6 | var resp ListGroupChatResp 7 | resp, err := c.execListGroupChat(req) 8 | if err != nil { 9 | return resp, err 10 | } 11 | return resp, nil 12 | } 13 | 14 | // GetGroupChat 获取客户群详情 15 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92122#获取客户群详情 16 | func (c *App) GetGroupChat(req GetGroupChatReq) (GetGroupChatResp, error) { 17 | resp, err := c.execGetGroupChat(req) 18 | if err != nil { 19 | return GetGroupChatResp{}, err 20 | } 21 | return resp, err 22 | } 23 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/group_chat_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // ListGroupChatReq 8 | type ListGroupChatReq struct { 9 | StatusFilter int `json:"status_filter"` 10 | OwnerFilter struct { 11 | UseridList []string `json:"userid_list"` 12 | } `json:"owner_filter"` 13 | Cursor string `json:"cursor"` 14 | Limit int `json:"limit"` 15 | } 16 | 17 | // ListGroupChatReq 获取客户群列表请求 18 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92120#获取客户群列表 19 | var _ bodyer = ListGroupChatReq{} 20 | 21 | func (x ListGroupChatReq) intoBody() ([]byte, error) { 22 | result, err := json.Marshal(x) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return result, nil 28 | } 29 | 30 | // ListGroupChatResp 获取客户群列表响应 31 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92120#获取客户群列表 32 | type ListGroupChatResp struct { 33 | CommonResp 34 | GroupChatList []struct { 35 | ChatID string `json:"chat_id"` 36 | Status int `json:"status"` 37 | } `json:"group_chat_list"` 38 | NextCursor string `json:"next_cursor"` 39 | } 40 | 41 | var _ bodyer = ListGroupChatResp{} 42 | 43 | func (x ListGroupChatResp) intoBody() ([]byte, error) { 44 | result, err := json.Marshal(x) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return result, nil 50 | } 51 | 52 | // execListGroupChat 获取客户群列表 53 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92120#获取客户群列表 54 | func (c *App) execListGroupChat(req ListGroupChatReq) (ListGroupChatResp, error) { 55 | var resp ListGroupChatResp 56 | err := c.executeWXApiJSONPost("/cgi-bin/externalcontact/groupchat/list", req, &resp, true) 57 | if err != nil { 58 | return ListGroupChatResp{}, err 59 | } 60 | if bizErr := resp.TryIntoErr(); bizErr != nil { 61 | return ListGroupChatResp{}, bizErr 62 | } 63 | 64 | return resp, nil 65 | } 66 | 67 | // execGetGroupChat 获取客户群详情 68 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92122#获取客户群详情 69 | func (c *App) execGetGroupChat(req GetGroupChatReq) (GetGroupChatResp, error) { 70 | var resp GetGroupChatResp 71 | err := c.executeWXApiJSONPost("/cgi-bin/externalcontact/groupchat/get", req, &resp, true) 72 | if err != nil { 73 | return GetGroupChatResp{}, err 74 | } 75 | if bizErr := resp.TryIntoErr(); bizErr != nil { 76 | return GetGroupChatResp{}, bizErr 77 | } 78 | 79 | return resp, nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/group_chat_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type GetGroupChatReq struct { 8 | ChatId string `json:"chat_id"` 9 | } 10 | 11 | // GetGroupChatReq 获取客户群详情请求 12 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92122#获取客户群详情 13 | var _ bodyer = GetGroupChatReq{} 14 | 15 | func (x GetGroupChatReq) intoBody() ([]byte, error) { 16 | result, err := json.Marshal(x) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return result, nil 22 | } 23 | 24 | // GetGroupChatResp 获取客户群详情响应 25 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/92122#获取客户群详情 26 | type GetGroupChatResp struct { 27 | CommonResp 28 | GroupChat `json:"group_chat"` 29 | } 30 | 31 | type GroupChat struct { 32 | AdminList []struct { 33 | Userid string `json:"userid"` 34 | } `json:"admin_list"` 35 | ChatID string `json:"chat_id"` 36 | CreateTime int `json:"create_time"` 37 | MemberList []struct { 38 | Invitor struct { 39 | Userid string `json:"userid"` 40 | } `json:"invitor"` 41 | JoinScene int `json:"join_scene"` 42 | JoinTime int `json:"join_time"` 43 | Type int `json:"type"` 44 | Unionid string `json:"unionid"` 45 | Userid string `json:"userid"` 46 | } `json:"member_list"` 47 | Name string `json:"name"` 48 | Notice string `json:"notice"` 49 | Owner string `json:"owner"` 50 | } 51 | 52 | var _ bodyer = GetGroupChatResp{} 53 | 54 | func (x GetGroupChatResp) intoBody() ([]byte, error) { 55 | result, err := json.Marshal(x) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return result, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/apicodegen/api_code.tmpl: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import "encoding/json" 4 | 5 | {{ range .}} 6 | // req{{ .StructName }} {{ .Name }}请求 7 | // 文档:{{ .DocURL }}#{{ .Name }}{{ .ReqCode }} 8 | var _ bodyer = req{{ .StructName }}{} 9 | 10 | func (x req{{ .StructName }}) intoBody() ([]byte, error) { 11 | result, err := json.Marshal(x) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | return result, nil 17 | } 18 | 19 | // resp{{ .StructName }} {{ .Name }}响应 20 | // 文档:{{ .DocURL }}#{{ .Name }}{{ .RespCode }} 21 | var _ bodyer = resp{{ .StructName }}{} 22 | 23 | func (x resp{{ .StructName }}) intoBody() ([]byte, error) { 24 | result, err := json.Marshal(x) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return result, nil 30 | } 31 | 32 | // exec{{ .StructName }} {{ .Name }} 33 | // 文档:{{ .DocURL }}#{{ .Name }} 34 | func (c *WorkwxApp) exec{{ .StructName }}(req req{{ .StructName }}) (resp{{ .StructName }}, error) { 35 | var resp resp{{ .StructName }} 36 | err := c.executeQyapiJSON{{ .MethodCaml }}("{{ .URL }}", req, &resp, true) 37 | if err != nil { 38 | return resp{{ .StructName }}{}, err 39 | } 40 | if bizErr := resp.TryIntoErr(); bizErr != nil { 41 | return resp{{ .StructName }}{}, bizErr 42 | } 43 | 44 | return resp, nil 45 | } 46 | 47 | // {{ .StructName }} {{ .Name }} 48 | // 文档:{{ .DocURL }}#{{ .Name }} 49 | func (c *WorkwxApp) {{ .StructName }}(req req{{ .StructName }}) (ok bool, err error) { 50 | var resp resp{{ .StructName }} 51 | resp, err = c.exec{{ .StructName }}(req) 52 | if err != nil { 53 | return false, err 54 | } 55 | ok = resp.IsOK() 56 | return 57 | } 58 | 59 | {{end}} 60 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package lowlevel 包含与企业微信服务端沟通相关的原语。 3 | */ 4 | 5 | package lowlevel 6 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/encryptor/mod.go: -------------------------------------------------------------------------------- 1 | package encryptor 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "encoding/binary" 9 | "errors" 10 | "io" 11 | "msg/pkg/go-workwx-develop/internal/lowlevel/pkcs7" 12 | ) 13 | 14 | type WorkWXPayload struct { 15 | Msg []byte 16 | ReceiveID []byte 17 | } 18 | 19 | type WorkWXEncryptor struct { 20 | aesKey []byte 21 | entropySource io.Reader 22 | } 23 | 24 | type WorkWXEncryptorOption interface { 25 | applyTo(x *WorkWXEncryptor) 26 | } 27 | 28 | type customEntropySource struct { 29 | inner io.Reader 30 | } 31 | 32 | func WithEntropySource(e io.Reader) WorkWXEncryptorOption { 33 | return &customEntropySource{inner: e} 34 | } 35 | 36 | func (o *customEntropySource) applyTo(x *WorkWXEncryptor) { 37 | x.entropySource = o.inner 38 | } 39 | 40 | var errMalformedEncodingAESKey = errors.New("malformed EncodingAESKey") 41 | 42 | func NewWorkWXEncryptor( 43 | encodingAESKey string, 44 | opts ...WorkWXEncryptorOption, 45 | ) (*WorkWXEncryptor, error) { 46 | aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | if len(aesKey) != 32 { 52 | return nil, errMalformedEncodingAESKey 53 | } 54 | 55 | obj := WorkWXEncryptor{ 56 | aesKey: aesKey, 57 | entropySource: rand.Reader, 58 | } 59 | for _, o := range opts { 60 | o.applyTo(&obj) 61 | } 62 | 63 | return &obj, nil 64 | } 65 | 66 | func (e *WorkWXEncryptor) Decrypt(base64Msg []byte) (WorkWXPayload, error) { 67 | // base64 decode 68 | bufLen := base64.StdEncoding.DecodedLen(len(base64Msg)) 69 | buf := make([]byte, bufLen) 70 | n, err := base64.StdEncoding.Decode(buf, base64Msg) 71 | if err != nil { 72 | return WorkWXPayload{}, err 73 | } 74 | buf = buf[:n] 75 | 76 | // init cipher 77 | block, err := aes.NewCipher(e.aesKey) 78 | if err != nil { 79 | return WorkWXPayload{}, err 80 | } 81 | 82 | iv := e.aesKey[:16] 83 | state := cipher.NewCBCDecrypter(block, iv) 84 | 85 | // decrypt in-place in the allocated temp buffer 86 | state.CryptBlocks(buf, buf) 87 | buf = pkcs7.Unpad(buf) 88 | 89 | // assemble decrypted payload 90 | // drop the 16-byte random prefix 91 | msgLen := binary.BigEndian.Uint32(buf[16:20]) 92 | msg := buf[20 : 20+msgLen] 93 | receiveID := buf[20+msgLen:] 94 | 95 | return WorkWXPayload{ 96 | Msg: msg, 97 | ReceiveID: receiveID, 98 | }, nil 99 | } 100 | 101 | func (e *WorkWXEncryptor) prepareBufForEncryption(payload *WorkWXPayload) ([]byte, error) { 102 | resultMsgLen := 16 + 4 + len(payload.Msg) + len(payload.ReceiveID) 103 | 104 | // allocate buffer 105 | buf := make([]byte, 16, resultMsgLen) 106 | 107 | // add random prefix 108 | _, err := io.ReadFull(e.entropySource, buf) // len(buf) == 16 at this moment 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | buf = buf[:cap(buf)] // grow to full capacity 114 | binary.BigEndian.PutUint32(buf[16:], uint32(len(payload.Msg))) 115 | copy(buf[20:], payload.Msg) 116 | copy(buf[20+len(payload.Msg):], payload.ReceiveID) 117 | 118 | return pkcs7.Pad(buf), nil 119 | } 120 | 121 | func (e *WorkWXEncryptor) Encrypt(payload *WorkWXPayload) (string, error) { 122 | buf, err := e.prepareBufForEncryption(payload) 123 | if err != nil { 124 | return "", err 125 | } 126 | 127 | // init cipher 128 | block, err := aes.NewCipher(e.aesKey) 129 | if err != nil { 130 | return "", err 131 | } 132 | 133 | iv := e.aesKey[:16] 134 | state := cipher.NewCBCEncrypter(block, iv) 135 | 136 | // encrypt in-place as we own the buffer 137 | state.CryptBlocks(buf, buf) 138 | 139 | return base64.StdEncoding.EncodeToString(buf), nil 140 | } 141 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/envelope/ctor_options.go: -------------------------------------------------------------------------------- 1 | package envelope 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type ProcessorOption interface { 8 | applyTo(x *Processor) 9 | } 10 | 11 | type customEntropySource struct { 12 | inner io.Reader 13 | } 14 | 15 | func WithEntropySource(e io.Reader) ProcessorOption { 16 | return &customEntropySource{inner: e} 17 | } 18 | 19 | func (o *customEntropySource) applyTo(x *Processor) { 20 | x.entropySource = o.inner 21 | } 22 | 23 | type customTimeSource struct { 24 | inner TimeSource 25 | } 26 | 27 | func WithTimeSource(t TimeSource) ProcessorOption { 28 | return &customTimeSource{inner: t} 29 | } 30 | 31 | func (o *customTimeSource) applyTo(x *Processor) { 32 | x.timeSource = o.inner 33 | } 34 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/envelope/mod.go: -------------------------------------------------------------------------------- 1 | package envelope 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/xml" 6 | "errors" 7 | "io" 8 | "math/big" 9 | "msg/pkg/go-workwx-develop/internal/lowlevel/encryptor" 10 | "msg/pkg/go-workwx-develop/internal/lowlevel/signature" 11 | "net/url" 12 | "strconv" 13 | ) 14 | 15 | type Processor struct { 16 | token string 17 | encryptor *encryptor.WorkWXEncryptor 18 | entropySource io.Reader 19 | timeSource TimeSource 20 | } 21 | 22 | func NewProcessor(token string, encodingAESKey string, opts ...ProcessorOption) (*Processor, error) { 23 | obj := Processor{ 24 | token: token, 25 | encryptor: nil, // XXX init later 26 | entropySource: rand.Reader, 27 | timeSource: DefaultTimeSource{}, 28 | } 29 | for _, o := range opts { 30 | o.applyTo(&obj) 31 | } 32 | 33 | enc, err := encryptor.NewWorkWXEncryptor( 34 | encodingAESKey, 35 | encryptor.WithEntropySource(obj.entropySource), 36 | ) 37 | if err != nil { 38 | return nil, err 39 | } 40 | obj.encryptor = enc 41 | 42 | return &obj, nil 43 | } 44 | 45 | var errInvalidSignature = errors.New("invalid signature") 46 | 47 | func (p *Processor) HandleIncomingMsg(url *url.URL, body []byte) (Envelope, error) { 48 | // xml unmarshal 49 | var x xmlRxEnvelope 50 | err := xml.Unmarshal(body, &x) 51 | if err != nil { 52 | return Envelope{}, err 53 | } 54 | 55 | // check signature 56 | if !signature.VerifyHTTPRequestSignature(p.token, url, x.Encrypt) { 57 | return Envelope{}, errInvalidSignature 58 | } 59 | 60 | // decrypt message 61 | msg, err := p.encryptor.Decrypt([]byte(x.Encrypt)) 62 | if err != nil { 63 | return Envelope{}, err 64 | } 65 | 66 | // assemble envelope to return 67 | return Envelope{ 68 | ToUserName: x.ToUserName, 69 | AgentID: x.AgentID, 70 | Msg: msg.Msg, 71 | ReceiveID: msg.ReceiveID, 72 | }, nil 73 | } 74 | 75 | func (p *Processor) MakeOutgoingEnvelope(msg []byte) ([]byte, error) { 76 | workwxPayload := encryptor.WorkWXPayload{ 77 | Msg: msg, 78 | ReceiveID: nil, 79 | } 80 | encryptedMsg, err := p.encryptor.Encrypt(&workwxPayload) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | ts := p.timeSource.GetCurrentTimestamp().Unix() 86 | nonce, err := makeNonce(p.entropySource) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | msgSignature := signature.MakeDevMsgSignature( 92 | p.token, 93 | strconv.FormatInt(ts, 10), 94 | nonce, 95 | encryptedMsg, 96 | ) 97 | 98 | envelope := xmlTxEnvelope{ 99 | XMLName: xml.Name{}, 100 | Encrypt: cdataNode{ 101 | CData: encryptedMsg, 102 | }, 103 | MsgSignature: cdataNode{ 104 | CData: msgSignature, 105 | }, 106 | Timestamp: ts, 107 | Nonce: cdataNode{ 108 | CData: nonce, 109 | }, 110 | } 111 | 112 | result, err := xml.Marshal(envelope) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | return result, nil 118 | } 119 | 120 | func makeNonce(entropySource io.Reader) (string, error) { 121 | limit := big.NewInt(1) 122 | limit = limit.Lsh(limit, 64) 123 | n, err := rand.Int(entropySource, limit) 124 | if err != nil { 125 | return "", err 126 | } 127 | return n.String(), nil 128 | } 129 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/envelope/models.go: -------------------------------------------------------------------------------- 1 | package envelope 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | type xmlRxEnvelope struct { 8 | ToUserName string `xml:"ToUserName"` 9 | AgentID string `xml:"AgentID"` 10 | Encrypt string `xml:"Encrypt"` 11 | } 12 | 13 | type cdataNode struct { 14 | CData string `xml:",cdata"` 15 | } 16 | 17 | type xmlTxEnvelope struct { 18 | XMLName xml.Name `xml:"xml"` 19 | Encrypt cdataNode `xml:"Encrypt"` 20 | MsgSignature cdataNode `xml:"MsgSignature"` 21 | Timestamp int64 `xml:"Timestamp"` 22 | Nonce cdataNode `xml:"Nonce"` 23 | } 24 | 25 | type Envelope struct { 26 | ToUserName string 27 | AgentID string 28 | Msg []byte 29 | ReceiveID []byte 30 | } 31 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/envelope/time_source.go: -------------------------------------------------------------------------------- 1 | package envelope 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type TimeSource interface { 8 | GetCurrentTimestamp() time.Time 9 | } 10 | 11 | type DefaultTimeSource struct{} 12 | 13 | var _ TimeSource = DefaultTimeSource{} 14 | 15 | func (DefaultTimeSource) GetCurrentTimestamp() time.Time { 16 | return time.Now() 17 | } 18 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/httpapi/echo_test_api.go: -------------------------------------------------------------------------------- 1 | package httpapi 2 | 3 | import ( 4 | "errors" 5 | "msg/pkg/go-workwx-develop/internal/lowlevel/signature" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | ) 10 | 11 | type ToEchoTestAPIArgs interface { 12 | ToEchoTestAPIArgs() (EchoTestAPIArgs, error) 13 | } 14 | 15 | type EchoTestAPIArgs struct { 16 | MsgSignature string 17 | Timestamp int64 18 | Nonce string 19 | EchoStr string 20 | } 21 | 22 | type URLValuesForEchoTestAPI url.Values 23 | 24 | var _ ToEchoTestAPIArgs = URLValuesForEchoTestAPI{} 25 | 26 | var errMalformedArgs = errors.New("malformed arguments for echo test API") 27 | 28 | func (x URLValuesForEchoTestAPI) ToEchoTestAPIArgs() (EchoTestAPIArgs, error) { 29 | var msgSignature string 30 | { 31 | l := x["msg_signature"] 32 | if len(l) != 1 { 33 | return EchoTestAPIArgs{}, errMalformedArgs 34 | } 35 | msgSignature = l[0] 36 | } 37 | 38 | var timestamp int64 39 | { 40 | l := x["timestamp"] 41 | if len(l) != 1 { 42 | return EchoTestAPIArgs{}, errMalformedArgs 43 | } 44 | timestampStr := l[0] 45 | 46 | timestampInt, err := strconv.ParseInt(timestampStr, 10, 64) 47 | if err != nil { 48 | return EchoTestAPIArgs{}, errMalformedArgs 49 | } 50 | 51 | timestamp = timestampInt 52 | } 53 | 54 | var nonce string 55 | { 56 | l := x["nonce"] 57 | if len(l) != 1 { 58 | return EchoTestAPIArgs{}, errMalformedArgs 59 | } 60 | nonce = l[0] 61 | } 62 | 63 | var echoStr string 64 | { 65 | l := x["echostr"] 66 | if len(l) != 1 { 67 | return EchoTestAPIArgs{}, errMalformedArgs 68 | } 69 | echoStr = l[0] 70 | } 71 | 72 | return EchoTestAPIArgs{ 73 | MsgSignature: msgSignature, 74 | Timestamp: timestamp, 75 | Nonce: nonce, 76 | EchoStr: echoStr, 77 | }, nil 78 | } 79 | 80 | func (h *LowLevelHandler) echoTestHandler(rw http.ResponseWriter, r *http.Request) { 81 | url := r.URL 82 | 83 | if !signature.VerifyHTTPRequestSignature(h.token, url, "") { 84 | rw.WriteHeader(http.StatusBadRequest) 85 | return 86 | } 87 | 88 | adapter := URLValuesForEchoTestAPI(url.Query()) 89 | args, err := adapter.ToEchoTestAPIArgs() 90 | if err != nil { 91 | rw.WriteHeader(http.StatusBadRequest) 92 | return 93 | } 94 | 95 | payload, err := h.encryptor.Decrypt([]byte(args.EchoStr)) 96 | if err != nil { 97 | rw.WriteHeader(http.StatusBadRequest) 98 | return 99 | } 100 | 101 | rw.WriteHeader(http.StatusOK) 102 | // No way to signal failure with the typical HTTP handler method signature 103 | _, _ = rw.Write(payload.Msg) 104 | } 105 | 106 | func (h *LowLevelHandler) Decrypt(echoStr string) ([]byte, error) { 107 | payload, err := h.encryptor.Decrypt([]byte(echoStr)) 108 | if err != nil { 109 | return nil, err 110 | } 111 | return payload.Msg, err 112 | } 113 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/httpapi/event_api.go: -------------------------------------------------------------------------------- 1 | package httpapi 2 | 3 | import ( 4 | "io/ioutil" 5 | "msg/pkg/go-workwx-develop/internal/lowlevel/envelope" 6 | "net/http" 7 | ) 8 | 9 | type EnvelopeHandler interface { 10 | OnIncomingEnvelope(rx envelope.Envelope) error 11 | } 12 | 13 | func (h *LowLevelHandler) eventHandler(rw http.ResponseWriter, r *http.Request) { 14 | // app bodies are assumed small 15 | // we can't do streaming parse/decrypt/verification anyway 16 | defer r.Body.Close() 17 | body, err := ioutil.ReadAll(r.Body) 18 | if err != nil { 19 | rw.WriteHeader(http.StatusInternalServerError) 20 | return 21 | } 22 | 23 | // signature verification is inside EnvelopeProcessor 24 | ev, err := h.ep.HandleIncomingMsg(r.URL, body) 25 | if err != nil { 26 | rw.WriteHeader(http.StatusBadRequest) 27 | return 28 | } 29 | 30 | err = h.eh.OnIncomingEnvelope(ev) 31 | if err != nil { 32 | rw.WriteHeader(http.StatusInternalServerError) 33 | return 34 | } 35 | 36 | // currently we always return empty 200 responses 37 | // any reply is to be sent asynchronously 38 | // this might change in the future (maybe save a couple of RTT or so) 39 | rw.WriteHeader(http.StatusOK) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/httpapi/mod.go: -------------------------------------------------------------------------------- 1 | package httpapi 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "msg/pkg/go-workwx-develop/internal/lowlevel/encryptor" 6 | "msg/pkg/go-workwx-develop/internal/lowlevel/envelope" 7 | "msg/pkg/go-workwx-develop/internal/lowlevel/signature" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | type LowLevelHandler struct { 13 | token string 14 | encryptor *encryptor.WorkWXEncryptor 15 | ep *envelope.Processor 16 | eh EnvelopeHandler 17 | } 18 | 19 | var _ http.Handler = (*LowLevelHandler)(nil) 20 | 21 | func (h *LowLevelHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 22 | switch r.Method { 23 | case http.MethodGet: 24 | // 测试回调模式请求 25 | h.echoTestHandler(rw, r) 26 | 27 | case http.MethodPost: 28 | // 回调事件 29 | h.eventHandler(rw, r) 30 | 31 | default: 32 | // unhandled app method 33 | rw.WriteHeader(http.StatusNotImplemented) 34 | } 35 | } 36 | 37 | func (h *LowLevelHandler) EchoTest(url *url.URL) (echoMsg []byte, err error) { 38 | if !signature.VerifyHTTPRequestSignature(h.token, url, "") { 39 | return nil, errors.New("verify signature failed") 40 | } 41 | 42 | adapter := URLValuesForEchoTestAPI(url.Query()) 43 | args, err := adapter.ToEchoTestAPIArgs() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | payload, err := h.encryptor.Decrypt([]byte(args.EchoStr)) 49 | if err != nil { 50 | return nil, err 51 | } 52 | return payload.Msg, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/pkcs7/mod.go: -------------------------------------------------------------------------------- 1 | package pkcs7 2 | 3 | func Pad(x []byte) []byte { 4 | numPadBytes := 32 - len(x)%32 5 | padByte := byte(numPadBytes) 6 | tmp := make([]byte, len(x)+numPadBytes) 7 | copy(tmp, x) 8 | for i := 0; i < numPadBytes; i++ { 9 | tmp[len(x)+i] = padByte 10 | } 11 | return tmp 12 | } 13 | 14 | func Unpad(x []byte) []byte { 15 | // last byte is number of suffix bytes to remove 16 | n := int(x[len(x)-1]) 17 | return x[:len(x)-n] 18 | } 19 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/internal/lowlevel/signature/mod.go: -------------------------------------------------------------------------------- 1 | package signature 2 | 3 | import ( 4 | "crypto/sha1" //nolint: gosec // this is part of vendor API spec 5 | "crypto/subtle" 6 | "fmt" 7 | "net/url" 8 | "sort" 9 | ) 10 | 11 | func MakeDevMsgSignature(paramValues ...string) string { 12 | tmp := make([]string, len(paramValues)) 13 | copy(tmp, paramValues) 14 | 15 | sort.Strings(tmp) 16 | 17 | //nolint: gosec 18 | //this is part of vendor API spec 19 | state := sha1.New() 20 | for _, x := range tmp { 21 | _, _ = state.Write([]byte(x)) 22 | } 23 | 24 | result := state.Sum(nil) 25 | return fmt.Sprintf("%x", result) 26 | } 27 | 28 | // ToMsgSignature 适配企业微信请求参数签名的 interface 29 | type ToMsgSignature interface { 30 | // GetMsgSignature 取请求上携带的签名串 31 | GetMsgSignature() (string, bool) 32 | // GetParamValues 取所有请求参数值(不必有序) 33 | GetParamValues() ([]string, bool) 34 | } 35 | 36 | // VerifySignature 校验一个 ToMsgSignature 的签名是否完好 37 | // 38 | // NOTE: Go 没有 default method for interface,因此无法以 `foo.VerifySignature()` 39 | // 的形式实现。 40 | func VerifySignature(token string, x ToMsgSignature) bool { 41 | msgSignature, ok := x.GetMsgSignature() 42 | if !ok { 43 | return false 44 | } 45 | 46 | paramValues, ok := x.GetParamValues() 47 | if !ok { 48 | return false 49 | } 50 | 51 | devMsgSignature := MakeDevMsgSignature(append(paramValues, token)...) 52 | eq := subtle.ConstantTimeCompare([]byte(msgSignature), []byte(devMsgSignature)) 53 | return eq != 0 54 | } 55 | 56 | // VerifyHTTPRequestSignature 校验一个 HTTP 请求的签名是否完好 57 | // 58 | // 这是 VerifySignature 的简单包装。 59 | func VerifyHTTPRequestSignature(token string, url *url.URL, body string) bool { 60 | // XXX seems this is a memcpy... 61 | wrapped := httpRequestWithSignature{ 62 | url: url, 63 | body: body, 64 | } 65 | return VerifySignature(token, &wrapped) 66 | } 67 | 68 | // httpRequestWithSignature 为 HTTP 请求适配签名校验逻辑 69 | type httpRequestWithSignature struct { 70 | url *url.URL 71 | body string 72 | } 73 | 74 | var _ ToMsgSignature = (*httpRequestWithSignature)(nil) 75 | 76 | // GetMsgSignature 取请求上携带的签名串 77 | func (u *httpRequestWithSignature) GetMsgSignature() (string, bool) { 78 | l := u.url.Query()["msg_signature"] 79 | if len(l) != 1 { 80 | return "", false 81 | } 82 | 83 | return l[0], true 84 | } 85 | 86 | // GetParamValues 取所有请求参数值(不必有序) 87 | func (u *httpRequestWithSignature) GetParamValues() ([]string, bool) { 88 | result := make([]string, 0) 89 | for k, l := range u.url.Query() { 90 | if k == "msg_signature" { 91 | continue 92 | } 93 | result = append(result, l...) 94 | } 95 | if len(u.body) > 0 { 96 | result = append(result, u.body) 97 | } 98 | return result, true 99 | } 100 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/mass_msg.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // AddMsgTemplate 创建企业群发 4 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92135#创建企业群发 5 | func (c *App) AddMsgTemplate(req AddMsgTemplateReq) (msgID string, failedList []string, err error) { 6 | var resp addMsgTemplateResp 7 | resp, err = c.execAddMsgTemplate(req) 8 | if err != nil { 9 | return "", nil, err 10 | } 11 | return resp.MsgID, resp.FailList, nil 12 | } 13 | 14 | // GetGroupMsgSendResultExternalContact 获取企业群发成员执行结果 15 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/93338#获取企业群发成员执行结果 16 | func (c *App) GetGroupMsgSendResultExternalContact(req GetGroupMsgSendResultExternalContactReq) (res GetGroupMsgSendResultExternalContactResp, err error) { 17 | var resp GetGroupMsgSendResultExternalContactResp 18 | resp, err = c.execGetGroupMsgSendResultExternalContact(req) 19 | if err != nil { 20 | return 21 | } 22 | //ok = resp.IsOK() 23 | return resp, nil 24 | } 25 | 26 | // GetGroupMsgTaskExternalContact 获取群发成员发送任务列表 27 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/93338#获取群发成员发送任务列表 28 | func (c *App) GetGroupMsgTaskExternalContact(req reqGetGroupmsgTaskExternalcontact) (ok bool, err error) { 29 | var resp getGroupMsgTaskExternalContactResp 30 | resp, err = c.execGetGroupmsgTaskExternalcontact(req) 31 | if err != nil { 32 | return false, err 33 | } 34 | ok = resp.IsOK() 35 | return 36 | } 37 | 38 | // GetGroupmsgListV2Externalcontact 获取群发记录列表 39 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/93338#获取群发记录列表 40 | func (c *App) GetGroupmsgListV2Externalcontact(req getGroupMsgListV2ExternalContactReq) (ok bool, err error) { 41 | var resp getGroupMsgListV2ExternalContactResp 42 | resp, err = c.execGetGroupmsgListV2Externalcontact(req) 43 | if err != nil { 44 | return false, err 45 | } 46 | ok = resp.IsOK() 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/media.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "mime/multipart" 7 | "os" 8 | ) 9 | 10 | const mediaFieldName = "media" 11 | 12 | // Media 欲上传的素材 13 | 14 | type Media struct { 15 | filename string 16 | filesize int64 17 | stream io.Reader 18 | } 19 | 20 | // NewMediaFromFile 从操作系统级文件创建一个欲上传的素材对象 21 | func NewMediaFromFile(f *os.File) (*Media, error) { 22 | stat, err := f.Stat() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &Media{ 28 | filename: stat.Name(), 29 | filesize: stat.Size(), 30 | stream: f, 31 | }, nil 32 | } 33 | 34 | // NewMediaFromBuffer 从内存创建一个欲上传的素材对象 35 | func NewMediaFromBuffer(filename string, buf []byte) (*Media, error) { 36 | stream := bytes.NewReader(buf) 37 | return &Media{ 38 | filename: filename, 39 | filesize: int64(len(buf)), 40 | stream: stream, 41 | }, nil 42 | } 43 | 44 | func (m *Media) writeTo(w *multipart.Writer) error { 45 | wr, err := w.CreateFormFile(mediaFieldName, m.filename) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | _, err = io.Copy(wr, m.stream) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | // UploadPermanentImageMedia 上传永久图片素材 59 | func (c *App) UploadPermanentImageMedia(media *Media) (url string, err error) { 60 | url, err = c.mediaUploadImg(media) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | return url, nil 66 | } 67 | 68 | const ( 69 | tempMediaTypeImage = "image" 70 | tempMediaTypeVoice = "voice" 71 | tempMediaTypeVideo = "video" 72 | tempMediaTypeFile = "file" 73 | ) 74 | 75 | // UploadTempImageMedia 上传临时图片素材 76 | func (c *App) UploadTempImageMedia(media *Media) (*MediaUploadResult, error) { 77 | result, err := c.mediaUpload(tempMediaTypeImage, media) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return result, nil 83 | } 84 | 85 | // UploadTempVoiceMedia 上传临时语音素材 86 | func (c *App) UploadTempVoiceMedia(media *Media) (*MediaUploadResult, error) { 87 | result, err := c.mediaUpload(tempMediaTypeVoice, media) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | return result, nil 93 | } 94 | 95 | // UploadTempVideoMedia 上传临时视频素材 96 | func (c *App) UploadTempVideoMedia(media *Media) (*MediaUploadResult, error) { 97 | result, err := c.mediaUpload(tempMediaTypeVideo, media) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return result, nil 103 | } 104 | 105 | // UploadTempFileMedia 上传临时文件素材 106 | func (c *App) UploadTempFileMedia(media *Media) (*MediaUploadResult, error) { 107 | result, err := c.mediaUpload(tempMediaTypeFile, media) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | return result, nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/media_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import "net/url" 4 | 5 | // mediaUploadReq 临时素材上传请求 6 | type mediaUploadReq struct { 7 | Type string 8 | Media *Media 9 | } 10 | 11 | var _ urlValuer = mediaUploadReq{} 12 | var _ mediaUploader = mediaUploadReq{} 13 | 14 | func (x mediaUploadReq) intoURLValues() url.Values { 15 | return url.Values{ 16 | "type": {x.Type}, 17 | } 18 | } 19 | 20 | func (x mediaUploadReq) getMedia() *Media { 21 | return x.Media 22 | } 23 | 24 | // mediaUploadImgReq 永久图片素材上传请求 25 | type mediaUploadImgReq struct { 26 | Media *Media 27 | } 28 | 29 | var _ urlValuer = mediaUploadImgReq{} 30 | var _ mediaUploader = mediaUploadImgReq{} 31 | 32 | func (x mediaUploadImgReq) intoURLValues() url.Values { 33 | return url.Values{} 34 | } 35 | 36 | func (x mediaUploadImgReq) getMedia() *Media { 37 | return x.Media 38 | } 39 | 40 | // mediaUploadImgResp 永久图片素材上传响应 41 | type mediaUploadImgResp struct { 42 | CommonResp 43 | URL string `json:"url"` 44 | } 45 | 46 | // execMediaUploadImg 上传永久图片 47 | func (c *App) execMediaUploadImg(req mediaUploadImgReq) (mediaUploadImgResp, error) { 48 | var resp mediaUploadImgResp 49 | err := c.executeWXApiMediaUpload("/cgi-bin/media/uploadimg", req, &resp, true) 50 | if err != nil { 51 | return mediaUploadImgResp{}, err 52 | } 53 | if bizErr := resp.TryIntoErr(); bizErr != nil { 54 | return mediaUploadImgResp{}, bizErr 55 | } 56 | 57 | return resp, nil 58 | } 59 | 60 | // mediaUploadImg 上传永久图片 61 | func (c *App) mediaUploadImg(media *Media) (url string, err error) { 62 | resp, err := c.execMediaUploadImg(mediaUploadImgReq{ 63 | Media: media, 64 | }) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | return resp.URL, nil 70 | } 71 | 72 | // mediaUpload 上传临时素材 73 | func (c *App) mediaUpload(typ string, media *Media) (*MediaUploadResult, error) { 74 | resp, err := c.execMediaUpload(mediaUploadReq{ 75 | Type: typ, 76 | Media: media, 77 | }) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | obj, err := resp.intoMediaUploadResult() 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return &obj, nil 88 | } 89 | 90 | // execMediaUpload 上传临时素材 91 | func (c *App) execMediaUpload(req mediaUploadReq) (mediaUploadResp, error) { 92 | var resp mediaUploadResp 93 | err := c.executeWXApiMediaUpload("/cgi-bin/media/upload", req, &resp, true) 94 | if err != nil { 95 | return mediaUploadResp{}, err 96 | } 97 | if bizErr := resp.TryIntoErr(); bizErr != nil { 98 | return mediaUploadResp{}, bizErr 99 | } 100 | 101 | return resp, nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/media_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // mediaUploadResp 临时素材上传响应 9 | type mediaUploadResp struct { 10 | CommonResp 11 | 12 | Type string `json:"type"` 13 | MediaID string `json:"media_id"` 14 | CreatedAt string `json:"created_at"` 15 | } 16 | 17 | func (x mediaUploadResp) intoMediaUploadResult() (MediaUploadResult, error) { 18 | createdAtInt, err := strconv.ParseInt(x.CreatedAt, 10, 64) 19 | if err != nil { 20 | return MediaUploadResult{}, err 21 | } 22 | createdAt := time.Unix(createdAtInt, 0) 23 | 24 | return MediaUploadResult{ 25 | Type: x.Type, 26 | MediaID: x.MediaID, 27 | CreatedAt: createdAt, 28 | }, nil 29 | } 30 | 31 | // MediaUploadResult 临时素材上传结果 32 | type MediaUploadResult struct { 33 | // Type 媒体文件类型,分别有图片(image)、语音(voice)、视频(video),普通文件(file) 34 | Type string 35 | // MediaID 媒体文件上传后获取的唯一标识,3天内有效 36 | MediaID string 37 | // CreatedAt 媒体文件上传时间戳 38 | CreatedAt time.Time 39 | } 40 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/message_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // sendMessage 发送消息底层接口 10 | // 11 | // 收件人参数如果仅设置了 `ChatID` 字段,则为【发送消息到群聊会话】接口调用; 12 | // 否则为单纯的【发送应用消息】接口调用。 13 | func (c *App) sendMessage( 14 | recipient *Recipient, 15 | msgtype string, 16 | content map[string]interface{}, 17 | isSafe bool, 18 | ) error { 19 | isApichatSendRequest := false 20 | if !recipient.isValidForMessageSend() { 21 | if !recipient.isValidForAppChatSend() { 22 | // TODO: better error 23 | return errors.New("recipient invalid for message sending") 24 | } 25 | 26 | // 发送给群聊 27 | isApichatSendRequest = true 28 | } 29 | 30 | req := MessageReq{ 31 | ToUser: recipient.UserIDs, 32 | ToParty: recipient.PartyIDs, 33 | ToTag: recipient.TagIDs, 34 | ChatID: recipient.ChatID, 35 | AgentID: c.AgentID, 36 | MsgType: msgtype, 37 | Content: content, 38 | IsSafe: isSafe, 39 | } 40 | 41 | var resp messageSendResp 42 | var err error 43 | if isApichatSendRequest { 44 | resp, err = c.execAppChatSend(req) 45 | } else { 46 | resp, err = c.execMessageSend(req) 47 | } 48 | 49 | if err != nil { 50 | return err 51 | } 52 | _ = resp 53 | return nil 54 | } 55 | 56 | // execMessageSend 发送应用消息 57 | func (c *App) execMessageSend(req MessageReq) (messageSendResp, error) { 58 | var resp messageSendResp 59 | err := c.executeWXApiJSONPost("/cgi-bin/message/send", req, &resp, true) 60 | if err != nil { 61 | return messageSendResp{}, err 62 | } 63 | if bizErr := resp.TryIntoErr(); bizErr != nil { 64 | return messageSendResp{}, bizErr 65 | } 66 | 67 | return resp, nil 68 | } 69 | 70 | // execAppChatSend 应用推送消息 71 | func (c *App) execAppChatSend(req MessageReq) (messageSendResp, error) { 72 | var resp messageSendResp 73 | err := c.executeWXApiJSONPost("/cgi-bin/appchat/send", req, &resp, true) 74 | if err != nil { 75 | return messageSendResp{}, err 76 | } 77 | if bizErr := resp.TryIntoErr(); bizErr != nil { 78 | return messageSendResp{}, bizErr 79 | } 80 | 81 | return resp, nil 82 | } 83 | 84 | // MessageReq 消息发送请求 85 | type MessageReq struct { 86 | ToUser []string 87 | ToParty []string 88 | ToTag []string 89 | ChatID string 90 | AgentID int64 91 | MsgType string 92 | Content map[string]interface{} 93 | IsSafe bool 94 | } 95 | 96 | var _ bodyer = MessageReq{} 97 | 98 | func (x MessageReq) intoBody() ([]byte, error) { 99 | // fuck 100 | safeInt := 0 101 | if x.IsSafe { 102 | safeInt = 1 103 | } 104 | 105 | obj := map[string]interface{}{ 106 | "msgtype": x.MsgType, 107 | "agentid": x.AgentID, 108 | "safe": safeInt, 109 | } 110 | 111 | // msgtype polymorphism 112 | obj[x.MsgType] = x.Content 113 | 114 | // 复用这个结构体,因为是 package-private 的所以这么做没风险 115 | if x.ChatID != "" { 116 | obj["chatid"] = x.ChatID 117 | } else { 118 | obj["touser"] = strings.Join(x.ToUser, "|") 119 | obj["toparty"] = strings.Join(x.ToParty, "|") 120 | obj["totag"] = strings.Join(x.ToTag, "|") 121 | } 122 | 123 | result, err := json.Marshal(obj) 124 | if err != nil { 125 | // should never happen unless OOM or similar bad things 126 | // TODO: error_chain 127 | return nil, err 128 | } 129 | 130 | return result, nil 131 | } 132 | 133 | // messageSendResp 消息发送响应 134 | type messageSendResp struct { 135 | CommonResp 136 | 137 | InvalidUsers string `json:"invaliduser"` 138 | InvalidParties string `json:"invalidparty"` 139 | InvalidTags string `json:"invalidtag"` 140 | } 141 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/message_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // TaskCardBtn 任务卡片消息按钮 4 | type TaskCardBtn struct { 5 | // Key 按钮key值,用户点击后,会产生任务卡片回调事件,回调事件会带上该key值,只能由数字、字母和“_-@”组成,最长支持128字节 6 | Key string `json:"key"` 7 | // Name 按钮名称 8 | Name string `json:"name"` 9 | // ReplaceName 点击按钮后显示的名称,默认为“已处理” 10 | ReplaceName string `json:"replace_name"` 11 | // Color 按钮字体颜色,可选“red”或者“blue”,默认为“blue” 12 | Color string `json:"color"` 13 | // IsBold 按钮字体是否加粗,默认false 14 | IsBold bool `json:"is_bold"` 15 | } 16 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/models.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | type accessTokenReq struct { 8 | CorpID string 9 | CorpSecret string 10 | } 11 | 12 | var _ urlValuer = accessTokenReq{} 13 | 14 | func (x accessTokenReq) intoURLValues() url.Values { 15 | return url.Values{ 16 | "corpid": {x.CorpID}, 17 | "corpsecret": {x.CorpSecret}, 18 | } 19 | } 20 | 21 | type CommonResp struct { 22 | ErrCode int64 `json:"errcode"` 23 | ErrMsg string `json:"errmsg"` 24 | } 25 | 26 | // IsOK 响应体是否为一次成功请求的响应 27 | // 28 | // 实现依据: https://work.weixin.qq.com/api/doc#10013 29 | // 30 | // > 企业微信所有接口,返回包里都有errcode、errmsg。 31 | // > 开发者需根据errcode是否为0判断是否调用成功(errcode意义请见全局错误码)。 32 | // > 而errmsg仅作参考,后续可能会有变动,因此不可作为是否调用成功的判据。 33 | func (x *CommonResp) IsOK() bool { 34 | return x.ErrCode == 0 35 | } 36 | 37 | func (x *CommonResp) TryIntoErr() error { 38 | if x.IsOK() { 39 | return nil 40 | } 41 | 42 | return &ClientError{ 43 | Code: x.ErrCode, 44 | Msg: x.ErrMsg, 45 | } 46 | } 47 | 48 | type accessTokenResp struct { 49 | CommonResp 50 | 51 | AccessToken string `json:"access_token"` 52 | ExpiresInSecs int64 `json:"expires_in"` 53 | } 54 | 55 | type jsAPITicketAgentConfigReq struct{} 56 | 57 | var _ urlValuer = jsAPITicketAgentConfigReq{} 58 | 59 | func (x jsAPITicketAgentConfigReq) intoURLValues() url.Values { 60 | return url.Values{ 61 | "type": {"agent_config"}, 62 | } 63 | } 64 | 65 | type jsAPITicketReq struct{} 66 | 67 | var _ urlValuer = jsAPITicketReq{} 68 | 69 | func (x jsAPITicketReq) intoURLValues() url.Values { 70 | return url.Values{} 71 | } 72 | 73 | type jsAPITicketResp struct { 74 | CommonResp 75 | 76 | Ticket string `json:"ticket"` 77 | ExpiresInSecs int64 `json:"expires_in"` 78 | } 79 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/msg_audit.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // MsgAuditAgreeStatus 会话中外部成员的同意状态 4 | type MsgAuditAgreeStatus string 5 | 6 | const ( 7 | // MsgAuditAgreeStatusAgree 同意 8 | MsgAuditAgreeStatusAgree = "Agree" 9 | // MsgAuditAgreeStatusDisagree 不同意 10 | MsgAuditAgreeStatusDisagree = "Disagree" 11 | // MsgAuditAgreeStatusDefaultAgree 默认同意 12 | MsgAuditAgreeStatusDefaultAgree = "Default_Agree" 13 | ) 14 | 15 | // CheckMsgAuditSingleAgree 获取会话同意情况(单聊) 16 | func (c *App) CheckMsgAuditSingleAgree(infos []CheckMsgAuditSingleAgreeUserInfo) ([]CheckMsgAuditSingleAgreeInfo, error) { 17 | resp, err := c.execMsgAuditCheckSingleAgree(msgAuditCheckSingleAgreeReq{ 18 | Infos: infos, 19 | }) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return resp.intoCheckSingleAgreeInfoList(), nil 24 | } 25 | 26 | // CheckMsgAuditRoomAgree 获取会话同意情况(群聊) 27 | func (c *App) CheckMsgAuditRoomAgree(roomId string) ([]CheckMsgAuditRoomAgreeInfo, error) { 28 | resp, err := c.execMsgAuditCheckRoomAgree(msgAuditCheckRoomAgreeReq{ 29 | RoomID: roomId, 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return resp.intoCheckRoomAgreeInfoList(), nil 35 | } 36 | 37 | // MsgAuditEdition 会话内容存档版本 38 | type MsgAuditEdition uint8 39 | 40 | const ( 41 | // MsgAuditEditionOffice 会话内容存档办公版 42 | MsgAuditEditionOffice MsgAuditEdition = 1 43 | // MsgAuditEditionService 会话内容存档服务版 44 | MsgAuditEditionService MsgAuditEdition = 2 45 | // MsgAuditEditionEnterprise 会话内容存档企业版 46 | MsgAuditEditionEnterprise MsgAuditEdition = 3 47 | ) 48 | 49 | // ListMsgAuditPermitUser 获取会话内容存档开启成员列表 50 | func (c *App) ListMsgAuditPermitUser(msgAuditEdition MsgAuditEdition) ([]string, error) { 51 | resp, err := c.execMsgAuditListPermitUser(msgAuditListPermitUserReq{ 52 | MsgAuditEdition: msgAuditEdition, 53 | }) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return resp.IDs, nil 58 | } 59 | 60 | // GetMsgAuditGroupChat 获取会话内容存档内部群信息 61 | func (c *App) GetMsgAuditGroupChat(roomID string) (*MsgAuditGroupChat, error) { 62 | resp, err := c.execMsgAuditGetGroupChat(msgAuditGetGroupChatReq{ 63 | RoomID: roomID, 64 | }) 65 | if err != nil { 66 | return nil, err 67 | } 68 | groupChat := resp.intoGroupChat() 69 | return &groupChat, nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/msg_audit_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // execMsgAuditListPermitUser 获取会话内容存档开启成员列表 4 | func (c *App) execMsgAuditListPermitUser(req msgAuditListPermitUserReq) (msgAuditListPermitUserResp, error) { 5 | var resp msgAuditListPermitUserResp 6 | err := c.executeWXApiJSONPost("/cgi-bin/msgaudit/get_permit_user_list", req, &resp, true) 7 | if err != nil { 8 | return msgAuditListPermitUserResp{}, err 9 | } 10 | if bizErr := resp.TryIntoErr(); bizErr != nil { 11 | return msgAuditListPermitUserResp{}, bizErr 12 | } 13 | 14 | return resp, nil 15 | } 16 | 17 | // execMsgAuditCheckSingleAgree 获取会话同意情况(单聊) 18 | func (c *App) execMsgAuditCheckSingleAgree(req msgAuditCheckSingleAgreeReq) (msgAuditCheckSingleAgreeResp, error) { 19 | var resp msgAuditCheckSingleAgreeResp 20 | err := c.executeWXApiJSONPost("/cgi-bin/msgaudit/check_single_agree", req, &resp, true) 21 | if err != nil { 22 | return msgAuditCheckSingleAgreeResp{}, err 23 | } 24 | if bizErr := resp.TryIntoErr(); bizErr != nil { 25 | return msgAuditCheckSingleAgreeResp{}, bizErr 26 | } 27 | 28 | return resp, nil 29 | } 30 | 31 | // execMsgAuditCheckRoomAgree 获取会话同意情况(群聊) 32 | func (c *App) execMsgAuditCheckRoomAgree(req msgAuditCheckRoomAgreeReq) (msgAuditCheckRoomAgreeResp, error) { 33 | var resp msgAuditCheckRoomAgreeResp 34 | err := c.executeWXApiJSONPost("/cgi-bin/msgaudit/check_room_agree", req, &resp, true) 35 | if err != nil { 36 | return msgAuditCheckRoomAgreeResp{}, err 37 | } 38 | if bizErr := resp.TryIntoErr(); bizErr != nil { 39 | return msgAuditCheckRoomAgreeResp{}, bizErr 40 | } 41 | 42 | return resp, nil 43 | } 44 | 45 | // execMsgAuditGetGroupChat 获取会话内容存档内部群信息 46 | func (c *App) execMsgAuditGetGroupChat(req msgAuditGetGroupChatReq) (msgAuditGetGroupChatResp, error) { 47 | var resp msgAuditGetGroupChatResp 48 | err := c.executeWXApiJSONPost("/cgi-bin/msgaudit/groupchat/get", req, &resp, true) 49 | if err != nil { 50 | return msgAuditGetGroupChatResp{}, err 51 | } 52 | if bizErr := resp.TryIntoErr(); bizErr != nil { 53 | return msgAuditGetGroupChatResp{}, bizErr 54 | } 55 | 56 | return resp, nil 57 | } 58 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/msg_audit_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // CheckMsgAuditSingleAgreeUserInfo 获取会话同意情况(单聊)内外成员 8 | type CheckMsgAuditSingleAgreeUserInfo struct { 9 | // UserID 内部成员的userid 10 | UserID string `json:"userid"` 11 | // ExternalOpenID 外部成员的externalopenid 12 | ExternalOpenID string `json:"exteranalopenid"` 13 | } 14 | 15 | // CheckMsgAuditSingleAgreeInfo 获取会话同意情况(单聊)同意信息 16 | type CheckMsgAuditSingleAgreeInfo struct { 17 | CheckMsgAuditSingleAgreeUserInfo 18 | // AgreeStatus 同意:”Agree”,不同意:”Disagree”,默认同意:”Default_Agree” 19 | AgreeStatus MsgAuditAgreeStatus 20 | // StatusChangeTime 同意状态改变的具体时间 21 | StatusChangeTime time.Time 22 | } 23 | 24 | // CheckMsgAuditRoomAgreeInfo 获取会话同意情况(群聊)同意信息 25 | type CheckMsgAuditRoomAgreeInfo struct { 26 | // StatusChangeTime 同意状态改变的具体时间 27 | StatusChangeTime time.Time 28 | // AgreeStatus 同意:”Agree”,不同意:”Disagree”,默认同意:”Default_Agree” 29 | AgreeStatus MsgAuditAgreeStatus 30 | // ExternalOpenID 群内外部联系人的externalopenid 31 | ExternalOpenID string 32 | } 33 | 34 | // MsgAuditGroupChatMember 获取会话内容存档内部群成员 35 | type MsgAuditGroupChatMember struct { 36 | // MemberID roomid群成员的id,userid 37 | MemberID int 38 | // JoinTime roomid群成员的入群时间 39 | JoinTime time.Time 40 | } 41 | 42 | // MsgAuditGroupChat 获取会话内容存档内部群信息 43 | type MsgAuditGroupChat struct { 44 | // Members roomid对应的群成员列表 45 | Members []MsgAuditGroupChatMember 46 | // RoomName roomid对应的群名称 47 | RoomName string 48 | // Creator roomid对应的群创建者,userid 49 | Creator string 50 | // RoomCreateTime roomid对应的群创建时间 51 | RoomCreateTime time.Time 52 | // Notice roomid对应的群公告 53 | Notice string 54 | } 55 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/oa.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // GetOATemplateDetail 获取审批模板详情 9 | func (c *App) GetOATemplateDetail(templateID string) (*OATemplateDetail, error) { 10 | resp, err := c.execOAGetTemplateDetail(oaGetTemplateDetailReq{ 11 | TemplateID: templateID, 12 | }) 13 | if err != nil { 14 | return nil, err 15 | } 16 | return &resp.OATemplateDetail, nil 17 | } 18 | 19 | // ApplyOAEvent 提交审批申请 20 | func (c *App) ApplyOAEvent(applyInfo OAApplyEvent) (string, error) { 21 | resp, err := c.execOAApplyEvent(oaApplyEventReq{ 22 | OAApplyEvent: applyInfo, 23 | }) 24 | if err != nil { 25 | return "", err 26 | } 27 | return resp.SpNo, nil 28 | } 29 | 30 | // GetOAApprovalInfo 批量获取审批单号 31 | func (c *App) GetOAApprovalInfo(req GetOAApprovalInfoReq) ([]string, error) { 32 | resp, err := c.execOAGetApprovalInfo(oaGetApprovalInfoReq{ 33 | StartTime: strconv.FormatInt(req.StartTime.Unix(), 10), 34 | EndTime: strconv.FormatInt(req.EndTime.Unix(), 10), 35 | Cursor: req.Cursor, 36 | Size: req.Size, 37 | Filters: req.Filters, 38 | }) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return resp.SpNoList, nil 43 | } 44 | 45 | // GetOAApprovalDetail 提交审批申请 46 | func (c *App) GetOAApprovalDetail(spNo string) (*OAApprovalDetail, error) { 47 | resp, err := c.execOAGetApprovalDetail(oaGetApprovalDetailReq{ 48 | SpNo: spNo, 49 | }) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return &resp.Info, nil 54 | } 55 | 56 | // GetOAApprovalInfoReq 批量获取审批单号请求 57 | type GetOAApprovalInfoReq struct { 58 | // StartTime 审批单提交的时间范围,开始时间,UNix时间戳 59 | StartTime time.Time 60 | // EndTime 审批单提交的时间范围,结束时间,Unix时间戳 61 | EndTime time.Time 62 | // Cursor 分页查询游标,默认为0,后续使用返回的next_cursor进行分页拉取 63 | Cursor int 64 | // Size 一次请求拉取审批单数量,默认值为100,上限值为100 65 | Size uint32 66 | // Filters 筛选条件,可对批量拉取的审批申请设置约束条件,支持设置多个条件 67 | Filters []OAApprovalInfoFilter 68 | } 69 | 70 | // OAApprovalInfo 审批申请状态变化回调通知 71 | type OAApprovalInfo struct { 72 | // SpNo 审批编号 73 | SpNo string `xml:"SpNo"` 74 | // SpName 审批申请类型名称(审批模板名称) 75 | SpName string `xml:"SpName"` 76 | // SpStatus 申请单状态:1-审批中;2-已通过;3-已驳回;4-已撤销;6-通过后撤销;7-已删除;10-已支付 77 | SpStatus string `xml:"SpStatus"` 78 | // TemplateID 审批模板id。可在“获取审批申请详情”、“审批状态变化回调通知”中获得,也可在审批模板的模板编辑页面链接中获得。 79 | TemplateID string `xml:"TemplateId"` 80 | // ApplyTime 审批申请提交时间,Unix时间戳 81 | ApplyTime string `xml:"ApplyTime"` 82 | // Applicant 申请人信息 83 | Applicant OAApprovalInfoApplicant `xml:"Applyer"` 84 | // SpRecord 审批流程信息,可能有多个审批节点。 85 | SpRecord []OAApprovalInfoSpRecord `xml:"SpRecord"` 86 | // Notifier 抄送信息,可能有多个抄送节点 87 | Notifier OAApprovalInfoNotifier `xml:"Notifyer"` 88 | // Comments 审批申请备注信息,可能有多个备注节点 89 | Comments []OAApprovalInfoComment `xml:"Comments"` 90 | // StatusChangeEvent 审批申请状态变化类型:1-提单;2-同意;3-驳回;4-转审;5-催办;6-撤销;8-通过后撤销;10-添加备注 91 | StatusChangeEvent string `xml:"StatuChangeEvent"` 92 | } 93 | 94 | // OAApprovalInfoApplicant 申请人信息 95 | type OAApprovalInfoApplicant struct { 96 | // UserID 申请人userid 97 | UserID string `xml:"UserId"` 98 | // Party 申请人所在部门pid 99 | Party string `xml:"Party"` 100 | } 101 | 102 | // OAApprovalInfoSpRecord 审批流程信息,可能有多个审批节点。 103 | type OAApprovalInfoSpRecord struct { 104 | // SpStatus 审批节点状态:1-审批中;2-已同意;3-已驳回;4-已转审 105 | SpStatus string `xml:"SpStatus"` 106 | // ApproverAttr 节点审批方式:1-或签;2-会签 107 | ApproverAttr string `xml:"ApproverAttr"` 108 | // Details 审批节点详情。当节点为标签或上级时,一个节点可能有多个分支 109 | Details []OAApprovalInfoSpRecordDetail `xml:"Details"` 110 | } 111 | 112 | // OAApprovalInfoSpRecordDetail 审批节点详情。当节点为标签或上级时,一个节点可能有多个分支 113 | type OAApprovalInfoSpRecordDetail struct { 114 | // Approver 分支审批人 115 | Approver OAApprovalInfoSpRecordDetailApprover `xml:"Approver"` 116 | // Speech 审批意见字段 117 | Speech string `xml:"Speech"` 118 | // SpStatus 分支审批人审批状态:1-审批中;2-已同意;3-已驳回;4-已转审 119 | SpStatus string `xml:"SpStatus"` 120 | // SpTime 节点分支审批人审批操作时间,0为尚未操作 121 | SpTime string `xml:"SpTime"` 122 | // Attach 节点分支审批人审批意见附件,赋值为media_id具体使用请参考:文档-获取临时素材 123 | Attach []string `xml:"Attach"` 124 | } 125 | 126 | // OAApprovalInfoSpRecordDetailApprover 分支审批人 127 | type OAApprovalInfoSpRecordDetailApprover struct { 128 | // UserID 分支审批人userid 129 | UserID string `xml:"UserId"` 130 | } 131 | 132 | // OAApprovalInfoNotifier 抄送信息,可能有多个抄送节点 133 | type OAApprovalInfoNotifier struct { 134 | // UserID 节点抄送人userid 135 | UserID string `xml:"UserId"` 136 | } 137 | 138 | // OAApprovalInfoComment 审批申请备注信息,可能有多个备注节点 139 | type OAApprovalInfoComment struct { 140 | // CommentUserInfo 备注人信息 141 | CommentUserInfo OAApprovalInfoCommentUserInfo `xml:"CommentUserInfo"` 142 | // CommentTime 备注提交时间 143 | CommentTime string `xml:"CommentTime"` 144 | // CommentContent 备注文本内容 145 | CommentContent string `xml:"CommentContent"` 146 | // CommentID 备注id 147 | CommentID string `xml:"CommentId"` 148 | // Attach 备注意见附件,值是附件media_id具体使用请参考:文档-获取临时素材 149 | Attach []string `xml:"Attach"` 150 | } 151 | 152 | // OAApprovalInfoCommentUserInfo 备注人信息 153 | type OAApprovalInfoCommentUserInfo struct { 154 | // UserID 备注人userid 155 | UserID string `xml:"UserId"` 156 | } 157 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/oa_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import "encoding/json" 4 | 5 | type oaGetTemplateDetailReq struct { 6 | TemplateID string `json:"template_id"` 7 | } 8 | 9 | var _ bodyer = oaGetTemplateDetailReq{} 10 | 11 | func (x oaGetTemplateDetailReq) intoBody() ([]byte, error) { 12 | result, err := json.Marshal(x) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return result, nil 18 | } 19 | 20 | type oaGetTemplateDetailResp struct { 21 | CommonResp 22 | OATemplateDetail 23 | } 24 | 25 | type oaApplyEventReq struct { 26 | OAApplyEvent 27 | } 28 | 29 | var _ bodyer = oaApplyEventReq{} 30 | 31 | func (x oaApplyEventReq) intoBody() ([]byte, error) { 32 | result, err := json.Marshal(x) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return result, nil 38 | } 39 | 40 | type oaApplyEventResp struct { 41 | CommonResp 42 | // SpNo 表单提交成功后,返回的表单编号 43 | SpNo string `json:"sp_no"` 44 | } 45 | 46 | type oaGetApprovalInfoReq struct { 47 | StartTime string `json:"starttime"` 48 | EndTime string `json:"endtime"` 49 | Cursor int `json:"cursor"` 50 | Size uint32 `json:"size"` 51 | Filters []OAApprovalInfoFilter `json:"filters"` 52 | } 53 | 54 | var _ bodyer = oaGetApprovalInfoReq{} 55 | 56 | func (x oaGetApprovalInfoReq) intoBody() ([]byte, error) { 57 | result, err := json.Marshal(x) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return result, nil 63 | } 64 | 65 | type oaGetApprovalInfoResp struct { 66 | CommonResp 67 | // SpNoList 审批单号列表,包含满足条件的审批申请 68 | SpNoList []string `json:"sp_no_list"` 69 | } 70 | 71 | type oaGetApprovalDetailReq struct { 72 | // SpNo 审批单编号。 73 | SpNo string `json:"sp_no"` 74 | } 75 | 76 | var _ bodyer = oaGetApprovalDetailReq{} 77 | 78 | func (x oaGetApprovalDetailReq) intoBody() ([]byte, error) { 79 | result, err := json.Marshal(x) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return result, nil 85 | } 86 | 87 | type oaGetApprovalDetailResp struct { 88 | CommonResp 89 | // Info 审批申请详情 90 | Info OAApprovalDetail `json:"info"` 91 | } 92 | 93 | // execOAGetTemplateDetail 获取审批模板详情 94 | func (c *App) execOAGetTemplateDetail(req oaGetTemplateDetailReq) (oaGetTemplateDetailResp, error) { 95 | var resp oaGetTemplateDetailResp 96 | err := c.executeWXApiJSONPost("/cgi-bin/oa/gettemplatedetail", req, &resp, true) 97 | if err != nil { 98 | return oaGetTemplateDetailResp{}, err 99 | } 100 | if bizErr := resp.TryIntoErr(); bizErr != nil { 101 | return oaGetTemplateDetailResp{}, bizErr 102 | } 103 | 104 | return resp, nil 105 | } 106 | 107 | // execOAApplyEvent 提交审批申请 108 | func (c *App) execOAApplyEvent(req oaApplyEventReq) (oaApplyEventResp, error) { 109 | var resp oaApplyEventResp 110 | err := c.executeWXApiJSONPost("/cgi-bin/oa/applyevent", req, &resp, true) 111 | if err != nil { 112 | return oaApplyEventResp{}, err 113 | } 114 | if bizErr := resp.TryIntoErr(); bizErr != nil { 115 | return oaApplyEventResp{}, bizErr 116 | } 117 | 118 | return resp, nil 119 | } 120 | 121 | // execOAGetApprovalInfo 批量获取审批单号 122 | func (c *App) execOAGetApprovalInfo(req oaGetApprovalInfoReq) (oaGetApprovalInfoResp, error) { 123 | var resp oaGetApprovalInfoResp 124 | err := c.executeWXApiJSONPost("/cgi-bin/oa/getapprovalinfo", req, &resp, true) 125 | if err != nil { 126 | return oaGetApprovalInfoResp{}, err 127 | } 128 | if bizErr := resp.TryIntoErr(); bizErr != nil { 129 | return oaGetApprovalInfoResp{}, bizErr 130 | } 131 | 132 | return resp, nil 133 | } 134 | 135 | // execOAGetApprovalDetail 获取审批申请详情 136 | func (c *App) execOAGetApprovalDetail(req oaGetApprovalDetailReq) (oaGetApprovalDetailResp, error) { 137 | var resp oaGetApprovalDetailResp 138 | err := c.executeWXApiJSONPost("/cgi-bin/oa/getapprovaldetail", req, &resp, true) 139 | if err != nil { 140 | return oaGetApprovalDetailResp{}, err 141 | } 142 | if bizErr := resp.TryIntoErr(); bizErr != nil { 143 | return oaGetApprovalDetailResp{}, bizErr 144 | } 145 | 146 | return resp, nil 147 | } 148 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/recipient.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // Recipient 消息收件人定义 4 | type Recipient struct { 5 | // UserIDs 成员ID列表(消息接收者),最多支持1000个 6 | UserIDs []string 7 | // PartyIDs 部门ID列表,最多支持100个。 8 | PartyIDs []string 9 | // TagIDs 标签ID列表,最多支持100个 10 | TagIDs []string 11 | // ChatID 应用关联群聊ID,仅用于【发送消息到群聊会话】 12 | ChatID string 13 | } 14 | 15 | // isIndividualTargetsEmpty 对非群发收件人字段而言,是否全为空 16 | // 17 | // 文档注释摘抄: 18 | // 19 | // > touser、toparty、totag不能同时为空,后面不再强调。 20 | func (x *Recipient) isIndividualTargetsEmpty() bool { 21 | return len(x.UserIDs) == 0 && len(x.PartyIDs) == 0 && len(x.TagIDs) == 0 22 | } 23 | 24 | // isValidForMessageSend 本结构体是否对【发送应用消息】请求有效 25 | func (x *Recipient) isValidForMessageSend() bool { 26 | if x.ChatID != "" { 27 | // 这时候你应该用 AppchatSend 接口 28 | return false 29 | } 30 | 31 | if x.isIndividualTargetsEmpty() { 32 | // 见这个方法的注释 33 | return false 34 | } 35 | 36 | if len(x.UserIDs) > 1000 || len(x.PartyIDs) > 100 || len(x.TagIDs) > 100 { 37 | // 见字段注释 38 | return false 39 | } 40 | 41 | return true 42 | } 43 | 44 | // isValidForAppChatSend 本结构体是否对【发送消息到群聊会话】请求有效 45 | func (x *Recipient) isValidForAppChatSend() bool { 46 | if !x.isIndividualTargetsEmpty() { 47 | return false 48 | } 49 | 50 | return x.ChatID != "" 51 | } 52 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/rx.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "msg/pkg/go-workwx-develop/internal/lowlevel/envelope" 5 | "msg/pkg/go-workwx-develop/internal/lowlevel/httpapi" 6 | "net/http" 7 | ) 8 | 9 | // RxMessageHandler 用来接收消息的接口。 10 | type RxMessageHandler interface { 11 | // OnIncomingMessage 一条消息到来时的回调。 12 | OnIncomingMessage(msg *RxMessage) error 13 | } 14 | 15 | type LowLevelEnvelopeHandler struct { 16 | highLevelHandler RxMessageHandler 17 | } 18 | 19 | var _ httpapi.EnvelopeHandler = (*LowLevelEnvelopeHandler)(nil) 20 | 21 | func (h *LowLevelEnvelopeHandler) OnIncomingEnvelope(rx envelope.Envelope) error { 22 | msg, err := fromEnvelope(rx.Msg) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | return h.highLevelHandler.OnIncomingMessage(msg) 28 | } 29 | 30 | type HTTPHandler struct { 31 | inner *httpapi.LowLevelHandler 32 | } 33 | 34 | var _ http.Handler = (*HTTPHandler)(nil) 35 | 36 | func (h *HTTPHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 37 | h.inner.ServeHTTP(rw, r) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/tag.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // CreateTag 创建标签 4 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90210#创建标签 5 | func (c *App) CreateTag(req Tag) (tagID int, err error) { 6 | var resp createTagResp 7 | resp, err = c.execCreateTag(req) 8 | if err != nil { 9 | return 10 | } 11 | 12 | tagID = resp.TagID 13 | return 14 | } 15 | 16 | // UpdateTag 更新标签名字 17 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90211#更新标签名字 18 | func (c *App) UpdateTag(req Tag) (ok bool, err error) { 19 | var resp updateTagResp 20 | resp, err = c.execUpdateTag(req) 21 | if err != nil { 22 | return 23 | } 24 | ok = resp.IsOK() 25 | return 26 | } 27 | 28 | // ListTag 获取标签列表 29 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90216#获取标签列表 30 | func (c *App) ListTag() (tags []Tag, err error) { 31 | var resp listTagResp 32 | resp, err = c.execListTag() 33 | if err != nil { 34 | return 35 | } 36 | 37 | tags = resp.TagList 38 | return 39 | } 40 | 41 | // DeleteTag 删除标签 42 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90212#删除标签 43 | func (c *App) DeleteTag(tagID int) (ok bool, err error) { 44 | var resp deleteTagResp 45 | resp, err = c.execDeleteTag(deleteTagReq{TagID: tagID}) 46 | if err != nil { 47 | return false, err 48 | } 49 | ok = resp.IsOK() 50 | return 51 | } 52 | 53 | // GetTagDetail 获取标签成员 54 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90213#获取标签成员 55 | func (c *App) GetTagDetail(tagID int) (tagDetail TagDetail, err error) { 56 | var resp getTagResp 57 | resp, err = c.execGetTag(getTagReq{ 58 | tagID, 59 | }) 60 | if err != nil { 61 | return TagDetail{}, err 62 | } 63 | 64 | tagDetail = resp.TagDetail 65 | return 66 | } 67 | 68 | // AddTagUsers 增加标签成员 69 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90214#增加标签成员 70 | func (c *App) AddTagUsers(req AddTagUsersReq) (ok bool, err error) { 71 | var resp addTagUsersResp 72 | resp, err = c.execAddTagUsers(req) 73 | if err != nil { 74 | return false, err 75 | } 76 | ok = resp.IsOK() 77 | return 78 | } 79 | 80 | // DelTagUsers 删除标签成员 81 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90215#删除标签成员 82 | func (c *App) DelTagUsers(req DelTagUsersReq) (ok bool, err error) { 83 | var resp delTagUsersResp 84 | resp, err = c.execDelTagUsers(req) 85 | if err != nil { 86 | return false, err 87 | } 88 | ok = resp.IsOK() 89 | return 90 | } 91 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/tag_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // Tag 创建标签 4 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90210#创建标签 5 | type Tag struct { 6 | TagID int `json:"tagid"` // 非必填,标签id,非负整型,指定此参数时新增的标签会生成对应的标签id,不指定时则以目前最大的id自增。 7 | TagName string `json:"tagname"` // 必填,标签名称,长度限制为32个字以内(汉字或英文字母),标签名不可与其他标签重名 8 | } 9 | 10 | // TagUser 标签关联成员 11 | type TagUser struct { 12 | // Name 成员名称,此字段从2019年12月30日起,对新创建第三方应用不再返回,2020年6月30日起,对所有历史第三方应用不再返回,后续第三方仅通讯录应用可获取,第三方页面需要通过通讯录展示组件来展示名字 13 | Name string `json:"name"` 14 | // Userid 成员帐号 15 | Userid string `json:"userid"` 16 | } 17 | 18 | // TagDetail 标签详情 19 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90213#获取标签成员 20 | type TagDetail struct { 21 | // PartyList 标签中包含的部门id列表 22 | PartyList []int `json:"partylist"` 23 | // TagName 标签名 24 | TagName string `json:"tagname"` 25 | UserList []TagUser `json:"userlist"` //标签中包含的成员列表 26 | } 27 | 28 | // AddTagUsersReq 增加标签成员 29 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90214#增加标签成员 30 | type AddTagUsersReq struct { 31 | // PartyList 企业部门ID列表,注意:userlist、partylist不能同时为空,单次请求个数不超过100 32 | PartyList []int `json:"partylist,omitempty"` 33 | // TagID 标签ID,必填 34 | TagID int `json:"tagid"` 35 | // UserList 企业成员ID列表,注意:userlist、partylist不能同时为空,单次请求个数不超过1000 36 | UserList []string `json:"userlist,omitempty"` 37 | } 38 | 39 | // DelTagUsersReq 删除标签成员请求 40 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/90215#删除标签成员 41 | type DelTagUsersReq struct { 42 | // PartyList 企业部门ID列表,注意:userlist、partylist不能同时为空,单次请求个数不超过100 43 | PartyList []int `json:"partylist,omitempty"` 44 | // TagID 标签ID,必填 45 | TagID int `json:"tagid"` 46 | // UserList 企业成员ID列表,注意:userlist、partylist不能同时为空,单次请求个数不超过1000 47 | UserList []string `json:"userlist,omitempty"` 48 | } 49 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/traits.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "net/url" 5 | ) 6 | 7 | // urlValuer 可转化为 url.Values 类型的 trait 8 | type urlValuer interface { 9 | intoURLValues() url.Values 10 | } 11 | 12 | // Bodyer 可转化为 API 请求体的 trait 13 | type bodyer interface { 14 | intoBody() ([]byte, error) 15 | } 16 | 17 | // mediaUploader 携带 *Media 对象,可转化为 multipart 文件上传请求体的 trait 18 | type mediaUploader interface { 19 | getMedia() *Media 20 | } 21 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/user_info.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | //企业成员(员工) 相关接口 4 | 5 | // GetUser 读取成员 6 | func (c *App) GetUser(userid string) (*UserInfo, error) { 7 | resp, err := c.execUserGet(userGetReq{ 8 | UserID: userid, 9 | }) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | obj := resp.intoUserInfo() 15 | return &obj, nil 16 | } 17 | 18 | // ListUsersByDeptID 获取部门成员详情 19 | func (c *App) ListUsersByDeptID(deptID int64, fetchChild bool) ([]*UserInfo, error) { 20 | resp, err := c.execUserList(userListReq{ 21 | DeptID: deptID, 22 | FetchChild: fetchChild, 23 | }) 24 | if err != nil { 25 | return nil, err 26 | } 27 | users := make([]*UserInfo, len(resp.Users)) 28 | for index, user := range resp.Users { 29 | userInfo := user.intoUserInfo() 30 | users[index] = &userInfo 31 | } 32 | return users, nil 33 | } 34 | 35 | // GetUserIDByMobile 通过手机号获取 userid 36 | func (c *App) GetUserIDByMobile(mobile string) (string, error) { 37 | resp, err := c.execUserIDByMobile(userIDByMobileReq{ 38 | Mobile: mobile, 39 | }) 40 | if err != nil { 41 | return "", err 42 | } 43 | return resp.UserID, nil 44 | } 45 | 46 | // GetUserInfoByCode 获取访问用户身份,根据code获取成员信息 47 | func (c *App) GetUserInfoByCode(code string) (*UserIdentityInfo, error) { 48 | resp, err := c.execUserInfoGet(userInfoGetReq{ 49 | Code: code, 50 | }) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return &resp.UserIdentityInfo, nil 55 | } 56 | 57 | // UpdateUser 更新成员 58 | // 文档:https://work.weixin.qq.com/api/doc/90000/90135/90197#更新成员 59 | func (c *App) UpdateUser(req UpdateUserReq) (ok bool, err error) { 60 | var resp updateUserResp 61 | resp, err = c.execUpdateUser(req) 62 | if err != nil { 63 | return false, err 64 | } 65 | ok = resp.IsOK() 66 | return 67 | } 68 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/user_info_helper.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func reshapeDeptInfo( 9 | ids []int64, 10 | orders []uint32, 11 | leaderStatuses []int, 12 | ) []UserDeptInfo { 13 | if len(ids) != len(orders) { 14 | panic("should never happen") 15 | } 16 | if len(ids) != len(leaderStatuses) { 17 | panic("should never happen") 18 | } 19 | 20 | result := make([]UserDeptInfo, len(ids)) 21 | for i := range ids { 22 | result[i].DeptID = ids[i] 23 | result[i].Order = orders[i] 24 | result[i].IsLeader = leaderStatuses[i] != 0 25 | } 26 | 27 | return result 28 | } 29 | 30 | func mustFromGenderStr(x string) UserGender { 31 | n, err := strconv.Atoi(x) 32 | if err != nil { 33 | panic(fmt.Sprintf("gender string parse failed: %+v", err)) 34 | } 35 | 36 | return UserGender(n) 37 | } 38 | 39 | func (x userDetailResp) intoUserInfo() UserInfo { 40 | deptInfo := reshapeDeptInfo(x.DeptIDs, x.DeptOrder, x.IsLeaderInDept) 41 | 42 | return UserInfo{ 43 | UserID: x.UserID, 44 | Name: x.Name, 45 | Position: x.Position, 46 | Departments: deptInfo, 47 | Mobile: x.Mobile, 48 | Gender: mustFromGenderStr(x.Gender), 49 | Email: x.Email, 50 | AvatarURL: x.AvatarURL, 51 | Telephone: x.Telephone, 52 | IsEnabled: x.IsEnabled != 0, 53 | Alias: x.Alias, 54 | Status: UserStatus(x.Status), 55 | QRCodeURL: x.QRCodeURL, 56 | DeptIDs: x.DeptIDs, 57 | DeptOrder: x.DeptOrder, 58 | IsLeaderInDept: x.IsLeaderInDept, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/user_info_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // UserInfo 用户信息 4 | type UserInfo struct { 5 | // UserID 成员UserID 6 | // 7 | // 对应管理端的账号,企业内必须唯一。不区分大小写,长度为1~64个字节 8 | UserID string 9 | // Name 成员名称 10 | Name string 11 | // Position 职务信息;第三方仅通讯录应用可获取 12 | Position string 13 | // Departments 成员所属部门信息 14 | Departments []UserDeptInfo 15 | // Mobile 手机号码;第三方仅通讯录应用可获取 16 | Mobile string 17 | // Gender 性别 18 | Gender UserGender 19 | // Email 邮箱;第三方仅通讯录应用可获取 20 | Email string 21 | // AvatarURL 头像 URL;第三方仅通讯录应用可获取 22 | // 23 | // NOTE:如果要获取小图将url最后的”/0”改成”/100”即可。 24 | AvatarURL string 25 | // Telephone 座机;第三方仅通讯录应用可获取 26 | Telephone string 27 | // IsEnabled 成员的启用状态 28 | IsEnabled bool 29 | // Alias 别名;第三方仅通讯录应用可获取 30 | Alias string 31 | // Status 成员激活状态 32 | Status UserStatus 33 | // QRCodeURL 员工个人二维码;第三方仅通讯录应用可获取 34 | // 35 | // 扫描可添加为外部联系人 36 | QRCodeURL string 37 | DeptIDs []int64 `json:"department"` 38 | DeptOrder []uint32 `json:"order"` 39 | IsLeaderInDept []int `json:"is_leader_in_dept"` 40 | Address string 41 | } 42 | 43 | // UserGender 用户性别 44 | type UserGender int 45 | 46 | const ( 47 | // UserGenderUnspecified 性别未定义 48 | UserGenderUnspecified UserGender = 0 49 | // UserGenderMale 男性 50 | UserGenderMale UserGender = 1 51 | // UserGenderFemale 女性 52 | UserGenderFemale UserGender = 2 53 | ) 54 | 55 | // UserStatus 用户激活信息 56 | // 57 | // 已激活代表已激活企业微信或已关注微工作台(原企业号)。 58 | // 未激活代表既未激活企业微信又未关注微工作台(原企业号)。 59 | type UserStatus int 60 | 61 | const ( 62 | // UserStatusActivated 已激活 63 | UserStatusActivated UserStatus = 1 64 | // UserStatusDeactivated 已禁用 65 | UserStatusDeactivated UserStatus = 2 66 | // UserStatusUnactivated 未激活 67 | UserStatusUnactivated UserStatus = 4 68 | ) 69 | 70 | // UserDeptInfo 用户部门信息 71 | type UserDeptInfo struct { 72 | // DeptID 部门 ID 73 | DeptID int64 74 | // Order 部门内的排序值,默认为0,数值越大排序越前面 75 | Order uint32 76 | // IsLeader 在所在的部门内是否为上级 77 | IsLeader bool 78 | } 79 | 80 | // UserIdentityInfo 访问用户身份信息 81 | type UserIdentityInfo struct { 82 | // UserID 成员UserID。若需要获得用户详情信息,可调用通讯录接口:读取成员。如果是互联企业,则返回的UserId格式如:CorpId/userid 83 | UserID string `json:"UserId"` 84 | // OpenID 非企业成员的标识,对当前企业唯一。不超过64字节 85 | OpenID string `json:"OpenId"` 86 | // DeviceID 手机设备号(由企业微信在安装时随机生成,删除重装会改变,升级不受影响) 87 | DeviceID string `json:"DeviceId"` 88 | // ExternalUserID 外部联系人ID 89 | ExternalUserID string `json:"external_userid"` 90 | } 91 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/welcome_msg.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // SendWelcomeMsg 发送新客户欢迎语 4 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92137#发送新客户欢迎语 5 | func (c *App) SendWelcomeMsg(req SendWelcomeMsgReq) (ok bool, err error) { 6 | var resp sendWelcomeMsgResp 7 | resp, err = c.execSendWelcomeMsg(req) 8 | if err != nil { 9 | return false, err 10 | } 11 | ok = resp.IsOK() 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/welcome_msg_api.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | import "encoding/json" 4 | 5 | type SendWelcomeMsgReq struct { 6 | //附件,最多可添加9个附件 7 | Attachments []Attachments `json:"attachments,omitempty"` 8 | Text Text `json:"text"` 9 | // WelcomeCode 通过 添加外部联系人事件 推送给企业的发送欢迎语的凭证,有效期为 20秒,必填 10 | WelcomeCode string `json:"welcome_code"` 11 | } 12 | 13 | var _ bodyer = SendWelcomeMsgReq{} 14 | 15 | func (x SendWelcomeMsgReq) intoBody() ([]byte, error) { 16 | result, err := json.Marshal(x) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return result, nil 22 | } 23 | 24 | // sendWelcomeMsgResp 发送新客户欢迎语响应 25 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92137#发送新客户欢迎语 26 | type sendWelcomeMsgResp struct { 27 | CommonResp 28 | } 29 | 30 | var _ bodyer = sendWelcomeMsgResp{} 31 | 32 | func (x sendWelcomeMsgResp) intoBody() ([]byte, error) { 33 | result, err := json.Marshal(x) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return result, nil 39 | } 40 | 41 | // execSendWelcomeMsg 发送新客户欢迎语 42 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92137#发送新客户欢迎语 43 | func (c *App) execSendWelcomeMsg(req SendWelcomeMsgReq) (sendWelcomeMsgResp, error) { 44 | var resp sendWelcomeMsgResp 45 | err := c.executeWXApiJSONPost("/cgi-bin/externalcontact/send_welcome_msg", req, &resp, true) 46 | if err != nil { 47 | return sendWelcomeMsgResp{}, err 48 | } 49 | if bizErr := resp.TryIntoErr(); bizErr != nil { 50 | return sendWelcomeMsgResp{}, bizErr 51 | } 52 | 53 | return resp, nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/go-workwx-develop/welcome_msg_model.go: -------------------------------------------------------------------------------- 1 | package workwx 2 | 3 | // SendWelcomeMsgReq 4 | // 发送新客户欢迎语请求 5 | // 文档:https://open.work.weixin.qq.com/api/doc/90000/90135/92137#发送新客户欢迎语 6 | 7 | type Image struct { 8 | // MediaID 图片的media_id,可以通过 素材管理 接口获得 9 | MediaID string `json:"media_id,omitempty"` 10 | // PicURL 图片的链接,仅可使用 上传图片接口得到的链接 11 | PicURL string `json:"pic_url,omitempty"` 12 | } 13 | type Link struct { 14 | // Desc 图文消息的描述,最长为512字节 15 | Desc string `json:"desc,omitempty"` 16 | // PicURL 图文消息封面的url 17 | PicURL string `json:"picurl,omitempty"` 18 | // Title 图文消息标题,最长为128字节,必填 19 | Title string `json:"title"` 20 | // URL 图文消息的链接,必填 21 | URL string `json:"url"` 22 | } 23 | type MiniProgram struct { 24 | // Appid 小程序appid,必须是关联到企业的小程序应用,必填 25 | Appid string `json:"appid"` 26 | // Page 小程序page路径,必填 27 | Page string `json:"page"` 28 | // PicMediaID 小程序消息封面的mediaid,封面图建议尺寸为520*416,必填 29 | PicMediaID string `json:"pic_media_id"` 30 | // Title 小程序消息标题,最长为64字节,必填 31 | Title string `json:"title"` 32 | } 33 | type Video struct { 34 | // MediaID 视频的media_id,可以通过 素材管理 接口获得,必填 35 | MediaID string `json:"media_id,omitempty"` 36 | } 37 | type Attachments []struct { 38 | // MsgType 附件类型,可选image、link、miniprogram或者video,必填 39 | MsgType string `json:"msgtype"` 40 | Image Image `json:"image"` 41 | Link Link `json:"link"` 42 | Miniprogram MiniProgram `json:"miniprogram"` 43 | Video Video `json:"video"` 44 | } 45 | 46 | type Text struct { 47 | // Content 消息文本内容,最长为4000字节 48 | Content string `json:"content,omitempty"` 49 | } 50 | -------------------------------------------------------------------------------- /requests/clue_manual.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | // CreateClueManualReq 创建跟进请求 4 | type CreateClueManualReq struct { 5 | // 员工外部ID 6 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 7 | // 客户外部ID 8 | ExtCustomerID string `json:"ext_customer_id" form:"ext_customer_id" validate:"required"` 9 | // 跟进事件的内容 10 | Content string `json:"content" form:"content" validate:"required"` 11 | } 12 | 13 | // UpdateClueManualReq 更新跟进请求 14 | type UpdateClueManualReq struct { 15 | // 跟进事件的内容 16 | Content string `json:"content" form:"content" validate:"required"` 17 | } 18 | -------------------------------------------------------------------------------- /requests/common.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | "time" 7 | ) 8 | 9 | type CommonDeleteReq struct { 10 | IDs []string `json:"ids" form:"ids" validate:"required,gt=0"` 11 | } 12 | 13 | type LocalTime time.Time 14 | 15 | func (t *LocalTime) UnmarshalJSON(data []byte) (err error) { 16 | // 空值不进行解析 17 | if len(data) == 2 { 18 | *t = LocalTime(time.Time{}) 19 | return 20 | } 21 | 22 | loc, _ := time.LoadLocation("Asia/Chongqing") 23 | now, err := time.ParseInLocation(`"`+constants.DateTimeLayout+`"`, string(data), loc) 24 | *t = LocalTime(now) 25 | return 26 | } 27 | 28 | func (t LocalTime) MarshalJSON() ([]byte, error) { 29 | b := make([]byte, 0, len(constants.DateTimeLayout)+2) 30 | b = append(b, '"') 31 | b = time.Time(t).AppendFormat(b, constants.DateTimeLayout) 32 | b = append(b, '"') 33 | return b, nil 34 | } 35 | 36 | type QueryCustomerGroupTagGroupReq struct { 37 | Name string `json:"name" form:"name" validate:"omitempty"` 38 | app.Sorter 39 | app.Pager 40 | } 41 | -------------------------------------------------------------------------------- /requests/contact_way_group.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | // CreateContactWayGroupReq 创建渠道码分组请求参数 9 | type CreateContactWayGroupReq struct { 10 | // Name 分组名称 11 | Name string `json:"name" gorm:"comment:'分组名称'" validate:"required"` 12 | // SortWeight 分组排序 13 | SortWeight int `json:"sort_weight" gorm:"comment:'分组排序'" validate:"gte=0"` 14 | } 15 | 16 | // UpdateContactWayGroupReq 更新渠道码分组请求参数 17 | type UpdateContactWayGroupReq struct { 18 | // Name 分组名称 19 | Name string `json:"name" gorm:"comment:'分组名称'"` 20 | // SortWeight 分组排序 21 | SortWeight int `json:"sort_weight" gorm:"comment:'分组排序'" validate:"gte=0"` 22 | } 23 | 24 | // QueryContactWayGroupReq 查询渠道码分组列表请求参数 25 | type QueryContactWayGroupReq struct { 26 | app.Pager 27 | app.Sorter 28 | // ID 渠道码分组ID 29 | ID string `form:"id" json:"id" gorm:"primaryKey;type:bigint;comment:'ID'" validate:"omitempty,int64" ` 30 | // Name 渠道码分组名称 31 | Name string `form:"name" json:"name" gorm:"type:varchar(255);index;comment:'渠道码分组名称'"` 32 | // IsDefault 是否为默认分组 33 | IsDefault constants.Boolean `form:"is_default" json:"is_default" gorm:"default:2;comment:'是否为默认分组'" validate:"omitempty,oneof=1 2" ` 34 | } 35 | 36 | // DeleteContactWayGroupReq 删除渠道码分组请求参数 37 | type DeleteContactWayGroupReq struct { 38 | // 渠道码分组ID 39 | IDs []string `json:"ids" validate:"gt=0,dive,int64"` 40 | } 41 | -------------------------------------------------------------------------------- /requests/customer.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | // QueryCustomerReq 客户管理 9 | type QueryCustomerReq struct { 10 | Name string `form:"name" json:"name"` // 客户名字,支持模糊查询 11 | ExtStaffIDs []string `json:"ext_staff_ids" form:"ext_staff_ids"` // 所属客服 12 | ExtTagIDs []string `form:"ext_tag_ids" json:"ext_tag_ids"` // 企业标签 13 | TagUnionType string `form:"tag_union_type" json:"tag_union_type" ` 14 | ChannelType int `form:"channel_type" json:"channel_type"` // 添加渠道 15 | Gender int `form:"gender" json:"gender" validate:"omitempty,oneof=0 1 2"` // 性别 16 | OutFlowStatus int `form:"out_flow_status" json:"out_flow_status "` // 流失状态 1-已经流失 2-未流失 17 | // 客户类型 1-微信用户, 2-企业微信用户 18 | Type int `form:"type" json:"type" validate:"omitempty,oneof=0 1 2"` 19 | StartTime constants.DateField `form:"start_time" json:"start_time"` // 添加客户的时间 20 | EndTime constants.DateField `form:"end_time" json:"end_time"` // 添加客户的时间 21 | app.Pager 22 | app.Sorter 23 | } 24 | -------------------------------------------------------------------------------- /requests/customer_group.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/constants" 4 | 5 | type UpdateCustomerGroupReq struct { 6 | // 群聊ID 7 | GroupChatIDs []string `json:"group_chat_ids" validate:"required,gt=0"` 8 | // 新增标签ID列表 9 | AddTagIDs constants.StringArrayField `json:"add_tag_ids" validate:"omitempty,gte=0"` 10 | // 删除标签的ID列表 11 | RemoveTagIDs constants.StringArrayField `json:"remove_tag_ids" validate:"omitempty,gte=0"` 12 | } 13 | -------------------------------------------------------------------------------- /requests/customer_group_tag.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type UpdateGroupChatTagReq struct { 4 | ID string `json:"id"` 5 | Name string `json:"name"` 6 | } 7 | 8 | type CreateGroupChatTagsReq struct { 9 | GroupID string `json:"group_id" validate:"required"` 10 | Names []string `json:"names" validate:"required,gt=0"` 11 | } 12 | -------------------------------------------------------------------------------- /requests/customer_group_tag_group.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type CreateGroupChatTagGroupReq struct { 4 | Name string `json:"name" form:"name" validate:"required"` 5 | Tags []struct { 6 | Name string `json:"name"` 7 | //Id string `json:"id,omitempty"` 8 | } `json:"tags"` 9 | } 10 | 11 | type UpdateGroupChatTagGroupReq struct { 12 | ID string `json:"id" validate:"required"` 13 | Name string `json:"name"` 14 | DeleteTagIDs []string `json:"delete_tag_ids"` 15 | Tags []struct { 16 | Name string `json:"name"` 17 | Id string `json:"id,omitempty"` 18 | } `json:"tags"` 19 | } 20 | -------------------------------------------------------------------------------- /requests/customer_remark.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type CustomerRemarkReq struct { 4 | // 微信客户ID 5 | ExtCustomerID string `json:"ext_customer_id" form:"ext_customer_id"` 6 | // 微信员工ID 7 | ExtStaffID string `form:"ext_staff_id" json:"ext_staff_id"` 8 | } 9 | 10 | // AddCustomerRemarkReq 添加自定义客户信息 11 | type AddCustomerRemarkReq struct { 12 | // 自定义信息类型 option_text text timestamp 13 | FieldType string `json:"field_type" json:"field_type" validate:"required,oneof=option_text text timestamp"` 14 | FieldName string `json:"field_name" validate:"required"` 15 | // 多选类型的选项 16 | OptionNameList []string `json:"option_name_list" validate:"required_if=FieldType option_text"` 17 | } 18 | type UpdateRemarkReq struct { 19 | ID string `json:"id" form:"id" validate:"required"` 20 | Name string `json:"name" form:"name" validate:"required"` 21 | } 22 | type DeleteCustomerRemarkReq struct { 23 | IDs []string `json:"ids" validate:"gt=0"` 24 | } 25 | 26 | // -------------------------------- 27 | 28 | type AddRemarkOptionReq struct { 29 | RemarkID string `json:"remark_id" form:"remark_id" validate:"required"` 30 | Name string `json:"name" form:"name" validate:"required"` 31 | } 32 | 33 | type UpdateRemarkOptionReq struct { 34 | //// 自定义信息ID 35 | //RemarkID string `json:"remark_id" form:"remark_id" validate:"required"` 36 | // 选项类型的选项ID 37 | RemarkOptionID string `json:"remark_option_id" form:"remark_option_id" validate:"required"` 38 | // 信息名或选项名 39 | Name string `json:"name" form:"name" validate:"required"` 40 | } 41 | 42 | type DeleteRemarkOptionReq struct { 43 | IDs []string `json:"ids" form:"ids" validate:"required,gt=0"` 44 | } 45 | -------------------------------------------------------------------------------- /requests/customer_staff.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | // QueryCustomerLossesReq 查询客户流失记录 9 | type QueryCustomerLossesReq struct { 10 | // 企微员工id 11 | ExtStaffIDs []string `json:"ext_staff_ids" form:"ext_staff_ids" validate:"omitempty,dive,word"` 12 | // 流失起止时间 13 | LossStart constants.DateField `json:"loss_start" form:"loss_start" validate:"omitempty"` 14 | LossEnd constants.DateField `json:"loss_end" form:"loss_end" validate:"omitempty"` 15 | // 添加好友起止时间 16 | ConnectionCreateStart constants.DateField `json:"connection_create_start" form:"connection_create_start" validate:"omitempty"` 17 | ConnectionCreateEnd constants.DateField `json:"connection_create_end" form:"connection_create_end" validate:"omitempty"` 18 | // 好友关系时长, 单位-天 19 | TimeSpanLowerLimit int64 `json:"time_span_lower_limit" form:"time_span_lower_limit" validate:"omitempty,gt=0,numeric"` 20 | TimeSpanUpperLimit int64 `json:"time_span_upper_limit" form:"time_span_upper_limit" validate:"omitempty,gtefield=TimeSpanLowerLimit,numeric"` 21 | app.Pager 22 | app.Sorter 23 | } 24 | -------------------------------------------------------------------------------- /requests/customer_statistic.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/constants" 5 | ) 6 | 7 | // QueryCustomerStatisticReq 客户统计 8 | type QueryCustomerStatisticReq struct { 9 | // 数据类型 total increase decrease net_increase 10 | StatisticType string `json:"statistic_type" form:"statistic_type" validate:"oneof=total increase decrease net_increase"` 11 | // 员工外部ID 12 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"omitempty"` 13 | // 开始时间 14 | StartTime constants.DateField `form:"start_time" json:"start_time" validate:"required"` 15 | // 结束时间 16 | EndTime constants.DateField `form:"end_time" json:"end_time" validate:"required"` 17 | } 18 | -------------------------------------------------------------------------------- /requests/department.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type QueryDepartmentReq struct { 4 | ExtID int64 `json:"ext_id" form:"ext_id" validate:"omitempty"` 5 | } 6 | -------------------------------------------------------------------------------- /requests/event_list.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | type QueryEventListReq struct { 6 | // 客户动态列表分类 7 | // customer_action 客户动态 8 | // integral_record 积分记录 9 | // manual_event 跟进记录 10 | // moment_interaction 朋友圈互动 11 | // reminder_event 提醒事件 12 | // template_event 模板事件 13 | // update_remark 修改信息 14 | EventType string `json:"event_type" validate:"omitempty,oneof=customer_action integral_record manual_event moment_interaction reminder_event template_event update_remark" form:"event_type"` 15 | ExtStaffID string `json:"ext_staff_id" validate:"required" form:"ext_staff_id"` 16 | ExtCustomerID string `json:"ext_customer_id" validate:"required" form:"ext_customer_id"` 17 | app.Pager `form:"app_pager"` 18 | app.Sorter `form:"app_sorter"` 19 | } 20 | -------------------------------------------------------------------------------- /requests/group_chat.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | // QueryGroupChatReq 查询群聊请求 9 | type QueryGroupChatReq struct { 10 | // 群主列表 11 | Owners []string `json:"owners" validate:"omitempty" form:"owners"` 12 | // 群名 13 | Name string `json:"name" validate:"omitempty" form:"name"` 14 | // 群状态 15 | Status constants.GroupChatStatus `json:"status" validate:"omitempty" form:"status"` 16 | // 创建群时间-开始 17 | CreateTimeStart constants.DateField `json:"create_time_start" validate:"omitempty,date" form:"create_time_start"` 18 | // 创建群时间-结束 19 | CreateTimeEnd constants.DateField `json:"create_time_end" validate:"omitempty,date" form:"create_time_end"` 20 | // 群标签ID列表 21 | GroupTagIDs constants.Int64ArrayField `json:"group_tag_ids" validate:"omitempty" form:"group_tag_ids"` 22 | // 群标签ID查询条件,and/or 23 | TagsUnionType string `json:"tags_union_type" validate:"omitempty" form:"tags_union_type"` 24 | app.Pager 25 | app.Sorter 26 | } 27 | 28 | type GetAllGroupChatReq struct { 29 | app.Pager 30 | app.Sorter 31 | } 32 | -------------------------------------------------------------------------------- /requests/group_chat_mass_msg.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/constants" 4 | 5 | type SendGroupChatMassMsgReq struct { 6 | // ExtStaffIDs为群主IDs 7 | ExtStaffIDs constants.StringArrayField `json:"ext_staff_ids" form:"ext_staff_ids" validate:"omitempty"` 8 | // 1-立即发送,2-定时发送 9 | SendType constants.SendMassMsgType `json:"send_type" validate:"required,oneof=1 2"` 10 | // 定时发送时间戳 11 | SendAt constants.DateTimeFiled `json:"send_at" validate:"omitempty,gt=0"` 12 | // 群发任务的类型,默认为single,表示发送给客户,group表示发送给客户群 13 | //ChatType constants.ChatType `json:"chat_type" validate:"omitempty,oneof=single group"` 14 | // 消息体 15 | Msg constants.AutoReplyField `json:"msg" validate:"omitempty,gte=0"` 16 | } 17 | -------------------------------------------------------------------------------- /requests/group_chat_welcome_msg.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | type UpdateGroupChatWelcomeMsgReq struct { 9 | // 文字内容 10 | Content string `json:"content"` 11 | // 附件类型 12 | AttachmentType string `json:"attachment_type"` 13 | // 附件内容 14 | Attachment constants.GroupChatWelcomeMsgField `json:"attachment" validate:"omitempty"` 15 | } 16 | 17 | type CreateGroupChatWelcomeMsgReq struct { 18 | // 文字内容 19 | Content string `json:"content"` 20 | // 附件类型 21 | AttachmentType string `json:"attachment_type"` 22 | // 附件内容 23 | Attachment constants.GroupChatWelcomeMsgField `json:"attachment" validate:"omitempty"` 24 | // 开启后,新建该条欢迎语会通过「客户群」群发通知企业全部员工:“管理员创建了新的入群欢迎语” 25 | // 不可更改 26 | NotifyStaffsEnable constants.Boolean `json:"notify_staffs_enable"` 27 | } 28 | 29 | type QueryGroupChatWelcomeMsgReq struct { 30 | // 搜索内容 31 | Content string `json:"content" form:"content" validate:"required"` 32 | app.Pager 33 | app.Sorter 34 | } 35 | -------------------------------------------------------------------------------- /requests/internal_tag.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | type CreateInternalTagReq struct { 6 | Name string `json:"name" validate:"required"` 7 | } 8 | 9 | type DeleteInternalTagReq struct { 10 | IDs []string `json:"ids" validate:"required,gt=0"` 11 | } 12 | 13 | type QueryInternalTagReq struct { 14 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 15 | app.Sorter 16 | app.Pager 17 | } 18 | -------------------------------------------------------------------------------- /requests/js_api.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type GetJSConfigReq struct { 4 | URL string `json:"url" form:"url" validate:"required"` 5 | } 6 | 7 | type GetJSAgentConfigReq struct { 8 | URL string `json:"url" form:"url" validate:"required"` 9 | } 10 | -------------------------------------------------------------------------------- /requests/material_lib_tag.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | type CreateMaterialLibTagReq struct { 6 | Names []string `json:"names" form:"names" validate:"required"` 7 | } 8 | 9 | type QueryMaterialLibTagReq struct { 10 | Name string `json:"name" form:"name"` 11 | app.Sorter `form:"app_sorter"` 12 | app.Pager `form:"app_pager"` 13 | } 14 | -------------------------------------------------------------------------------- /requests/msg_arch.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | // QueryChatMsgReq 会话中的消息列表 9 | type QueryChatMsgReq struct { 10 | // 员工外部ID,from 11 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 12 | // 接收者id,员工或客户外部ID 13 | ReceiverID string `json:"receiver_id" form:"receiver_id" validate:"required"` 14 | // 消息类型 15 | MsgType string `json:"msg_type" form:"msg_type" validate:"omitempty"` 16 | // 起止时间 17 | SendAtStart constants.DateField `json:"send_at_start" form:"send_at_start" validate:"omitempty"` 18 | SendAtEnd constants.DateField `json:"send_at_end" form:"send_at_end" validate:"omitempty"` 19 | // 上下文上限的消息ID 20 | MaxID string `json:"max_id" form:"max_id" validate:"omitempty"` 21 | // 上下文下限的消息ID 22 | MinID string `json:"min_id" form:"min_id" validate:"omitempty"` 23 | // 上下文条数限制 24 | Limit int `json:"limit" form:"limit" validate:"omitempty"` 25 | app.Sorter 26 | app.Pager 27 | } 28 | 29 | // QuerySessionReq 查询会话列表 30 | type QuerySessionReq struct { 31 | // 员工外部ID 32 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 33 | // 类型 room-群聊 external-外部 internal-内部 34 | SessionType string `json:"session_type" form:"session_type" validate:"oneof=room external internal"` 35 | // 客户名 36 | Name string `json:"name" form:"name" validate:"omitempty"` 37 | app.Pager 38 | app.Sorter 39 | } 40 | 41 | type SearchMsgReq struct { 42 | Keyword string `json:"keyword" form:"keyword" validate:"required"` 43 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 44 | ExtPeerID string `json:"ext_peer_id" form:"ext_peer_id" validate:"required"` 45 | app.Pager 46 | } 47 | 48 | // ------------- req for inner srv -------------- 49 | 50 | type SyncReq struct { 51 | ExtCorpID string `json:"ext_corp_id" validate:"required"` 52 | Signature string `json:"signature" validate:"required"` 53 | } 54 | 55 | type InnerQuerySessionsReq struct { 56 | QuerySessionReq 57 | ExtCorpID string `json:"ext_corp_id" form:"ext_corp_id" validate:"required"` 58 | Signature string `json:"signature" form:"signature" validate:"required"` 59 | } 60 | 61 | // InnerQueryMsgsReq 查询会话内容 62 | type InnerQueryMsgsReq struct { 63 | QueryChatMsgReq 64 | ExtCorpID string `json:"ext_corp_id" form:"ext_corp_id" validate:"required"` 65 | Signature string `json:"signature" form:"signature" validate:"required"` 66 | } 67 | 68 | // InnerSearchMsgReq 搜索会话请求 69 | type InnerSearchMsgReq struct { 70 | SearchMsgReq 71 | ExtCorpID string `json:"ext_corp_id" form:"ext_corp_id" validate:"required"` 72 | Signature string `json:"signature" form:"signature" validate:"required"` 73 | } 74 | -------------------------------------------------------------------------------- /requests/query_welcome_msg.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | // QueryWelcomeMsgReq 欢迎语查询请求 6 | type QueryWelcomeMsgReq struct { 7 | // 欢迎语标题 8 | Name string `json:"name" form:"name" validate:"omitempty"` 9 | // 欢迎语可用员工 10 | ExtStaffIDs string `json:"ext_staff_ids" form:"ext_staff_ids" validate:"omitempty"` 11 | app.Pager 12 | app.Sorter 13 | } 14 | -------------------------------------------------------------------------------- /requests/quick_reply.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | // QueryQuickReplyReq 查询话术条目 6 | type QueryQuickReplyReq struct { 7 | // 话术组id 8 | GroupID string `form:"group_id" json:"group_id" validate:"omitempty,int64"` 9 | // 可用部门id 10 | DepartmentIDs []int64 `form:"department_ids" json:"department_ids" validate:"omitempty,gt=0"` 11 | // 关键词 12 | Keyword string `form:"keyword" json:"keyword" validate:"omitempty"` 13 | app.Pager 14 | app.Sorter 15 | } 16 | -------------------------------------------------------------------------------- /requests/quick_reply_group.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import "msg/common/app" 4 | 5 | // QueryQuickReplyGroupReq 查询话术分组 6 | // 企业话术无需查询条件 7 | type QueryQuickReplyGroupReq struct { 8 | app.Pager 9 | app.Sorter 10 | } 11 | -------------------------------------------------------------------------------- /requests/staff.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | "msg/constants" 6 | ) 7 | 8 | type QueryMainStaffInfoReq struct { 9 | ExtStaffID string `form:"ext_staff_id" json:"ext_staff_id" validate:"omitempty"` 10 | ExtDepartmentID string `json:"ext_department_id" form:"ext_department_id" validate:"omitempty"` 11 | app.Pager 12 | } 13 | 14 | type StaffCustomerCount struct { 15 | DecreaseUserCount int `json:"decrease_user_count"` 16 | IncreaseUserCount int `json:"increase_user_count"` 17 | TotalUserCount int `json:"total_user_count"` 18 | } 19 | 20 | type QueryStaffReq struct { 21 | // 企业微信部门id, 0-所有部门, 非0-制定部门 22 | ExtDepartmentID int64 `json:"ext_department_id" form:"ext_department_id" validate:"omitempty,gte=0"` 23 | // 员工名字 24 | Name string `json:"name" form:"name" validate:"omitempty,gt=0"` 25 | // RoleID 角色ID 26 | RoleID string `form:"role_id" json:"role_id" validate:"omitempty,int64"` 27 | // RoleType 角色类型 admin departmentAdmin staff superAdmin 28 | RoleType string `form:"role_type" json:"role_type" validate:"omitempty,oneof=admin departmentAdmin staff superAdmin"` 29 | // 开启会话存档 1-是 2-否 30 | EnableMsgArch constants.Boolean `json:"enable_msg_arch" form:"enable_msg_arch" validate:"omitempty"` 31 | app.Pager 32 | app.Sorter 33 | } 34 | 35 | type UpdateCustomerInternalTagsReq struct { 36 | ExtStaffID string `json:"ext_staff_id" form:"ext_staff_id" validate:"required"` 37 | ExtCustomerID string `json:"ext_customer_id" form:"ext_customer_id" validate:"required"` 38 | AddTags []string `json:"add_tags" form:"add_tags" validate:"omitempty,gt=0"` 39 | RemoveTags []string `json:"remove_tags" form:"remove_tags" validate:"omitempty,gt=0"` 40 | } 41 | 42 | // UpdateCustomerTagsReq 更新标签和批量打标签 43 | type UpdateCustomerTagsReq struct { 44 | ExtCustomerIDs []string `json:"ext_customer_ids" form:"ext_customer_ids" validate:"required"` 45 | AddExtTagIDs []string `json:"add_ext_tag_ids" form:"add_ext_tag_ids" validate:"omitempty,gt=0"` 46 | RemoveExtTagIDs []string `json:"remove_ext_tag_ids" form:"remove_ext_tag_ids" validate:"omitempty,gt=0"` 47 | } 48 | 49 | // EnableStaffs 批量启用、禁用员工 50 | type EnableStaffs struct { 51 | // 启用员工外部id 52 | ExtStaffIDs []string `json:"ext_staff_ids" form:"ext_staff_ids"` 53 | // 禁用员工外部id 54 | ExcludeExtStaffIDs []string `json:"exclude_ext_staff_ids" form:"exclude_ext_staff_ids"` 55 | } 56 | -------------------------------------------------------------------------------- /requests/storage.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type GetUploadURLReq struct { 4 | // 文件名 5 | Filename string `json:"file_name" validate:"required"` 6 | } 7 | 8 | // GetUploadURLResp 上传文件地址 9 | type GetUploadURLResp struct { 10 | // 上传地址 11 | UploadURL string `json:"upload_url"` 12 | // 下载地址 13 | DownloadURL string `json:"download_url"` 14 | } 15 | -------------------------------------------------------------------------------- /requests/tag.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "msg/common/app" 5 | ) 6 | 7 | type TagListReq struct { 8 | // 部门ID, 默认0,查询所有 9 | ExtDepartmentIDs []int64 `json:"ext_department_ids" form:"ext_department_ids" validate:"omitempty,dive,gte=0"` 10 | // 标签/标签组名 11 | Name string `json:"name" form:"name" validate:"omitempty"` 12 | app.Pager 13 | app.Sorter 14 | } 15 | 16 | type CreateTagReq struct { 17 | // 外部标签组id 18 | ExtTagGroupId string `json:"ext_tag_group_id" form:"ext_tag_group_id" validate:"required"` 19 | // 标签名 20 | Names []string `json:"names" form:"names" validate:"required,gt=0"` 21 | } 22 | 23 | type DeleteTagGroupsReq struct { 24 | // 外部组id列表 25 | ExtIDs []string `json:"ext_ids" form:"ext_ids" validate:"required,gt=0"` 26 | } 27 | 28 | type CreateTagGroupReq struct { 29 | Name string `json:"name" validate:"required"` 30 | DepartmentList []int64 `json:"department_list" validate:"omitempty"` 31 | Order uint32 `json:"order"` 32 | // 标签列表 33 | Tags []Tag `json:"tags" form:"tags" validate:"omitempty,gt=0"` 34 | } 35 | 36 | type UpdateTagGroupReq struct { 37 | // 标签组ext_id 38 | ExtID string `json:"ext_id"` 39 | // 标签组名 40 | Name string `json:"name" validate:"required"` 41 | // 排序权重 42 | Order uint32 `json:"order"` 43 | // 删除的标签id 44 | RemoveExtTagIDs []string `json:"remove_ext_tag_ids" form:"remove_ext_tag_ids" validate:"omitempty,gt=0"` 45 | // 标签列表 46 | Tags []Tag `json:"tags" form:"tags" validate:"omitempty,gt=0"` 47 | // 标签可用部门列表, 缺省所有部门可用 48 | ExtDepartmentList []int64 `json:"ext_department_list" form:"ext_department_list" validate:"omitempty,gt=0"` 49 | } 50 | 51 | type Tag struct { 52 | // 标签名 53 | Name string `json:"name" form:"name"` 54 | // 更新标签时使用,新建标签不用带 55 | ExtId string `json:"ext_id" form:"ext_id"` 56 | // 排序权重 57 | Order uint32 `json:"order"` 58 | } 59 | 60 | type ExchangeOrderReq struct { 61 | ID string `json:"id" form:"id" validate:"required,int64"` 62 | ExchangeOrderID string `json:"exchange_order_id" form:"exchange_order_id" validate:"required,int64"` 63 | } 64 | -------------------------------------------------------------------------------- /requests/upload_quick_reply_file.go: -------------------------------------------------------------------------------- 1 | package requests 2 | -------------------------------------------------------------------------------- /requests/util.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type ParseLinkReq struct { 4 | // 链接 5 | URL string `json:"url" form:"url" validate:"required,url"` 6 | } 7 | 8 | type UploadMediaReq struct { 9 | // 文件类型 10 | Type string `json:"type" form:"type" validate:"oneof=image voice video file"` 11 | // 文件链接 12 | URL string `json:"url" form:"url" validate:"required,url"` 13 | } 14 | -------------------------------------------------------------------------------- /responses/msg_arch.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import ( 4 | "msg/constants" 5 | "msg/models" 6 | "time" 7 | ) 8 | 9 | // ChatSessionItem 员工的会话列表条目 10 | type ChatSessionItem struct { 11 | Id int `json:"id"` 12 | ExtId string `json:"ext_id"` 13 | AgreeMsgaudit constants.Boolean `json:"agree_msgaudit"` 14 | // 同意存档的时间 15 | AgreeTime time.Time `json:"agree_time"` 16 | // 客户/员工 头像 17 | Avatar string `json:"avatar"` 18 | // 聊天状态 idle 19 | ChatStatus string `json:"chat_status"` 20 | // 0-未定义 1-男 2-女 3-未知 21 | Gender constants.UserGender `json:"gender"` 22 | GroupType string `json:"group_type"` 23 | //LastStartTime time.Time `json:"last_start_time"` 24 | //LastEndTime time.Time `json:"last_end_time"` 25 | // 最新对话内容 26 | LastMsgThumb string `json:"last_msg_thumb"` 27 | // 最新对话时间 28 | LastMsgTime time.Time `json:"last_msg_time"` 29 | Name string `json:"name"` 30 | // 群聊人员ID列表,extStaffID/extCustomerID 31 | RoomUserIDs string `json:"room_user_ids"` 32 | // external/inner/group 33 | SessionType string `json:"session_type"` 34 | // 内部会话时存在 35 | StaffExtId string `json:"staff_ext_id"` 36 | // 员工的企业信息 saas 可用 37 | //CorpFullName string `json:"corp_full_name"` 38 | //CorpId string `json:"corp_id"` 39 | //CorpName string `json:"corp_name"` 40 | //ExternalProfile models.ExternalProfile `json:"external_profile"` 41 | // todo 42 | //Position string `json:"position"` 43 | //Type interface{} `json:"type"` 44 | //Unionid interface{} `json:"unionid"` 45 | } 46 | 47 | // ChatMessage 会话中的消息列表条目 48 | type ChatMessage struct { 49 | models.ChatMsg 50 | ExtStaffAvatar string `json:"ext_staff_avatar"` 51 | ToUserAvatar string `json:"to_user_avatar"` 52 | ToUserName string `json:"to_user_name"` 53 | // external/inner/group 54 | SessionType string `json:"session_type"` 55 | } 56 | 57 | type InnerMsgArchServSessionsResp struct { 58 | Items []ChatSessionItem 59 | Total int64 `json:"total"` 60 | } 61 | 62 | type InnerMsgArchSerMsgResp struct { 63 | Items []models.ChatMessage `json:"items"` 64 | Total int64 `json:"total"` 65 | } 66 | --------------------------------------------------------------------------------