All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
186 lines
7.3 KiB
Go
186 lines
7.3 KiB
Go
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
|
|
}
|
|
request, err := c.newListRequest(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.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType,
|
|
ResourceID: &resourceID, RequestSummary: map[string]any{
|
|
"application_id": applicationID, "department_id": constants.WeComRootDepartmentID, "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()
|
|
result, err := c.readResponse(response)
|
|
if 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)
|
|
}
|
|
members := normalizeRemoteMembers(result.UserList)
|
|
if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result, len(members), startedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*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(constants.WeComRootDepartmentID, 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 directoryMember struct {
|
|
UserID string `json:"userid"`
|
|
Name string `json:"name"`
|
|
Department []int64 `json:"department"`
|
|
}
|
|
|
|
func (c *DirectoryClient) readResponse(response *http.Response) (directoryResponse, error) {
|
|
var result directoryResponse
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1))
|
|
if err != nil {
|
|
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败")
|
|
}
|
|
if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
|
|
return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大")
|
|
}
|
|
if err := sonic.Unmarshal(body, &result); err != nil {
|
|
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
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, result directoryResponse, memberCount int, startedAt time.Time) error {
|
|
_, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{
|
|
Result: constants.IntegrationResultSuccess, HTTPStatus: status,
|
|
ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
|
|
ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount},
|
|
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)
|