package wecom import ( "bytes" "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" ) // TemplateClient 读取企微后台已有审批模板的控件结构。 type TemplateClient struct { tokens DirectoryTokenProvider integration TokenIntegrationLog httpClient *http.Client baseURL string now func() time.Time } // NewTemplateClient 创建企业微信审批模板详情客户端。 func NewTemplateClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *TemplateClient { if timeout <= 0 { timeout = constants.WeComDefaultHTTPTimeout } return &TemplateClient{ tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, } } // GetTemplateDetail 获取模板详情并只投影控件结构,不保存审批节点和审批人规则。 func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (wecomapp.TemplateDefinition, error) { if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil { return wecomapp.TemplateDefinition{}, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置") } token, err := c.tokens.GetAccessToken(ctx, applicationID) if err != nil { return wecomapp.TemplateDefinition{}, err } request, err := c.newRequest(ctx, token, templateID) if err != nil { return wecomapp.TemplateDefinition{}, 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.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType, ResourceID: &resourceID, RequestSummary: map[string]any{ "application_id": applicationID, "template_id": templateID, }, RequestID: requestID, CorrelationID: requestID, }) if err != nil { return wecomapp.TemplateDefinition{}, err } startedAt := c.now() response, err := c.httpClient.Do(request) if err != nil { return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信模板详情请求失败", startedAt) } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信模板详情响应无效", startedAt) } definition, errCode, errMsg, err := parseTemplateDefinition(templateID, body) if err != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 { providerCode := strconv.FormatInt(errCode, 10) if errCode == 0 { providerCode = strconv.Itoa(response.StatusCode) } return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, providerCode, errMsg, startedAt) } _, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg, ResponseSummary: map[string]any{"errcode": errCode, "control_count": len(definition.Controls)}, DurationMS: c.now().Sub(startedAt).Milliseconds(), }) if err != nil { return wecomapp.TemplateDefinition{}, err } return definition, nil } func (c *TemplateClient) newRequest(ctx context.Context, token, templateID string) (*http.Request, error) { endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/gettemplatedetail") 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() body, err := sonic.Marshal(map[string]string{"template_id": templateID}) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板详情请求失败") } request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信模板详情请求失败") } request.Header.Set("Content-Type", "application/json") return request, nil } func parseTemplateDefinition(templateID string, body []byte) (wecomapp.TemplateDefinition, int64, string, error) { var payload map[string]any if err := sonic.Unmarshal(body, &payload); err != nil { return wecomapp.TemplateDefinition{}, 0, "", err } errCode := numberToInt64(payload["errcode"]) errMsg, _ := payload["errmsg"].(string) if errCode != 0 { return wecomapp.TemplateDefinition{}, errCode, errMsg, nil } content, ok := payload["template_content"].(map[string]any) if !ok { return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeServiceUnavailable, "企业微信模板详情缺少模板内容") } controls := make([]wecomapp.TemplateControl, 0) seen := make(map[string]struct{}) collectTemplateControls(content["controls"], &controls, seen) if len(controls) == 0 { return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeInvalidParam, "企业微信模板没有可映射控件") } name := localizedText(content["template_name"]) if name == "" { name = localizedText(content["template_names"]) } return wecomapp.TemplateDefinition{ TemplateID: templateID, Name: name, Controls: controls, }, errCode, errMsg, nil } func collectTemplateControls(value any, result *[]wecomapp.TemplateControl, seen map[string]struct{}) { switch typed := value.(type) { case []any: for _, item := range typed { collectTemplateControls(item, result, seen) } case map[string]any: if property, ok := typed["property"].(map[string]any); ok { id, _ := property["id"].(string) controlType, _ := property["control"].(string) if id != "" && controlType != "" { if _, exists := seen[id]; !exists { seen[id] = struct{}{} *result = append(*result, wecomapp.TemplateControl{ ID: id, Type: controlType, Title: localizedText(property["title"]), Required: numberToInt64(property["require"]) == 1, OptionKeys: collectOptionKeys(typed), }) } } } for _, item := range typed { collectTemplateControls(item, result, seen) } } } func collectOptionKeys(value any) []string { keys := make([]string, 0) seen := make(map[string]struct{}) var walk func(any) walk = func(current any) { switch typed := current.(type) { case []any: for _, item := range typed { walk(item) } case map[string]any: if key, ok := typed["key"].(string); ok && key != "" { if _, exists := seen[key]; !exists { seen[key] = struct{}{} keys = append(keys, key) } } for _, item := range typed { walk(item) } } } walk(value) return keys } func localizedText(value any) string { if text, ok := value.(string); ok { return text } if items, ok := value.([]any); ok { for _, item := range items { if translated, ok := item.(map[string]any); ok { if text, ok := translated["text"].(string); ok && text != "" { return text } } } } return "" } func numberToInt64(value any) int64 { switch number := value.(type) { case float64: return int64(number) case int64: return number case int: return int64(number) default: return 0 } } func (c *TemplateClient) completeTemplateFailure(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.CodeInvalidParam, "企业微信审批模板无效或不可访问") }