auth_dingtalk_client.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. package handler
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "net/http"
  10. "net/url"
  11. "strings"
  12. "sync"
  13. "time"
  14. )
  15. // dingTalkClientConfig 是 DingTalkClient 需要的最小配置子集
  16. type dingTalkClientConfig struct {
  17. ClientID string
  18. ClientSecret string
  19. TokenURL string
  20. UserInfoURL string
  21. }
  22. type DingTalkClient struct {
  23. cfg dingTalkClientConfig
  24. appToken string
  25. appTokenExp time.Time // 钉钉 7200s,留 200s 余量 → 7000s
  26. mu sync.Mutex
  27. httpClient *http.Client
  28. // TODO(multi-instance): Redis 集中缓存 appToken
  29. }
  30. type DingTalkUserTokenResp struct {
  31. AccessToken string `json:"accessToken"`
  32. RefreshToken string `json:"refreshToken"`
  33. ExpireIn int64 `json:"expireIn"`
  34. CorpID string `json:"corpId"`
  35. }
  36. func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) {
  37. body := map[string]string{
  38. "clientId": c.cfg.ClientID,
  39. "clientSecret": c.cfg.ClientSecret,
  40. "code": code,
  41. "grantType": "authorization_code",
  42. }
  43. payload, _ := json.Marshal(body)
  44. req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload))
  45. if err != nil {
  46. return nil, err
  47. }
  48. req.Header.Set("Content-Type", "application/json")
  49. resp, err := c.httpClient.Do(req)
  50. if err != nil {
  51. return nil, err
  52. }
  53. defer func() { _ = resp.Body.Close() }()
  54. raw, _ := io.ReadAll(resp.Body)
  55. if resp.StatusCode != http.StatusOK {
  56. return nil, parseDingTalkErr(raw, resp.StatusCode)
  57. }
  58. var out DingTalkUserTokenResp
  59. if err := json.Unmarshal(raw, &out); err != nil {
  60. return nil, err
  61. }
  62. if strings.TrimSpace(out.AccessToken) == "" {
  63. return nil, parseDingTalkErr(raw, resp.StatusCode)
  64. }
  65. return &out, nil
  66. }
  67. type DingTalkAPIError struct {
  68. Code string
  69. Message string
  70. HTTP int
  71. }
  72. func (e *DingTalkAPIError) Error() string {
  73. return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP)
  74. }
  75. func parseDingTalkErr(raw []byte, status int) error {
  76. var v struct {
  77. Code string `json:"code"`
  78. Message string `json:"message"`
  79. ErrCode int `json:"errcode"`
  80. ErrMsg string `json:"errmsg"`
  81. }
  82. _ = json.Unmarshal(raw, &v)
  83. code := v.Code
  84. if code == "" && v.ErrCode != 0 {
  85. code = fmt.Sprintf("%d", v.ErrCode)
  86. }
  87. msg := v.Message
  88. if msg == "" {
  89. msg = v.ErrMsg
  90. }
  91. return &DingTalkAPIError{Code: code, Message: msg, HTTP: status}
  92. }
  93. // GetUnionIdByUserToken 调用 /v1.0/contact/users/me 返回 unionId 与用户自设昵称 nick。
  94. // nick 来自钉钉新版 OIDC 接口(用户在 App 个人资料填的昵称),与旧版 user/get.nickname 不同源。
  95. func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) {
  96. req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil)
  97. if err != nil {
  98. return "", "", err
  99. }
  100. req.Header.Set("x-acs-dingtalk-access-token", userToken)
  101. resp, err := c.httpClient.Do(req)
  102. if err != nil {
  103. return "", "", err
  104. }
  105. defer func() { _ = resp.Body.Close() }()
  106. raw, _ := io.ReadAll(resp.Body)
  107. if resp.StatusCode != http.StatusOK {
  108. return "", "", parseDingTalkErr(raw, resp.StatusCode)
  109. }
  110. var v struct {
  111. UnionID string `json:"unionId"`
  112. Nick string `json:"nick"`
  113. }
  114. if err := json.Unmarshal(raw, &v); err != nil {
  115. return "", "", err
  116. }
  117. if strings.TrimSpace(v.UnionID) == "" {
  118. return "", "", parseDingTalkErr(raw, resp.StatusCode)
  119. }
  120. return v.UnionID, v.Nick, nil
  121. }
  122. type DingTalkStaffInfo struct {
  123. UserID string
  124. Name string // 企业内真实姓名(钉钉企业管理后台配置)
  125. Nickname string // 钉钉个人昵称(用户自己设置)
  126. Email string
  127. DeptIDs []int64
  128. // CorpID 不来自 staff 接口,来自 userToken;不在此 struct
  129. }
  130. // dingTalkOAPIBase 推导钉钉旧版 OAPI base URL(host: api.dingtalk.com → oapi.dingtalk.com)。
  131. // getbyunionid 与 topapi/v2/user/get 仅在旧版 OAPI 提供,不在 v1.0 OpenAPI。
  132. func (c *DingTalkClient) dingTalkOAPIBase() string {
  133. u, err := url.Parse(c.cfg.UserInfoURL)
  134. if err != nil || u.Scheme == "" || u.Host == "" {
  135. return "https://oapi.dingtalk.com"
  136. }
  137. host := u.Host
  138. if strings.HasPrefix(host, "api.") {
  139. host = "oapi." + strings.TrimPrefix(host, "api.")
  140. }
  141. return u.Scheme + "://" + host
  142. }
  143. func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) {
  144. c.mu.Lock()
  145. defer c.mu.Unlock()
  146. if c.appToken != "" && time.Now().Before(c.appTokenExp) {
  147. return c.appToken, nil
  148. }
  149. body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret}
  150. payload, _ := json.Marshal(body)
  151. // 钉钉新版 v1.0 企业内部应用 access_token: POST /v1.0/oauth2/accessToken
  152. // 此 token 也可作为旧版 OAPI 的 access_token 使用(钉钉文档已说明)
  153. appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1)
  154. if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") {
  155. appTokenURL = c.cfg.TokenURL // fallback for test stub
  156. }
  157. req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload))
  158. if err != nil {
  159. return "", err
  160. }
  161. req.Header.Set("Content-Type", "application/json")
  162. resp, err := c.httpClient.Do(req)
  163. if err != nil {
  164. return "", err
  165. }
  166. defer func() { _ = resp.Body.Close() }()
  167. raw, _ := io.ReadAll(resp.Body)
  168. if resp.StatusCode != http.StatusOK {
  169. return "", parseDingTalkErr(raw, resp.StatusCode)
  170. }
  171. var v struct {
  172. AccessToken string `json:"accessToken"`
  173. ExpireIn int64 `json:"expireIn"`
  174. }
  175. if err := json.Unmarshal(raw, &v); err != nil {
  176. return "", err
  177. }
  178. if v.AccessToken == "" {
  179. return "", parseDingTalkErr(raw, resp.StatusCode)
  180. }
  181. c.appToken = v.AccessToken
  182. ttl := v.ExpireIn
  183. if ttl > 200 {
  184. ttl -= 200
  185. }
  186. c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second)
  187. return c.appToken, nil
  188. }
  189. func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) {
  190. appToken, err := c.GetAppToken(ctx)
  191. if err != nil {
  192. return "", err
  193. }
  194. body := map[string]string{"unionid": unionID}
  195. payload, _ := json.Marshal(body)
  196. // 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=XXX
  197. // access_token 通过 query string 传递(不是 header)
  198. var targetURL string
  199. if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
  200. targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken)
  201. } else {
  202. targetURL = c.cfg.UserInfoURL // fallback for test stub
  203. }
  204. req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
  205. if err != nil {
  206. return "", err
  207. }
  208. req.Header.Set("Content-Type", "application/json")
  209. resp, err := c.httpClient.Do(req)
  210. if err != nil {
  211. return "", err
  212. }
  213. defer func() { _ = resp.Body.Close() }()
  214. raw, _ := io.ReadAll(resp.Body)
  215. if resp.StatusCode != http.StatusOK {
  216. return "", parseDingTalkErr(raw, resp.StatusCode)
  217. }
  218. var v struct {
  219. Result struct {
  220. UserID string `json:"userid"`
  221. } `json:"result"`
  222. ErrCode int `json:"errcode"`
  223. ErrMsg string `json:"errmsg"`
  224. }
  225. if err := json.Unmarshal(raw, &v); err != nil {
  226. return "", err
  227. }
  228. if v.ErrCode != 0 {
  229. return "", parseDingTalkErr(raw, resp.StatusCode)
  230. }
  231. if strings.TrimSpace(v.Result.UserID) == "" {
  232. return "", parseDingTalkErr(raw, resp.StatusCode)
  233. }
  234. return v.Result.UserID, nil
  235. }
  236. // DingTalkDeptInfo 部门信息(topapi/v2/department/get 返回子集)
  237. type DingTalkDeptInfo struct {
  238. DeptID int64
  239. Name string
  240. ParentID int64
  241. }
  242. // GetDeptInfo 查询单个部门信息(用于递归拼部门路径)。
  243. // 调用钉钉旧版 OAPI: POST /topapi/v2/department/get?access_token=XXX
  244. func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) {
  245. appToken, err := c.GetAppToken(ctx)
  246. if err != nil {
  247. return nil, err
  248. }
  249. body := map[string]any{"dept_id": deptID, "language": "zh_CN"}
  250. payload, _ := json.Marshal(body)
  251. var targetURL string
  252. if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
  253. targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken)
  254. } else {
  255. targetURL = c.cfg.UserInfoURL // test stub fallback
  256. }
  257. req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
  258. if err != nil {
  259. return nil, err
  260. }
  261. req.Header.Set("Content-Type", "application/json")
  262. resp, err := c.httpClient.Do(req)
  263. if err != nil {
  264. return nil, err
  265. }
  266. defer func() { _ = resp.Body.Close() }()
  267. raw, _ := io.ReadAll(resp.Body)
  268. if resp.StatusCode != http.StatusOK {
  269. return nil, parseDingTalkErr(raw, resp.StatusCode)
  270. }
  271. var v struct {
  272. Result struct {
  273. DeptID int64 `json:"dept_id"`
  274. Name string `json:"name"`
  275. ParentID int64 `json:"parent_id"`
  276. } `json:"result"`
  277. ErrCode int `json:"errcode"`
  278. ErrMsg string `json:"errmsg"`
  279. }
  280. if err := json.Unmarshal(raw, &v); err != nil {
  281. return nil, err
  282. }
  283. if v.ErrCode != 0 {
  284. return nil, parseDingTalkErr(raw, resp.StatusCode)
  285. }
  286. return &DingTalkDeptInfo{
  287. DeptID: v.Result.DeptID,
  288. Name: v.Result.Name,
  289. ParentID: v.Result.ParentID,
  290. }, nil
  291. }
  292. func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) {
  293. appToken, err := c.GetAppToken(ctx)
  294. if err != nil {
  295. return nil, err
  296. }
  297. body := map[string]string{"userid": userID}
  298. payload, _ := json.Marshal(body)
  299. // 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/v2/user/get?access_token=XXX
  300. var targetURL string
  301. if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
  302. targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken)
  303. } else {
  304. targetURL = c.cfg.UserInfoURL // fallback for test stub
  305. }
  306. req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
  307. if err != nil {
  308. return nil, err
  309. }
  310. req.Header.Set("Content-Type", "application/json")
  311. resp, err := c.httpClient.Do(req)
  312. if err != nil {
  313. return nil, err
  314. }
  315. defer func() { _ = resp.Body.Close() }()
  316. raw, _ := io.ReadAll(resp.Body)
  317. if resp.StatusCode != http.StatusOK {
  318. return nil, parseDingTalkErr(raw, resp.StatusCode)
  319. }
  320. var v struct {
  321. Result struct {
  322. UserID string `json:"userid"`
  323. Name string `json:"name"`
  324. Nickname string `json:"nickname"`
  325. Email string `json:"email"`
  326. OrgEmail string `json:"org_email"`
  327. Extension string `json:"extension"`
  328. DeptID []int64 `json:"dept_id_list"`
  329. } `json:"result"`
  330. ErrCode int `json:"errcode"`
  331. ErrMsg string `json:"errmsg"`
  332. }
  333. if err := json.Unmarshal(raw, &v); err != nil {
  334. return nil, err
  335. }
  336. if v.ErrCode != 0 {
  337. return nil, parseDingTalkErr(raw, resp.StatusCode)
  338. }
  339. if strings.TrimSpace(v.Result.UserID) == "" {
  340. return nil, parseDingTalkErr(raw, resp.StatusCode)
  341. }
  342. // 邮箱三级 fallback:org_email > email > extension["企业邮箱"](钉钉自定义扩展字段,JSON string)
  343. email := strings.TrimSpace(v.Result.OrgEmail)
  344. emailSource := "org_email"
  345. if email == "" {
  346. email = strings.TrimSpace(v.Result.Email)
  347. emailSource = "email"
  348. }
  349. extensionParsed := false
  350. if email == "" && strings.TrimSpace(v.Result.Extension) != "" {
  351. var ext map[string]string
  352. if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil {
  353. extensionParsed = true
  354. if v, ok := ext["企业邮箱"]; ok {
  355. email = strings.TrimSpace(v)
  356. emailSource = "extension.企业邮箱"
  357. }
  358. }
  359. }
  360. if email == "" {
  361. emailSource = "none"
  362. }
  363. slog.Info("dingtalk staff fetched",
  364. "userid", v.Result.UserID,
  365. "name_present", v.Result.Name != "",
  366. "nickname_present", v.Result.Nickname != "",
  367. "name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname,
  368. "email_present", v.Result.Email != "",
  369. "org_email_present", v.Result.OrgEmail != "",
  370. "extension_present", v.Result.Extension != "",
  371. "extension_parsed", extensionParsed,
  372. "email_source", emailSource,
  373. "dept_count", len(v.Result.DeptID),
  374. )
  375. return &DingTalkStaffInfo{
  376. UserID: v.Result.UserID,
  377. Name: v.Result.Name,
  378. Nickname: v.Result.Nickname,
  379. Email: email,
  380. DeptIDs: v.Result.DeptID,
  381. }, nil
  382. }