// Package wecom 提供企业微信配置简单写与连接测试用例。 package wecom import ( "context" stdErrors "errors" "fmt" "time" "gorm.io/gorm" systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/auditfailure" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // ApplicationRepository 定义企业微信应用配置持久化边界。 type ApplicationRepository interface { FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error) Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error } // DefaultCreatorMemberFinder 定义默认审批发起人的可见成员查询边界。 type DefaultCreatorMemberFinder interface { GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) } // AccessTokenProvider 定义按应用取得及失效 access_token 的边界。 type AccessTokenProvider interface { GetAccessToken(ctx context.Context, applicationID uint) (string, error) Invalidate(ctx context.Context, applicationID uint) } // SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。 type SensitiveReadAuditWriter interface { WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error } // SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。 type SensitiveReadAudit struct { OperatorID uint Applications []SensitiveReadResource FieldClasses []string RequestID string CorrelationID string } // SensitiveReadResource 是不含任何明文凭据的读取目标快照。 type SensitiveReadResource struct { ID uint CorpID string AgentID int64 Name string Status int CredentialsConfigured bool } // ConnectionService 保存应用配置并测试企业微信连接。 type ConnectionService struct { db *gorm.DB repo ApplicationRepository tokens AccessTokenProvider audit systemconfigapp.AuditWriter readAudit SensitiveReadAuditWriter members DefaultCreatorMemberFinder now func() time.Time } // SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。 func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) { s.readAudit = writer } // SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。 func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) { s.members = finder } // NewConnectionService 创建企业微信连接用例。 func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService { return &ConnectionService{db: db, repo: repo, tokens: tokens, audit: audit, now: time.Now} } // Save 创建或更新企业微信应用配置。 func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) { if s == nil || s.db == nil || s.repo == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden) } operatorID := middleware.GetUserIDFromContext(ctx) if operatorID == 0 { return nil, errors.New(errors.CodeInvalidParam) } now := s.now().UTC() var saved *model.WeComApplication err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil { return err } existing, findErr := s.repo.FindByIdentityForUpdate(ctx, tx, request.CorpID, request.AgentID) if findErr != nil { return findErr } before := map[string]any{"configured": false} if existing == nil { existing = &model.WeComApplication{ Model: gorm.Model{CreatedAt: now}, CorpID: request.CorpID, AgentID: request.AgentID, CreatedBy: operatorID, } } else { before = applicationAuditSnapshot(existing) } existing.Name = request.Name existing.Secret = request.Secret existing.CallbackToken = request.CallbackToken existing.EncodingAESKey = request.EncodingAESKey existing.Status = request.Status existing.UpdatedBy = operatorID existing.UpdatedAt = now if existing.ID == 0 { if err := s.repo.Create(ctx, tx, existing); err != nil { return err } } else if err := s.repo.Update(ctx, tx, existing); err != nil { return err } if s.audit != nil { requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } resourceID := fmt.Sprintf("%d", existing.ID) if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave, Description: "保存企业微信应用安全配置", ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before, AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, ResourceID: &resourceID, DisplayName: existing.Name, Identity: applicationAuditIdentity(existing), }); err != nil { return err } } saved = existing return nil }) if err != nil { recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave, Description: "保存企业微信应用配置失败", ConfigKey: fmt.Sprintf("wecom.application.%s.%d", request.CorpID, request.AgentID), DisplayName: request.Name, Identity: map[string]any{ "corp_id": request.CorpID, "agent_id": request.AgentID, "name": request.Name, "status": request.Status, "credentials_configured": request.Secret != "" && request.CallbackToken != "" && request.EncodingAESKey != "", }, Result: constants.AuditResultFailed, ErrorCode: fmt.Sprintf("%d", errors.CodeDatabaseError), ErrorSummary: "企业微信应用配置事务已回滚", }) var appErr *errors.AppError if stdErrors.As(err, &appErr) { return nil, appErr } return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信应用配置失败") } if s.tokens != nil { s.tokens.Invalidate(ctx, saved.ID) } response := toApplicationResponse(*saved) return &response, nil } // List 返回企业微信应用列表,并向超级管理员返回可直接编辑的凭据。 func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) { if s == nil || s.repo == nil { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden) } if request.Page <= 0 { request.Page = constants.DefaultPage } if request.PageSize <= 0 { request.PageSize = constants.DefaultPageSize } if request.PageSize > constants.MaxPageSize { return nil, errors.New(errors.CodeInvalidParam) } applications, total, err := s.repo.List(ctx, request.Page, request.PageSize) if err != nil { return nil, err } if len(applications) > 0 { if s.db == nil || s.readAudit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置") } requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } resources := make([]SensitiveReadResource, 0, len(applications)) for _, application := range applications { resources = append(resources, SensitiveReadResource{ ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name, Status: application.Status, CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "", }) } readAudit := SensitiveReadAudit{ OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources, FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"}, RequestID: requestID, CorrelationID: requestID, } if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit) }); err != nil { return nil, err } } result := make([]dto.WeComApplicationResponse, 0, len(applications)) for _, application := range applications { result = append(result, toApplicationResponse(application)) } return &dto.WeComApplicationListResponse{ Items: result, Total: total, Page: request.Page, PageSize: request.PageSize, }, nil } // Test 强制失效旧缓存后取得一次 access_token,但绝不向调用方返回 token。 func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error { if s == nil { return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return errors.New(errors.CodeForbidden) } if s.tokens == nil { return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") } s.tokens.Invalidate(ctx, applicationID) _, err := s.tokens.GetAccessToken(ctx, applicationID) return err } // SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。 func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) { if s == nil || s.db == nil || s.repo == nil || s.members == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden) } operatorID := middleware.GetUserIDFromContext(ctx) if applicationID == 0 || operatorID == 0 { return nil, errors.New(errors.CodeInvalidParam) } application, err := s.repo.GetEnabled(ctx, applicationID) if err != nil { return nil, err } member, err := s.members.GetVisible(ctx, applicationID, request.UserID) if err != nil { recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "拒绝保存不可用的企业微信默认审批发起人", application, constants.AuditResultDenied, errors.CodeInvalidParam) return nil, err } now := s.now().UTC() err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { before := applicationAuditSnapshot(application) if err := s.repo.UpdateDefaultCreator(ctx, tx, applicationID, member.UserID, member.Name, operatorID, now); err != nil { return err } application.DefaultCreatorUserID = member.UserID application.DefaultCreatorName = member.Name application.UpdatedBy = operatorID application.UpdatedAt = now if s.audit != nil { requestID := "" if value := middleware.GetRequestIDFromContext(ctx); value != nil { requestID = *value } resourceID := fmt.Sprintf("%d", applicationID) return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationWeComDefaultCreatorSave, Description: "保存企业微信默认审批发起人", ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before, AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID, ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application), }) } return nil }) if err != nil { recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "保存企业微信默认审批发起人失败", application, constants.AuditResultFailed, errors.CodeDatabaseError) var appErr *errors.AppError if stdErrors.As(err, &appErr) { return nil, appErr } return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败") } response := toApplicationResponse(*application) return &response, nil } func applicationAuditSnapshot(application *model.WeComApplication) map[string]any { return map[string]any{ "id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID, "name": application.Name, "status": application.Status, "credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "", "default_creator_userid": application.DefaultCreatorUserID, "default_creator_name": application.DefaultCreatorName, } } func applicationAuditIdentity(application *model.WeComApplication) map[string]any { if application == nil { return nil } return map[string]any{ "id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID, "name": application.Name, "status": application.Status, "credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "", } } func recordApplicationFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, operation, description string, application *model.WeComApplication, result string, code int) { if application == nil { return } resourceID := fmt.Sprintf("%d", application.ID) recordConfigFailure(ctx, db, audit, systemconfigapp.ChangeAudit{ OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description, ConfigKey: fmt.Sprintf("wecom.application.%d", application.ID), ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application), BeforeData: applicationAuditSnapshot(application), Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description, }) } func recordConfigFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, change systemconfigapp.ChangeAudit) { if db == nil || audit == nil || change.OperatorID == 0 || change.ConfigKey == "" { return } if value := middleware.GetRequestIDFromContext(ctx); value != nil { change.RequestID = *value change.CorrelationID = *value } if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return audit.WriteConfigChange(ctx, tx, change) }); err != nil { auditfailure.RecordSecondaryWriteFailure(change.OperationType, change.ConfigKey, change.RequestID, change.CorrelationID, change.ErrorCode, err) } } func toApplicationResponse(application model.WeComApplication) dto.WeComApplicationResponse { statusName := "禁用" if application.Status == constants.StatusEnabled { statusName = "启用" } return dto.WeComApplicationResponse{ ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name, Secret: application.Secret, CallbackToken: application.CallbackToken, EncodingAESKey: application.EncodingAESKey, DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName, Status: application.Status, StatusName: statusName, CredentialsSet: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "", LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt, } }