su2a / backend /internal /handler /auth_dingtalk_client.go
cjovs's picture
PG local + bucket backup/restore, DB: su2adb
126ef31 verified
Raw
History Blame Contribute Delete
12.3 kB
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// dingTalkClientConfig 是 DingTalkClient 需要的最小配置子集
type dingTalkClientConfig struct {
ClientID string
ClientSecret string
TokenURL string
UserInfoURL string
}
type DingTalkClient struct {
cfg dingTalkClientConfig
appToken string
appTokenExp time.Time // 钉钉 7200s,留 200s 余量 → 7000s
mu sync.Mutex
httpClient *http.Client
// TODO(multi-instance): Redis 集中缓存 appToken
}
type DingTalkUserTokenResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpireIn int64 `json:"expireIn"`
CorpID string `json:"corpId"`
}
func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) {
body := map[string]string{
"clientId": c.cfg.ClientID,
"clientSecret": c.cfg.ClientSecret,
"code": code,
"grantType": "authorization_code",
}
payload, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var out DingTalkUserTokenResp
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if strings.TrimSpace(out.AccessToken) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &out, nil
}
type DingTalkAPIError struct {
Code string
Message string
HTTP int
}
func (e *DingTalkAPIError) Error() string {
return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP)
}
func parseDingTalkErr(raw []byte, status int) error {
var v struct {
Code string `json:"code"`
Message string `json:"message"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(raw, &v)
code := v.Code
if code == "" && v.ErrCode != 0 {
code = fmt.Sprintf("%d", v.ErrCode)
}
msg := v.Message
if msg == "" {
msg = v.ErrMsg
}
return &DingTalkAPIError{Code: code, Message: msg, HTTP: status}
}
// GetUnionIdByUserToken 调用 /v1.0/contact/users/me 返回 unionId 与用户自设昵称 nick。
// nick 来自钉钉新版 OIDC 接口(用户在 App 个人资料填的昵称),与旧版 user/get.nickname 不同源。
func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("x-acs-dingtalk-access-token", userToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
UnionID string `json:"unionId"`
Nick string `json:"nick"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", "", err
}
if strings.TrimSpace(v.UnionID) == "" {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.UnionID, v.Nick, nil
}
type DingTalkStaffInfo struct {
UserID string
Name string // 企业内真实姓名(钉钉企业管理后台配置)
Nickname string // 钉钉个人昵称(用户自己设置)
Email string
DeptIDs []int64
// CorpID 不来自 staff 接口,来自 userToken;不在此 struct
}
// dingTalkOAPIBase 推导钉钉旧版 OAPI base URL(host: api.dingtalk.com → oapi.dingtalk.com)。
// getbyunionid 与 topapi/v2/user/get 仅在旧版 OAPI 提供,不在 v1.0 OpenAPI。
func (c *DingTalkClient) dingTalkOAPIBase() string {
u, err := url.Parse(c.cfg.UserInfoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "https://oapi.dingtalk.com"
}
host := u.Host
if strings.HasPrefix(host, "api.") {
host = "oapi." + strings.TrimPrefix(host, "api.")
}
return u.Scheme + "://" + host
}
func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.appToken != "" && time.Now().Before(c.appTokenExp) {
return c.appToken, nil
}
body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret}
payload, _ := json.Marshal(body)
// 钉钉新版 v1.0 企业内部应用 access_token: POST /v1.0/oauth2/accessToken
// 此 token 也可作为旧版 OAPI 的 access_token 使用(钉钉文档已说明)
appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1)
if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") {
appTokenURL = c.cfg.TokenURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.AccessToken == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
c.appToken = v.AccessToken
ttl := v.ExpireIn
if ttl > 200 {
ttl -= 200
}
c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second)
return c.appToken, nil
}
func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return "", err
}
body := map[string]string{"unionid": unionID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=XXX
// access_token 通过 query string 传递(不是 header)
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.ErrCode != 0 {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.Result.UserID, nil
}
// DingTalkDeptInfo 部门信息(topapi/v2/department/get 返回子集)
type DingTalkDeptInfo struct {
DeptID int64
Name string
ParentID int64
}
// GetDeptInfo 查询单个部门信息(用于递归拼部门路径)。
// 调用钉钉旧版 OAPI: POST /topapi/v2/department/get?access_token=XXX
func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]any{"dept_id": deptID, "language": "zh_CN"}
payload, _ := json.Marshal(body)
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // test stub fallback
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
DeptID int64 `json:"dept_id"`
Name string `json:"name"`
ParentID int64 `json:"parent_id"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &DingTalkDeptInfo{
DeptID: v.Result.DeptID,
Name: v.Result.Name,
ParentID: v.Result.ParentID,
}, nil
}
func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]string{"userid": userID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/v2/user/get?access_token=XXX
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
Name string `json:"name"`
Nickname string `json:"nickname"`
Email string `json:"email"`
OrgEmail string `json:"org_email"`
Extension string `json:"extension"`
DeptID []int64 `json:"dept_id_list"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
// 邮箱三级 fallback:org_email > email > extension["企业邮箱"](钉钉自定义扩展字段,JSON string)
email := strings.TrimSpace(v.Result.OrgEmail)
emailSource := "org_email"
if email == "" {
email = strings.TrimSpace(v.Result.Email)
emailSource = "email"
}
extensionParsed := false
if email == "" && strings.TrimSpace(v.Result.Extension) != "" {
var ext map[string]string
if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil {
extensionParsed = true
if v, ok := ext["企业邮箱"]; ok {
email = strings.TrimSpace(v)
emailSource = "extension.企业邮箱"
}
}
}
if email == "" {
emailSource = "none"
}
slog.Info("dingtalk staff fetched",
"userid", v.Result.UserID,
"name_present", v.Result.Name != "",
"nickname_present", v.Result.Nickname != "",
"name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname,
"email_present", v.Result.Email != "",
"org_email_present", v.Result.OrgEmail != "",
"extension_present", v.Result.Extension != "",
"extension_parsed", extensionParsed,
"email_source", emailSource,
"dept_count", len(v.Result.DeptID),
)
return &DingTalkStaffInfo{
UserID: v.Result.UserID,
Name: v.Result.Name,
Nickname: v.Result.Nickname,
Email: email,
DeptIDs: v.Result.DeptID,
}, nil
}