├── .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 |
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 |
--------------------------------------------------------------------------------