Files
junhong_cmp_fiber/internal/infrastructure/wecom/template_client.go
break 73f5125d3d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
2026-07-25 17:06:58 +08:00

241 lines
8.7 KiB
Go

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, "企业微信审批模板无效或不可访问")
}