修复企微的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m15s

This commit is contained in:
2026-07-27 17:04:32 +08:00
parent cbf909b878
commit 072ec1db7e
15 changed files with 511 additions and 229 deletions

View File

@@ -62,6 +62,34 @@ func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepositor
return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now}
}
// InspectTemplate 实时读取企微模板详情,供管理员配置控件映射。
func (s *SceneService) InspectTemplate(ctx context.Context, applicationID uint, request dto.InspectWeComTemplateRequest) (*dto.WeComTemplateDetailResponse, error) {
if s == nil || s.provider == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden)
}
templateID := strings.TrimSpace(request.TemplateID)
if applicationID == 0 || templateID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
definition, err := s.provider.GetTemplateDetail(ctx, applicationID, templateID)
if err != nil {
return nil, err
}
controls := make([]dto.WeComTemplateControlResponse, 0, len(definition.Controls))
for _, control := range definition.Controls {
controls = append(controls, dto.WeComTemplateControlResponse{
ID: control.ID, Type: control.Type, Title: control.Title,
Required: control.Required, OptionKeys: control.OptionKeys,
})
}
return &dto.WeComTemplateDetailResponse{
TemplateID: definition.TemplateID, Name: definition.Name, Controls: controls,
}, nil
}
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {

View File

@@ -156,6 +156,30 @@ func (h *WeComHandler) ListMembers(c *fiber.Ctx) error {
return response.Success(c, result)
}
// InspectTemplate 实时读取企业微信审批模板控件。
// POST /api/admin/wecom/applications/:id/templates/inspect
func (h *WeComHandler) InspectTemplate(c *fiber.Ctx) error {
if h == nil || h.scenes == nil || h.validator == nil {
return errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效")
}
var request dto.InspectWeComTemplateRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.scenes.InspectTemplate(c.UserContext(), uint(id), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// SaveScene 保存并校验稳定业务类型的企微模板控件映射。
// PUT /api/admin/wecom/scenes/:business_type
func (h *WeComHandler) SaveScene(c *fiber.Ctx) error {

View File

@@ -43,7 +43,7 @@ func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrat
}
}
// ListVisibleMembers 拉取根部门及其子部门中当前应用可见的成员。
// 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, "企业微信通讯录服务未配置")
@@ -52,7 +52,64 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID
if err != nil {
return nil, err
}
request, err := c.newListRequest(ctx, token)
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
}
@@ -62,7 +119,7 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID
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,
"application_id": applicationID, "department_id": departmentID, "fetch_child": true,
}, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
@@ -74,8 +131,8 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID
return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt)
}
defer response.Body.Close()
result, err := c.readResponse(response)
if err != nil {
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 {
@@ -85,21 +142,36 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID
}
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 {
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 members, nil
return result.UserList, nil
}
func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*http.Request, error) {
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(constants.WeComRootDepartmentID, 10))
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)
@@ -115,25 +187,54 @@ type directoryResponse struct {
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) (directoryResponse, error) {
var result directoryResponse
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 result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败")
return errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败")
}
if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes {
return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大")
return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大")
}
if err := sonic.Unmarshal(body, &result); err != nil {
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败")
if err := sonic.Unmarshal(body, result); err != nil {
return errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败")
}
return result, nil
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 {
@@ -157,12 +258,11 @@ func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember
return result
}
func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, result directoryResponse, memberCount int, startedAt time.Time) error {
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(result.ErrCode, 10), ProviderMessage: result.ErrMsg,
ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
ProviderCode: strconv.FormatInt(providerCode, 10), ProviderMessage: providerMessage,
ResponseSummary: responseSummary, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return err
}

View File

@@ -10,6 +10,33 @@ type WeComControlMappingItem struct {
OptionMapping map[string]string `json:"option_mapping" description:"业务枚举值到企微选择项 key 的映射;非选择控件传空对象"`
}
// InspectWeComTemplateRequest 检查企业微信审批模板请求。
type InspectWeComTemplateRequest struct {
TemplateID string `json:"template_id" validate:"required,min=1,max=128" description:"企微后台已创建模板 ID"`
}
// InspectWeComTemplateParams 检查企业微信审批模板路径与请求参数。
type InspectWeComTemplateParams struct {
IDReq
InspectWeComTemplateRequest
}
// WeComTemplateControlResponse 企业微信审批模板控件响应。
type WeComTemplateControlResponse struct {
ID string `json:"id" description:"企微模板控件 ID"`
Type string `json:"type" description:"企微模板控件类型"`
Title string `json:"title" description:"企微模板控件标题"`
Required bool `json:"required" description:"是否必填"`
OptionKeys []string `json:"option_keys" description:"选择控件可用选项 key"`
}
// WeComTemplateDetailResponse 企业微信审批模板详情响应。
type WeComTemplateDetailResponse struct {
TemplateID string `json:"template_id" description:"企微模板 ID"`
Name string `json:"name" description:"企微模板名称"`
Controls []WeComTemplateControlResponse `json:"controls" description:"可用于业务字段映射的模板控件"`
}
// SaveWeComApprovalSceneRequest 保存企业微信审批场景请求。
type SaveWeComApprovalSceneRequest struct {
ApplicationID uint `json:"application_id" validate:"required,gt=0" description:"企业微信应用配置 ID"`

View File

@@ -63,6 +63,13 @@ func registerWeComRoutes(router fiber.Router, handler *admin.WeComHandler, doc *
Output: new(dto.WeComMemberListResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/applications/:id/templates/inspect", handler.InspectTemplate, RouteSpec{
Summary: "读取企业微信审批模板控件",
Tags: []string{"企业微信审批"},
Input: new(dto.InspectWeComTemplateParams),
Output: new(dto.WeComTemplateDetailResponse),
Auth: true,
})
Register(group, doc, groupPath, "PUT", "/scenes/:business_type", handler.SaveScene, RouteSpec{
Summary: "保存并校验企业微信审批场景模板映射",
Tags: []string{"企业微信审批"},