| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079 |
- package handler
- import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "net/url"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- // dingTalkUpstreamRedirect 在 4 步链上游调用失败时记录详细错误日志并跳错误页。
- // 把钉钉 errcode/errmsg 写进 backend log + URL fragment,避免被泛 "internal error" 吞掉。
- func dingTalkUpstreamRedirect(c *gin.Context, frontendCallback, step string, err error) {
- var apiErr *DingTalkAPIError
- dtCode := ""
- dtMsg := ""
- dtHTTP := 0
- if errors.As(err, &apiErr) {
- dtCode = apiErr.Code
- dtMsg = apiErr.Message
- dtHTTP = apiErr.HTTP
- }
- slog.Error("dingtalk upstream call failed",
- "step", step,
- "dingtalk_code", dtCode,
- "dingtalk_msg", dtMsg,
- "http_status", dtHTTP,
- "error", err.Error(),
- )
- msg := dtMsg
- if strings.TrimSpace(msg) == "" {
- msg = infraerrors.Message(err)
- }
- if strings.TrimSpace(dtCode) != "" {
- msg = "dingtalk[" + dtCode + "] " + msg
- }
- redirectOAuthError(c, frontendCallback, mapDingTalkErrorCode(err), msg, "")
- }
- // ─── 常量 ──────────────────────────────────────────────────────────────────
- const (
- dingTalkOAuthCookiePath = "/api/v1/auth/oauth/dingtalk"
- dingTalkOAuthStateCookieName = "dingtalk_oauth_state"
- dingTalkOAuthRedirectCookie = "dingtalk_oauth_redirect"
- dingTalkOAuthIntentCookieName = "dingtalk_oauth_intent"
- dingTalkOAuthBindUserCookieName = "dingtalk_oauth_bind_user"
- dingTalkOAuthCookieMaxAgeSec = 600 // 10 分钟
- dingTalkOAuthDefaultRedirectTo = "/dashboard"
- dingTalkOAuthDefaultFrontendCB = "/auth/dingtalk/callback"
- dingTalkLevelThreeEnabled = true
- )
- // ─── Config helper ─────────────────────────────────────────────────────────
- // getDingTalkOAuthConfig 返回 DingTalk OAuth 最终生效配置。
- // 优先从 settingSvc(settings 表)读取,回退到 h.cfg.DingTalk。
- func (h *AuthHandler) getDingTalkOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
- if h != nil && h.settingSvc != nil {
- return h.settingSvc.GetDingTalkConnectOAuthConfig(ctx)
- }
- if h == nil || h.cfg == nil {
- return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
- }
- if !h.cfg.DingTalk.Enabled {
- return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
- }
- return h.cfg.DingTalk, nil
- }
- // ─── Cookie helpers(使用 dingtalk path)─────────────────────────────────
- func setDingTalkCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: name,
- Value: value,
- Path: dingTalkOAuthCookiePath,
- MaxAge: maxAgeSec,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: name,
- Value: "",
- Path: dingTalkOAuthCookiePath,
- MaxAge: -1,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- // ─── DingTalkOAuthStart ────────────────────────────────────────────────────
- // DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
- // GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
- func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
- if !h.requireActionCaptchaForOAuthLoginStart(c) {
- return
- }
- cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
- if err != nil {
- frontendCB := dingTalkOAuthDefaultFrontendCB
- redirectOAuthError(c, frontendCB, "dingtalk_not_enabled", "", "")
- return
- }
- state, err := oauth.GenerateState()
- if err != nil {
- response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
- return
- }
- redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
- if redirectTo == "" {
- redirectTo = dingTalkOAuthDefaultRedirectTo
- }
- browserSessionKey, err := generateOAuthPendingBrowserSession()
- if err != nil {
- response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
- return
- }
- secureCookie := isRequestHTTPS(c)
- setDingTalkCookie(c, dingTalkOAuthStateCookieName, encodeCookieValue(state), dingTalkOAuthCookieMaxAgeSec, secureCookie)
- setDingTalkCookie(c, dingTalkOAuthRedirectCookie, encodeCookieValue(redirectTo), dingTalkOAuthCookieMaxAgeSec, secureCookie)
- intent := normalizeOAuthIntent(c.Query("intent"))
- setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
- captureOAuthPromoCode(c, secureCookie)
- setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
- clearOAuthPendingSessionCookie(c, secureCookie)
- if intent == oauthIntentBindCurrentUser {
- bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- setDingTalkCookie(c, dingTalkOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), dingTalkOAuthCookieMaxAgeSec, secureCookie)
- } else {
- clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
- }
- authURL, err := buildDingTalkAuthorizeURL(cfg, state)
- if err != nil {
- response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build dingtalk authorization url").WithCause(err))
- return
- }
- respondOAuthStart(c, authURL)
- }
- // ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
- // ─── findDingTalkCompatEmailUser ───────────────────────────────────────────
- // findDingTalkCompatEmailUser 通过真实邮箱查找可与 DingTalk 账号兼容绑定的现有用户。
- func (h *AuthHandler) findDingTalkCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
- if !dingTalkLevelThreeEnabled {
- return nil, nil
- }
- client := h.entClient()
- if client == nil {
- return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- email = strings.TrimSpace(strings.ToLower(email))
- if email == "" ||
- strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) ||
- strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
- strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
- strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
- return nil, nil
- }
- userEntities, err := client.User.Query().
- Where(userNormalizedEmailPredicate(email)).
- Order(dbent.Asc(dbuser.FieldID)).
- All(ctx)
- if err != nil {
- return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
- }
- switch len(userEntities) {
- case 0:
- return nil, nil
- case 1:
- return userEntities[0], nil
- default:
- return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
- }
- }
- // ─── createDingTalkOAuthChoicePendingSession ───────────────────────────────
- // createDingTalkOAuthChoicePendingSession 创建 DingTalk OAuth 三方注册/绑定的 choice pending session。
- // signupBlocked=true 时关闭"创建新账户"出口;若同时没有 compat email 匹配的已有账户,
- // 直接把 step 切到 bind_login_required,避免前端展示一个没有实际可点选项的 choice 界面。
- func (h *AuthHandler) createDingTalkOAuthChoicePendingSession(
- c *gin.Context,
- identity service.PendingAuthIdentityKey,
- suggestedEmail string,
- resolvedEmail string,
- redirectTo string,
- browserSessionKey string,
- upstreamClaims map[string]any,
- compatEmail string,
- compatEmailUser *dbent.User,
- forceEmailOnSignup bool,
- signupBlocked bool,
- ) error {
- suggestionEmail := strings.TrimSpace(suggestedEmail)
- canonicalEmail := strings.TrimSpace(resolvedEmail)
- if suggestionEmail == "" {
- suggestionEmail = canonicalEmail
- }
- completionResponse := map[string]any{
- "step": oauthPendingChoiceStep,
- "adoption_required": true,
- "redirect": strings.TrimSpace(redirectTo),
- "email": suggestionEmail,
- "resolved_email": canonicalEmail,
- "existing_account_email": "",
- "existing_account_bindable": false,
- "create_account_allowed": !signupBlocked,
- "force_email_on_signup": forceEmailOnSignup,
- "choice_reason": "third_party_signup",
- }
- if strings.TrimSpace(compatEmail) != "" {
- completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
- }
- resolvedChoiceEmail := suggestionEmail
- if compatEmailUser != nil {
- completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
- completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
- completionResponse["existing_account_bindable"] = true
- completionResponse["choice_reason"] = "compat_email_match"
- resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
- }
- if forceEmailOnSignup && compatEmailUser == nil {
- completionResponse["choice_reason"] = "force_email_on_signup"
- }
- // 注册被拦:无论是否匹配到 compat email user,都跳过 choice,直接进 bind_login。
- // "开放注册" 关闭 且 "钉钉企业模式豁免" 也关闭时,唯一合法出口是绑定已有账户,
- // 不应该让用户看到"创建新账户"按钮;compat user 命中只是让 bind_login 的邮箱字段预填得更准。
- if signupBlocked {
- completionResponse["step"] = "bind_login_required"
- completionResponse["existing_account_bindable"] = true
- completionResponse["choice_reason"] = "signup_blocked_redirect_to_bind"
- }
- var targetUserID *int64
- if compatEmailUser != nil && compatEmailUser.ID > 0 {
- targetUserID = &compatEmailUser.ID
- }
- return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin,
- Identity: identity,
- TargetUserID: targetUserID,
- ResolvedEmail: resolvedChoiceEmail,
- RedirectTo: redirectTo,
- BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: completionResponse,
- })
- }
- // ─── DingTalkOAuthCallback ─────────────────────────────────────────────────
- // DingTalkOAuthCallback 处理钉钉授权回调。
- // GET /api/v1/auth/oauth/dingtalk/callback?code=...&state=...
- func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
- cfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context())
- if cfgErr != nil {
- response.ErrorFrom(c, cfgErr)
- return
- }
- frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
- if frontendCallback == "" {
- frontendCallback = dingTalkOAuthDefaultFrontendCB
- }
- if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
- redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
- return
- }
- code := strings.TrimSpace(c.Query("code"))
- state := strings.TrimSpace(c.Query("state"))
- if code == "" || state == "" {
- redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
- return
- }
- secureCookie := isRequestHTTPS(c)
- defer func() {
- clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
- clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
- clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
- clearOAuthPromoCodeCookie(c, secureCookie)
- }()
- expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
- if err != nil || state != expectedState {
- redirectOAuthError(c, frontendCallback, "csrf", "state mismatch", "")
- return
- }
- redirectTo, _ := readCookieDecoded(c, dingTalkOAuthRedirectCookie)
- intent, _ := readCookieDecoded(c, dingTalkOAuthIntentCookieName)
- intent = normalizeOAuthIntent(intent)
- browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
- if strings.TrimSpace(browserSessionKey) == "" {
- redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing browser session cookie", "")
- return
- }
- forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
- // ─── 4 步链(Step 1 + Step 2 必须;Step 3/4 按需 + 跨组织降级)───
- client := h.dingTalkClient(cfg)
- userToken, err := client.ExchangeCodeForUserToken(c.Request.Context(), code)
- if err != nil {
- dingTalkUpstreamRedirect(c, frontendCallback, "exchange_code", err)
- return
- }
- // D: corp 校验提前到 Step 1 之后、Step 2 之前,减少不必要的上游调用
- corpID := strings.TrimSpace(userToken.CorpID)
- if !checkDingTalkCorpAllowed(cfg, corpID) {
- // 不在 URL 中透传 corpID,避免内部企业标识泄露给前端
- redirectOAuthError(c, frontendCallback, "corp_rejected", "", "")
- return
- }
- // Step 2: 必须 — UnionID 是全局唯一,作为 subject + 合成邮箱种子;nick 是用户在 App 自设的昵称
- unionID, oauthNick, err := client.GetUnionIdByUserToken(c.Request.Context(), userToken.AccessToken)
- if err != nil {
- dingTalkUpstreamRedirect(c, frontendCallback, "get_union_id", err)
- return
- }
- identityKey := service.PendingAuthIdentityKey{ProviderType: "dingtalk", ProviderKey: "dingtalk", ProviderSubject: unionID}
- // Step 3/4 调用策略由 policy 决定,与 require_email 解耦。
- // policy=internal_only → 必须成功(hard fail),因为 AppType=internal 已保证用户在应用企业。
- // policy=none / "" → 尝试,失败降级(公网场景跨组织用户属正常预期)。
- // require_email 只影响 Step 3/4 结果后的邮箱处理路径,不影响是否调用。
- var staff *DingTalkStaffInfo
- switch cfg.CorpRestrictionPolicy {
- case "internal_only":
- // AppType=internal 已保证用户在应用企业,Step 3/4 必须成功。
- // 失败 = 钉钉 OAPI 故障或应用配置错误,应 hard fail。
- upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
- if errStep3 != nil {
- dingTalkUpstreamRedirect(c, frontendCallback, "get_user_id", errStep3)
- return
- }
- staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
- if errStep4 != nil {
- dingTalkUpstreamRedirect(c, frontendCallback, "get_staff_info", errStep4)
- return
- }
- staff = staffInfo
- default: // "none" or ""
- // 公网登录,跨组织用户 Step 3/4 可能失败(设计预期),尝试调用,失败降级。
- // 即使 require_email=false 也尝试拿 name(用于 upstreamClaims.username),失败就空着。
- upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
- if errStep3 != nil {
- slog.Debug("dingtalk step3 fallback (none/cross-org)",
- "corp_id", corpID, "union_id", unionID, "err", errStep3.Error())
- staff = &DingTalkStaffInfo{}
- break
- }
- staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
- if errStep4 != nil {
- slog.Debug("dingtalk step4 fallback (none/cross-org)",
- "corp_id", corpID, "union_id", unionID, "err", errStep4.Error())
- staff = &DingTalkStaffInfo{}
- break
- }
- staff = staffInfo
- }
- // nick 来自 OIDC /contact/users/me,优先作为钉钉昵称(user/get.nickname 多数为空)。
- if staff != nil && strings.TrimSpace(oauthNick) != "" {
- staff.Nickname = strings.TrimSpace(oauthNick)
- }
- upstreamClaims := buildDingTalkUpstreamClaims(staff, unionID, corpID)
- // ─── S1 主动绑定分支(PR-3 才走到这里)───
- if intent == oauthIntentBindCurrentUser {
- targetUserID, err := h.readOAuthBindUserIDFromCookie(c, dingTalkOAuthBindUserCookieName)
- if err != nil {
- redirectOAuthError(c, frontendCallback, "invalid_state", "invalid bind user cookie", "")
- return
- }
- // policy=none 跨组织用户绑定时 staff.Email="",用合成邮箱占位(用于 audit log,不用于注册)
- bindResolvedEmail := staff.Email
- if bindResolvedEmail == "" {
- bindResolvedEmail = buildDingTalkSyntheticEmail(unionID)
- }
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentBindCurrentUser, Identity: identityKey,
- TargetUserID: &targetUserID, ResolvedEmail: bindResolvedEmail,
- RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: map[string]any{"redirect": redirectTo},
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- // ─── Level 1:auth_identities hit ───
- if existing, _ := h.findOAuthIdentityUser(c.Request.Context(), identityKey); existing != nil {
- // 身份同步:已登录用户,直接同步(user_id 已知)。
- // 异步执行避免上游钉钉接口(GetStaffInfoByUserId / 部门递归)阻塞登录跳转。
- runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
- h.syncDingTalkIdentity(ctx, cfg, client, existing.ID, staff, false)
- })
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: &existing.ID,
- ResolvedEmail: existing.Email, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: map[string]any{"redirect": redirectTo},
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- signupBlocked := h.isDingTalkSignupBlocked(c.Request.Context(), cfg)
- // ─── 非命中:require_email=false 走 synthetic email 直接登录 ───
- if !cfg.RequireEmail {
- if signupBlocked {
- // 注册被拦 + 无邮箱可输:唯一出路是绑定已有账户
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
- ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: dingTalkBindLoginCompletionResponse(redirectTo),
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- syntheticEmail := buildDingTalkSyntheticEmail(unionID)
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
- ResolvedEmail: syntheticEmail, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: map[string]any{"redirect": redirectTo, "synthetic_email": syntheticEmail},
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- // ─── require_email=true 且 staff.Email 空 → 补邮箱(默认)或直接 bind_login(注册被拦时) ───
- if staff.Email == "" {
- completionResponse := map[string]any{
- "step": "email_completion",
- "requires_email_completion": true,
- "redirect": redirectTo,
- }
- if signupBlocked {
- // 注册被全局关闭且未豁免:跳过补邮箱页,直接进 bind_login 让用户输入已有账户
- completionResponse = dingTalkBindLoginCompletionResponse(redirectTo)
- }
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
- ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: completionResponse,
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- // ─── L3/L4 有邮箱:统一 choice pending session ───
- var compatEmailUser *dbent.User
- if dingTalkLevelThreeEnabled && staff.Email != "" {
- compatEmailUser, _ = h.findDingTalkCompatEmailUser(c.Request.Context(), staff.Email)
- }
- if err := h.createDingTalkOAuthChoicePendingSession(
- c, identityKey, staff.Email, staff.Email,
- redirectTo, browserSessionKey, upstreamClaims,
- staff.Email, compatEmailUser, forceEmailOnSignup,
- signupBlocked,
- ); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- }
- func buildDingTalkSyntheticEmail(userID string) string {
- return "dingtalk-" + strings.ToLower(strings.TrimSpace(userID)) + service.DingTalkConnectSyntheticEmailDomain
- }
- // isDingTalkSignupBlocked 当注册总开关关闭且未开启钉钉企业模式豁免
- // (policy=internal_only + dingtalk_connect_bypass_registration=true)时返回 true。
- // 镜像 service.AuthService.canBypassRegistrationDisabledForOAuth 用于 OAuth callback
- // 早期路由决策:注册被拦 → 跳过补邮箱页直接进 bind_login,避免用户填完表单才报错。
- func (h *AuthHandler) isDingTalkSignupBlocked(ctx context.Context, cfg config.DingTalkConnectConfig) bool {
- if h.settingSvc == nil {
- return false
- }
- if h.settingSvc.IsRegistrationEnabled(ctx) {
- return false
- }
- if cfg.BypassRegistration && cfg.CorpRestrictionPolicy == "internal_only" {
- return false
- }
- return true
- }
- func dingTalkBindLoginCompletionResponse(redirectTo string) map[string]any {
- return map[string]any{
- "step": "bind_login_required",
- "existing_account_bindable": true,
- "create_account_allowed": false,
- "redirect": redirectTo,
- }
- }
- func buildDingTalkUpstreamClaims(staff *DingTalkStaffInfo, unionID, corpID string) map[string]any {
- primaryDeptID := int64(0)
- if len(staff.DeptIDs) > 0 {
- primaryDeptID = staff.DeptIDs[0]
- }
- return map[string]any{
- "email": staff.Email,
- "username": staff.Name,
- "nickname": staff.Nickname,
- "subject": unionID, // 与 identityKey.ProviderSubject 保持一致(全局唯一 unionID)
- "corp_user_id": staff.UserID, // 企业 userid(跨组织时为空),保留作独立字段用于 audit
- "union_id": unionID,
- "corp_id": corpID,
- "primary_dept_id": primaryDeptID, // 首个部门 ID,用于 internal_only 同步路径
- }
- }
- func checkDingTalkCorpAllowed(cfg config.DingTalkConnectConfig, corpID string) bool {
- switch cfg.CorpRestrictionPolicy {
- case "internal_only":
- // 方案 A:完全跳过 corpID 字段校验,由 step 3 `GetUserIdByUnionId` 做真实判定。
- // 原因:钉钉 /v1.0/oauth2/userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)
- // 不会返回 corpId 字段。而 step 3 用本企业 appToken 查 unionId→userId 映射,
- // 跨企业用户会被钉钉拒绝(错误码 60011/60121),mapDingTalkErrorCode 已将其映射回 "corp_rejected"。
- // AppType=internal 已由 ValidateDingTalkConfig 强制保证应用属性。
- return true
- case "none", "":
- return true
- default:
- return false
- }
- }
- // decideDingTalkStep34Strategy 根据 policy 和 Step 3/4 运行时错误决定处理方式。
- // 返回 (proceed bool, fatal bool):
- // - proceed=true:继续处理(step 成功或降级)
- // - fatal=true:应 hard fail(upstream_error)
- //
- // 此 helper 从主链中提取,便于 unit test 独立验证策略决策逻辑。
- func decideDingTalkStep34Strategy(policy string, stepErr error) (shouldFallback bool, isFatal bool) {
- if stepErr == nil {
- return false, false // 成功,不需要降级
- }
- switch policy {
- case "internal_only":
- return false, true // hard fail:同企业 Step 3/4 必须成功
- case "none", "":
- return true, false // 降级:公网场景跨组织用户失败属正常预期
- default:
- return false, true // 未知 policy,视为 hard fail
- }
- }
- // mapDingTalkErrorCode 把 DingTalkAPIError 映射到 redirectOAuthError 用的字符串 code
- func mapDingTalkErrorCode(err error) string {
- var apiErr *DingTalkAPIError
- if !errors.As(err, &apiErr) {
- return "upstream_error"
- }
- switch apiErr.Code {
- case "60011", "60121":
- return "corp_rejected"
- case "40014", "50015", "88":
- return "upstream_error"
- default:
- return "upstream_error"
- }
- }
- // dingTalkClient 构造或返回缓存的 client 实例(h-level 单例)。
- // 若 cfg 关键字段(ClientID/ClientSecret/TokenURL/UserInfoURL)与已缓存实例不一致,
- // 则丢弃旧实例(含 appToken 缓存)并重建,避免管理员改配置后旧凭据持续生效。
- func (h *AuthHandler) dingTalkClient(cfg config.DingTalkConnectConfig) *DingTalkClient {
- h.dingTalkClientMu.Lock()
- defer h.dingTalkClientMu.Unlock()
- newCfg := dingTalkClientConfig{
- ClientID: cfg.ClientID,
- ClientSecret: cfg.ClientSecret,
- TokenURL: cfg.TokenURL,
- UserInfoURL: cfg.UserInfoURL,
- }
- if h.dingTalkClientInstance == nil || h.dingTalkClientInstance.cfg != newCfg {
- h.dingTalkClientInstance = &DingTalkClient{
- cfg: newCfg,
- // 与 wechat OAuth client 对齐,避免上游网络抖动时请求悬挂。
- httpClient: &http.Client{Timeout: 10 * time.Second},
- }
- }
- return h.dingTalkClientInstance
- }
- // ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
- // buildDingTalkAuthorizeURL 根据配置和 state 构建钉钉 OAuth 授权 URL。
- func buildDingTalkAuthorizeURL(cfg config.DingTalkConnectConfig, state string) (string, error) {
- base := strings.TrimSpace(cfg.AuthorizeURL)
- if base == "" {
- return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_EMPTY", "dingtalk authorize_url not configured")
- }
- redirectURI := strings.TrimSpace(cfg.RedirectURL)
- if redirectURI == "" {
- return "", infraerrors.InternalServer("DINGTALK_REDIRECT_URL_EMPTY", "dingtalk redirect_url not configured")
- }
- u, err := url.Parse(base)
- if err != nil {
- return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_PARSE_FAILED", "failed to parse dingtalk authorize_url").WithCause(err)
- }
- scopes := strings.TrimSpace(cfg.Scopes)
- if scopes == "" {
- scopes = "openid"
- }
- q := u.Query()
- q.Set("client_id", cfg.ClientID)
- q.Set("redirect_uri", redirectURI)
- q.Set("response_type", "code")
- q.Set("scope", scopes)
- q.Set("state", state)
- q.Set("prompt", "consent")
- u.RawQuery = q.Encode()
- return u.String(), nil
- }
- // ─── Complete Registration ─────────────────────────────────────────────────
- type completeDingTalkOAuthRequest struct {
- InvitationCode string `json:"invitation_code" binding:"required"`
- AffCode string `json:"aff_code,omitempty"`
- AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
- AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
- }
- // CompleteDingTalkOAuthRegistration completes a pending OAuth registration by validating
- // the invitation code and creating the user account.
- // POST /api/v1/auth/oauth/dingtalk/complete-registration
- func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
- var req completeDingTalkOAuthRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
- return
- }
- secureCookie := isRequestHTTPS(c)
- sessionToken, err := readOAuthPendingSessionCookie(c)
- if err != nil {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
- return
- }
- browserSessionKey, err := readOAuthPendingBrowserCookie(c)
- if err != nil {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
- return
- }
- pendingSvc, err := h.pendingIdentityService()
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
- if err != nil {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- response.ErrorFrom(c, err)
- return
- }
- if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
- response.ErrorFrom(c, err)
- return
- } else if handled {
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
- return
- } else {
- session = updatedSession
- }
- if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- email := strings.TrimSpace(session.ResolvedEmail)
- username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
- // E: username 空时退到 email local part(跨组织用户没拿到 staff.Name 也能注册)
- if username == "" {
- if at := strings.Index(email, "@"); at > 0 {
- username = email[:at]
- } else {
- username = email
- }
- }
- if email == "" || username == "" {
- response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
- return
- }
- client := h.entClient()
- if client == nil {
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
- return
- }
- if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
- AdoptDisplayName: req.AdoptDisplayName,
- AdoptAvatar: req.AdoptAvatar,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
- c.Request.Context(),
- email,
- username,
- req.InvitationCode,
- req.AffCode,
- pendingOAuthPromoCode(session),
- "dingtalk",
- )
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- // 新用户注册完成后执行身份同步(user_id 现在已知)。
- // 异步执行避免阻塞 token 响应。
- if completionCfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context()); cfgErr == nil {
- dtClient := h.dingTalkClient(completionCfg)
- claims := session.UpstreamIdentityClaims
- runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
- h.syncDingTalkIdentityFromClaims(ctx, completionCfg, dtClient, user.ID, claims, true)
- })
- }
- h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
- }
- // CreateDingTalkOAuthAccount creates a new user account from a pending DingTalk OAuth session.
- // POST /api/v1/auth/oauth/dingtalk/create-account
- func (h *AuthHandler) CreateDingTalkOAuthAccount(c *gin.Context) {
- h.createPendingOAuthAccount(c, "dingtalk")
- }
- // BindDingTalkOAuthLogin 处理已有账户绑定钉钉 OAuth 登录。
- // POST /api/v1/auth/oauth/dingtalk/bind-login
- func (h *AuthHandler) BindDingTalkOAuthLogin(c *gin.Context) {
- h.bindPendingOAuthLogin(c, "dingtalk")
- }
- // ─── DingTalk 身份同步 ─────────────────────────────────────────────────────
- // runDingTalkSyncAsync 在后台 goroutine 执行钉钉身份同步,避免阻塞登录响应。
- // 与请求 ctx 解耦(handler 返回后会被取消),但保留其 values(trace/request id)。
- // 固定 30s 超时上限,防止 goroutine 因上游卡顿无限挂起。
- func runDingTalkSyncAsync(parent context.Context, fn func(ctx context.Context)) {
- base := context.WithoutCancel(parent)
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("dingtalk sync: panic recovered", "panic", r)
- }
- }()
- ctx, cancel := context.WithTimeout(base, 30*time.Second)
- defer cancel()
- fn(ctx)
- }()
- }
- // syncDingTalkIdentity 在 internal_only 模式下,按三个 sync 开关把钉钉身份信息
- // 同步到用户属性表(以及 users.username)。
- // 任何错误仅记日志,不中断登录流程(最终一致性)。
- func (h *AuthHandler) syncDingTalkIdentity(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, staff *DingTalkStaffInfo, syncUsername bool) {
- slog.Info("dingtalk sync: entry",
- "user_id", userID,
- "policy", cfg.CorpRestrictionPolicy,
- "sync_corp_email", cfg.SyncCorpEmail,
- "sync_display_name", cfg.SyncDisplayName,
- "sync_dept", cfg.SyncDept,
- "sync_username", syncUsername,
- "attr_key_email", cfg.SyncCorpEmailAttrKey,
- "attr_key_name", cfg.SyncDisplayNameAttrKey,
- "attr_key_dept", cfg.SyncDeptAttrKey,
- "staff_nil", staff == nil,
- )
- if cfg.CorpRestrictionPolicy != "internal_only" || staff == nil {
- slog.Info("dingtalk sync: skip, not internal_only or staff nil")
- return
- }
- slog.Info("dingtalk sync: staff snapshot",
- "name", staff.Name, "email", staff.Email, "dept_ids", staff.DeptIDs,
- )
- if !cfg.SyncCorpEmail && !cfg.SyncDisplayName && !cfg.SyncDept {
- slog.Info("dingtalk sync: skip, all flags disabled")
- return
- }
- if h.userAttributeService == nil {
- slog.Warn("dingtalk sync: userAttributeService not available, skipping")
- return
- }
- // 仅首次注册时覆盖 users.username(避免每次登录覆盖用户后续手动改过的名字)。
- // dingtalk_name 属性下面单独每次写入企业 name,不受此条件影响。
- if syncUsername && cfg.SyncDisplayName {
- username := strings.TrimSpace(staff.Nickname)
- source := "nickname"
- if username == "" {
- username = strings.TrimSpace(staff.Name)
- source = "name(fallback)"
- }
- if username != "" && h.userService != nil {
- if _, err := h.userService.UpdateProfile(ctx, userID, service.UpdateProfileRequest{Username: &username}); err != nil {
- slog.Warn("dingtalk sync: failed to update username", "user_id", userID, "err", err)
- } else {
- slog.Info("dingtalk sync: username updated (register)", "user_id", userID, "username", username, "source", source)
- }
- }
- }
- // 属性同步(目标 attr key 从 cfg 读取,默认值由 GetDingTalkConnectOAuthConfig 保证非空)
- type syncField struct {
- key string
- value string
- }
- var fields []syncField
- if cfg.SyncDisplayName && strings.TrimSpace(staff.Name) != "" {
- fields = append(fields, syncField{cfg.SyncDisplayNameAttrKey, strings.TrimSpace(staff.Name)})
- }
- if cfg.SyncCorpEmail && strings.TrimSpace(staff.Email) != "" {
- fields = append(fields, syncField{cfg.SyncCorpEmailAttrKey, strings.TrimSpace(staff.Email)})
- }
- if cfg.SyncDept && len(staff.DeptIDs) > 0 {
- // 跳过根部门 ID=1,找第一个真实子部门;都是根则保留 1(最终写入空字符串覆盖旧值)。
- primaryDeptID := int64(0)
- for _, id := range staff.DeptIDs {
- if id > 1 {
- primaryDeptID = id
- break
- }
- }
- if primaryDeptID == 0 {
- primaryDeptID = staff.DeptIDs[0]
- }
- slog.Info("dingtalk sync: pick primary dept", "user_id", userID, "all_dept_ids", staff.DeptIDs, "primary", primaryDeptID)
- path, err := h.resolveDingTalkDeptPath(ctx, client, primaryDeptID)
- if err != nil {
- slog.Warn("dingtalk sync: failed to resolve dept path", "user_id", userID, "dept_id", primaryDeptID, "err", err)
- } else {
- // path="" 表示公司直属(仅在根部门下),仍写入空串覆盖旧值。
- fields = append(fields, syncField{cfg.SyncDeptAttrKey, path})
- }
- }
- if len(fields) == 0 {
- return
- }
- // 逐 key 查 definition 并 upsert
- for _, f := range fields {
- if err := h.setUserAttributeByKey(ctx, userID, f.key, f.value); err != nil {
- slog.Warn("dingtalk sync: failed to set attribute", "user_id", userID, "key", f.key, "err", err)
- }
- }
- }
- // syncDingTalkIdentityFromClaims 从 upstreamClaims 恢复 DingTalkStaffInfo 并调用 syncDingTalkIdentity。
- // 用于 pending session 完成阶段(complete-registration / create-account / bind-login)。
- // syncUsername=true 表示首次注册场景,需要把 nickname 写入 users.username。
- func (h *AuthHandler) syncDingTalkIdentityFromClaims(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, claims map[string]any, syncUsername bool) {
- staff := dingTalkStaffFromClaims(claims)
- h.syncDingTalkIdentity(ctx, cfg, client, userID, staff, syncUsername)
- }
- // maybeSyncDingTalkAfterRegistration 在通用 OAuth 注册路径完成后调用。
- // 同步 4 个字段:users.username(首次) + dingtalk_name/email/department(每次)。
- func (h *AuthHandler) maybeSyncDingTalkAfterRegistration(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
- h.dispatchDingTalkPendingSync(ctx, session, userID, true)
- }
- // maybeSyncDingTalkAfterLogin 在通用 OAuth 登录/绑定路径完成后调用。
- // 仅刷新 3 个属性(dingtalk_name/email/department),不动 users.username。
- func (h *AuthHandler) maybeSyncDingTalkAfterLogin(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
- h.dispatchDingTalkPendingSync(ctx, session, userID, false)
- }
- func (h *AuthHandler) dispatchDingTalkPendingSync(ctx context.Context, session *dbent.PendingAuthSession, userID int64, syncUsername bool) {
- if session == nil || userID <= 0 {
- return
- }
- if !strings.EqualFold(strings.TrimSpace(session.ProviderType), "dingtalk") {
- return
- }
- cfg, err := h.getDingTalkOAuthConfig(ctx)
- if err != nil {
- slog.Debug("dingtalk sync: skip post-login sync, config unavailable", "user_id", userID, "err", err.Error())
- return
- }
- client := h.dingTalkClient(cfg)
- claims := session.UpstreamIdentityClaims
- // 异步执行避免阻塞 token 响应。
- runDingTalkSyncAsync(ctx, func(asyncCtx context.Context) {
- h.syncDingTalkIdentityFromClaims(asyncCtx, cfg, client, userID, claims, syncUsername)
- })
- }
- // dingTalkStaffFromClaims 从 upstreamClaims 重建最小 DingTalkStaffInfo。
- func dingTalkStaffFromClaims(claims map[string]any) *DingTalkStaffInfo {
- if claims == nil {
- return &DingTalkStaffInfo{}
- }
- staff := &DingTalkStaffInfo{}
- if v, ok := claims["username"].(string); ok {
- staff.Name = v
- }
- if v, ok := claims["nickname"].(string); ok {
- staff.Nickname = v
- }
- if v, ok := claims["email"].(string); ok {
- staff.Email = v
- }
- if v, ok := claims["corp_user_id"].(string); ok {
- staff.UserID = v
- }
- // primary_dept_id 存为 int64 或 float64(JSON round-trip)
- switch v := claims["primary_dept_id"].(type) {
- case int64:
- if v > 0 {
- staff.DeptIDs = []int64{v}
- }
- case float64:
- if id := int64(v); id > 0 {
- staff.DeptIDs = []int64{id}
- }
- }
- return staff
- }
- // setUserAttributeByKey 按 attribute key 查找 definition,再 upsert 用户属性值。
- // definition 不存在时记 warn 日志跳过(admin 在 settings 保存时已按需 upsert
- // 对应 def;缺失意味着 admin 改了 attr key 但未保存 settings,或 def 被手工删除)。
- func (h *AuthHandler) setUserAttributeByKey(ctx context.Context, userID int64, key, value string) error {
- def, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
- if err != nil {
- slog.Warn("dingtalk sync: attribute definition not found, skipping", "key", key, "err", err.Error())
- return nil
- }
- if err := h.userAttributeService.UpdateUserAttributes(ctx, userID, []service.UpdateUserAttributeInput{
- {AttributeID: def.ID, Value: value},
- }); err != nil {
- return err
- }
- slog.Info("dingtalk sync: attribute upserted", "user_id", userID, "key", key, "attr_id", def.ID)
- return nil
- }
- // resolveDingTalkDeptPath 从叶部门递归向上拼 "公司/部门/子部门" 路径字符串。
- // 遇 dept_id=1(根)或 parent_id=0 停止。加 visited set 防循环,最多 50 层。
- func (h *AuthHandler) resolveDingTalkDeptPath(ctx context.Context, client *DingTalkClient, deptID int64) (string, error) {
- slog.Info("dingtalk sync: resolve dept path start", "dept_id", deptID)
- const maxDepth = 50
- visited := make(map[int64]bool, maxDepth)
- var parts []string
- current := deptID
- for i := 0; i < maxDepth; i++ {
- if current < 1 || visited[current] {
- break
- }
- visited[current] = true
- info, err := client.GetDeptInfo(ctx, current)
- if err != nil {
- return "", fmt.Errorf("get dept info %d: %w", current, err)
- }
- if strings.TrimSpace(info.Name) != "" {
- parts = append([]string{strings.TrimSpace(info.Name)}, parts...)
- }
- // 钉钉根部门 dept_id=1,ParentID 通常为 0;遇到 0 / self 终止避免循环。
- if info.ParentID < 1 || info.ParentID == current {
- break
- }
- current = info.ParentID
- }
- // 去除根组织名(parts[0] 始终是企业全称),仅保留部门层级。
- // 例:["公司","A","B"] → "A/B";["公司"] → ""(公司直属)。
- if len(parts) > 0 {
- parts = parts[1:]
- }
- return strings.Join(parts, "/"), nil
- }
|