Files
YouduWiki/backend/pkg/dingtalk/dingtalk.go
2026-05-21 19:52:45 +08:00

352 lines
10 KiB
Go

package dingtalk
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dingtalkcard_1_0 "github.com/alibabacloud-go/dingtalk/card_1_0"
dingtalkoauth2_1_0 "github.com/alibabacloud-go/dingtalk/v2/oauth2_1_0"
"github.com/alibabacloud-go/tea/tea"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/store/cache"
)
const (
callbackPath = "/share/pro/v1/openapi/dingtalk/callback"
userInfoUrl = "https://api.dingtalk.com/v1.0/contact/users/me"
DepartmentListUrl = "https://oapi.dingtalk.com/department/list"
// https://open.dingtalk.com/document/isvapp/queries-the-complete-information-of-a-department-user
UserListUrl = "https://oapi.dingtalk.com/topapi/v2/user/list"
)
type Client struct {
ctx context.Context
logger *log.Logger
httpClient *http.Client
clientID string
clientSecret string
oauthClient *dingtalkoauth2_1_0.Client
cardClient *dingtalkcard_1_0.Client
dingTalkAuthURL string
cache *cache.Cache
}
// UserInfo 用于解析获取用户信息的接口返回
type UserInfo struct {
Nick string `json:"nick"`
UnionID string `json:"unionId"`
OpenID string `json:"openId"`
AvatarURL string `json:"avatarUrl"`
StateCode string `json:"stateCode"`
}
// DepartmentListRsp 用于解析组织信息接口返回
type DepartmentListRsp struct {
Errcode int `json:"errcode"`
Department []struct {
CreateDeptGroup bool `json:"createDeptGroup"`
Name string `json:"name"`
Id int `json:"id"`
AutoAddUser bool `json:"autoAddUser"`
Parentid int `json:"parentid,omitempty"`
} `json:"department"`
Errmsg string `json:"errmsg"`
}
type GetUserListResp struct {
Errcode int `json:"errcode"`
Result struct {
HasMore bool `json:"has_more"`
List []UserDetail `json:"list"`
} `json:"result"`
Errmsg string `json:"errmsg"`
}
type UserDetail struct {
Active bool `json:"active"`
Admin bool `json:"admin"`
Avatar string `json:"avatar"`
Boss bool `json:"boss"`
DeptIdList []int `json:"dept_id_list"`
DeptOrder int64 `json:"dept_order"`
Email string `json:"email"`
ExclusiveAccount bool `json:"exclusive_account"`
HideMobile bool `json:"hide_mobile"`
JobNumber string `json:"job_number"`
Leader bool `json:"leader"`
Mobile string `json:"mobile"`
Name string `json:"name"`
Remark string `json:"remark"`
StateCode string `json:"state_code"`
Telephone string `json:"telephone"`
Title string `json:"title"`
Unionid string `json:"unionid"`
Userid string `json:"userid"`
WorkPlace string `json:"work_place"`
}
func NewDingTalkClient(ctx context.Context, logger *log.Logger, clientId, clientSecret string, cache *cache.Cache) (*Client, error) {
config := &openapi.Config{}
config.Protocol = tea.String("https")
config.RegionId = tea.String("central")
oauthClient, err := dingtalkoauth2_1_0.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create oauth client: %w", err)
}
cardClient, err := dingtalkcard_1_0.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create card client: %w", err)
}
return &Client{
ctx: ctx,
logger: logger.WithModule("pkg.dingtalk"),
httpClient: &http.Client{},
clientID: clientId,
clientSecret: clientSecret,
oauthClient: oauthClient,
cardClient: cardClient,
dingTalkAuthURL: "https://login.dingtalk.com/oauth2/auth",
cache: cache,
}, nil
}
// GenerateAuthURL 生成钉钉授权URL
func (c *Client) GenerateAuthURL(baseUrl string, state string) string {
redirectURI, err := url.JoinPath(baseUrl, callbackPath)
if err != nil {
c.logger.Error("failed to join path", log.Error(err))
return ""
}
params := url.Values{}
params.Add("response_type", "code")
params.Add("client_id", c.clientID)
params.Add("redirect_uri", redirectURI)
params.Add("scope", "openid")
params.Add("state", state)
params.Add("prompt", "consent")
return fmt.Sprintf("%s?%s", c.dingTalkAuthURL, params.Encode())
}
func (c *Client) GetAccessTokenByCode(code string) (string, error) {
request := &dingtalkoauth2_1_0.GetUserTokenRequest{
ClientId: tea.String(c.clientID),
ClientSecret: tea.String(c.clientSecret),
Code: tea.String(code),
GrantType: tea.String("authorization_code"),
}
response, err := c.oauthClient.GetUserToken(request)
if err != nil {
return "", fmt.Errorf("failed to get user access token: %w", err)
}
accessToken := tea.StringValue(response.Body.AccessToken)
return accessToken, nil
}
func (c *Client) GetAccessToken() (string, error) {
ctx := context.Background()
cacheKey := fmt.Sprintf("dingtalk-access-token:%s", c.clientID)
cachedData, err := c.cache.Get(ctx, cacheKey).Result()
if err == nil && cachedData != "" {
return cachedData, nil
}
request := &dingtalkoauth2_1_0.GetAccessTokenRequest{
AppKey: tea.String(c.clientID),
AppSecret: tea.String(c.clientSecret),
}
response, tryErr := func() (_resp *dingtalkoauth2_1_0.GetAccessTokenResponse, _e error) {
defer func() {
if r := tea.Recover(recover()); r != nil {
_e = r
}
}()
_resp, _err := c.oauthClient.GetAccessToken(request)
if _err != nil {
return nil, _err
}
return _resp, nil
}()
if tryErr != nil {
return "", tryErr
}
accessToken := *response.Body.AccessToken
c.logger.Debug("get access token", log.String("access_token", accessToken), log.Int("expire_in", int(*response.Body.ExpireIn)))
if err := c.cache.Set(ctx, cacheKey, accessToken, time.Duration(*response.Body.ExpireIn-300)*time.Second).Err(); err != nil {
c.logger.Warn("failed to set cache", log.Error(err))
}
return accessToken, nil
}
func (c *Client) GetUserInfoByCode(code string) (*UserInfo, error) {
req, err := http.NewRequest("GET", userInfoUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to create GET request: %w", err)
}
accessToken, err := c.GetAccessTokenByCode(code)
if err != nil {
return nil, err
}
// Set request headers
req.Header.Set("x-acs-dingtalk-access-token", accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send GET request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DingTalk API returned non-200 status: %s, response: %s", resp.Status, string(body))
}
var userInfo UserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON response: %w", err)
}
return &userInfo, nil
}
func (c *Client) GetDepartmentList() (*DepartmentListRsp, error) {
accessToken, err := c.GetAccessToken()
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("access_token", accessToken)
requestURL := fmt.Sprintf("%s?%s", DepartmentListUrl, params.Encode())
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DingTalk API returned non-200 status: %s, response: %s", resp.Status, string(body))
}
c.logger.Debug("DepartmentListUrl:", log.String("body", string(body)))
var departmentListRsp DepartmentListRsp
if err := json.Unmarshal(body, &departmentListRsp); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON response: %w", err)
}
if departmentListRsp.Errcode != 0 {
return nil, fmt.Errorf("DingTalk API error: errcode=%d errmsg=%s", departmentListRsp.Errcode, departmentListRsp.Errmsg)
}
return &departmentListRsp, nil
}
func (c *Client) GetAllUserList(deptID int) ([]UserDetail, error) {
depth := 0
const maxDepth = 10
userList := make([]UserDetail, 0)
for depth < maxDepth {
resp, err := c.GetUserList(deptID)
if err != nil {
return nil, err
}
if len(resp.Result.List) > 0 {
userList = append(userList, resp.Result.List...)
}
if !resp.Result.HasMore {
break
}
depth++
}
return userList, nil
}
func (c *Client) GetUserList(deptID int) (*GetUserListResp, error) {
accessToken, err := c.GetAccessToken()
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("access_token", accessToken)
requestURL := fmt.Sprintf("%s?%s", UserListUrl, params.Encode())
bodyMap := map[string]interface{}{
"dept_id": deptID,
"size": 100,
"cursor": 0,
}
jsonData, err := json.Marshal(bodyMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DingTalk API returned non-200 status: %s, response: %s", resp.Status, string(body))
}
c.logger.Debug("GetUserList:", log.String("body", string(body)))
var getUserListResp GetUserListResp
if err := json.Unmarshal(body, &getUserListResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON response: %w", err)
}
if getUserListResp.Errcode != 0 {
return nil, fmt.Errorf("DingTalk GetUserList error: errcode=%d errcode=%s", getUserListResp.Errcode, getUserListResp.Errmsg)
}
return &getUserListResp, nil
}