Files
junhong_cmp_fiber/internal/infrastructure/wecom/directory_client.go
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

290 lines
12 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
}
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)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComVisibleDepartments, ResourceType: constants.WeComApplicationResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{"application_id": applicationID},
RequestID: requestID, CorrelationID: correlationID, TriggerSeries: triggerSeries,
})
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)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: integrationID,
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: correlationID, TriggerSeries: triggerSeries,
})
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)