package wecom import ( "context" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/bytedance/sonic" wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // DirectoryTokenProvider 定义通讯录客户端取得应用 access_token 的边界。 type DirectoryTokenProvider interface { GetAccessToken(ctx context.Context, applicationID uint) (string, error) } // DirectoryClient 拉取企业微信应用可见成员,不读取手机号或邮箱。 type DirectoryClient struct { tokens DirectoryTokenProvider integration TokenIntegrationLog httpClient *http.Client baseURL string now func() time.Time } // NewDirectoryClient 创建企业微信通讯录客户端。 func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *DirectoryClient { if timeout <= 0 { timeout = constants.WeComDefaultHTTPTimeout } return &DirectoryClient{ tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, } } // ListVisibleMembers 按应用当前可见部门拉取成员。 func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID uint) ([]wecomapp.DirectoryMember, error) { if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil || applicationID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") } token, err := c.tokens.GetAccessToken(ctx, applicationID) if err != nil { return nil, err } departments, err := c.listVisibleDepartments(ctx, applicationID, token) if err != nil { return nil, err } departmentIDs := visibleDepartmentRoots(departments) remoteMembers := make([]directoryMember, 0) for _, departmentID := range departmentIDs { members, err := c.listDepartmentMembers(ctx, applicationID, token, departmentID) if err != nil { return nil, err } remoteMembers = append(remoteMembers, members...) } return normalizeRemoteMembers(remoteMembers), nil } func (c *DirectoryClient) listVisibleDepartments(ctx context.Context, applicationID uint, token string) ([]directoryDepartment, error) { request, err := c.newDepartmentListRequest(ctx, token) if err != nil { return nil, err } resourceID := strconv.FormatUint(uint64(applicationID), 10) requestID := middleware.GetRequestIDFromContext(ctx) attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, Operation: constants.IntegrationOperationWeComVisibleDepartments, ResourceType: constants.WeComApplicationResourceType, ResourceID: &resourceID, RequestSummary: map[string]any{"application_id": applicationID}, RequestID: requestID, CorrelationID: requestID, }) if err != nil { return nil, err } startedAt := c.now() response, err := c.httpClient.Do(request) if err != nil { return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt) } defer response.Body.Close() var result departmentResponse if err := c.readResponse(response, &result); err != nil { return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { providerCode := strconv.FormatInt(result.ErrCode, 10) if result.ErrCode == 0 { providerCode = strconv.Itoa(response.StatusCode) } return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt) } if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result.ErrCode, result.ErrMsg, map[string]any{"errcode": result.ErrCode, "department_count": len(result.Departments)}, startedAt); err != nil { return nil, err } return result.Departments, nil } func (c *DirectoryClient) listDepartmentMembers(ctx context.Context, applicationID uint, token string, departmentID int64) ([]directoryMember, error) { request, err := c.newMemberListRequest(ctx, token, departmentID) if err != nil { return nil, err } resourceID := strconv.FormatUint(uint64(applicationID), 10) requestID := middleware.GetRequestIDFromContext(ctx) attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType, ResourceID: &resourceID, RequestSummary: map[string]any{ "application_id": applicationID, "department_id": departmentID, "fetch_child": true, }, RequestID: requestID, CorrelationID: requestID, }) if err != nil { return nil, err } startedAt := c.now() response, err := c.httpClient.Do(request) if err != nil { return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt) } defer response.Body.Close() var result directoryResponse if err := c.readResponse(response, &result); err != nil { return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { providerCode := strconv.FormatInt(result.ErrCode, 10) if result.ErrCode == 0 { providerCode = strconv.Itoa(response.StatusCode) } return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt) } if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result.ErrCode, result.ErrMsg, map[string]any{"errcode": result.ErrCode, "member_count": len(result.UserList)}, startedAt); err != nil { return nil, err } return result.UserList, nil } func (c *DirectoryClient) newDepartmentListRequest(ctx context.Context, token string) (*http.Request, error) { endpoint, err := url.Parse(c.baseURL + "/cgi-bin/department/list") if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") } query := endpoint.Query() query.Set("access_token", token) endpoint.RawQuery = query.Encode() request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信部门请求失败") } return request, nil } func (c *DirectoryClient) newMemberListRequest(ctx context.Context, token string, departmentID int64) (*http.Request, error) { endpoint, err := url.Parse(c.baseURL + "/cgi-bin/user/simplelist") if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") } query := endpoint.Query() query.Set("access_token", token) query.Set("department_id", strconv.FormatInt(departmentID, 10)) query.Set("fetch_child", "1") endpoint.RawQuery = query.Encode() request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信通讯录请求失败") } return request, nil } type directoryResponse struct { ErrCode int64 `json:"errcode"` ErrMsg string `json:"errmsg"` UserList []directoryMember `json:"userlist"` } type departmentResponse struct { ErrCode int64 `json:"errcode"` ErrMsg string `json:"errmsg"` Departments []directoryDepartment `json:"department"` } type directoryDepartment struct { ID int64 `json:"id"` ParentID int64 `json:"parentid"` } type directoryMember struct { UserID string `json:"userid"` Name string `json:"name"` Department []int64 `json:"department"` } func (c *DirectoryClient) readResponse(response *http.Response, result any) error { body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) if err != nil { return errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败") } if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大") } if err := sonic.Unmarshal(body, result); err != nil { return errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败") } return nil } func visibleDepartmentRoots(departments []directoryDepartment) []int64 { visible := make(map[int64]struct{}, len(departments)) for _, department := range departments { if department.ID > 0 { visible[department.ID] = struct{}{} } } result := make([]int64, 0, len(departments)) for _, department := range departments { if department.ID <= 0 { continue } if _, parentVisible := visible[department.ParentID]; department.ParentID <= 0 || department.ParentID == department.ID || !parentVisible { result = append(result, department.ID) } } return result } func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember { seen := make(map[string]struct{}, len(source)) result := make([]wecomapp.DirectoryMember, 0, len(source)) for _, member := range source { userID := strings.ToLower(strings.TrimSpace(member.UserID)) if userID == "" { continue } if _, exists := seen[userID]; exists { continue } seen[userID] = struct{}{} name := strings.TrimSpace(member.Name) if name == "" { name = userID } result = append(result, wecomapp.DirectoryMember{UserID: userID, Name: name, DepartmentIDs: member.Department}) } return result } func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, providerCode int64, providerMessage string, responseSummary map[string]any, startedAt time.Time) error { _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ Result: constants.IntegrationResultSuccess, HTTPStatus: status, ProviderCode: strconv.FormatInt(providerCode, 10), ProviderMessage: providerMessage, ResponseSummary: responseSummary, DurationMS: c.now().Sub(startedAt).Milliseconds(), }) return err } func (c *DirectoryClient) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error { if providerMessage == "" { providerMessage = "企业微信通讯录接口返回失败" } _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(), }) if err != nil { return err } return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录同步失败,请检查应用可见范围") } var _ TokenIntegrationLog = (*integrationlog.Repository)(nil)