Files
junhong_cmp_fiber/internal/application/wecom/connection.go
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

373 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
}
}