All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
304 lines
12 KiB
Go
304 lines
12 KiB
Go
// 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/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)
|
||
}
|
||
|
||
// CredentialCipher 定义企业微信敏感凭据加密边界。
|
||
type CredentialCipher interface {
|
||
Encrypt(plaintext string) ([]byte, error)
|
||
Decrypt(ciphertext []byte) (string, error)
|
||
}
|
||
|
||
// AccessTokenProvider 定义按应用取得及失效 access_token 的边界。
|
||
type AccessTokenProvider interface {
|
||
GetAccessToken(ctx context.Context, applicationID uint) (string, error)
|
||
Invalidate(ctx context.Context, applicationID uint)
|
||
}
|
||
|
||
// ConnectionService 保存加密配置并测试企业微信连接。
|
||
type ConnectionService struct {
|
||
db *gorm.DB
|
||
repo ApplicationRepository
|
||
cipher CredentialCipher
|
||
tokens AccessTokenProvider
|
||
audit systemconfigapp.AuditWriter
|
||
members DefaultCreatorMemberFinder
|
||
now func() time.Time
|
||
}
|
||
|
||
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
|
||
func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) {
|
||
s.members = finder
|
||
}
|
||
|
||
// NewConnectionService 创建企业微信连接用例。
|
||
func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService {
|
||
return &ConnectionService{db: db, repo: repo, cipher: cipher, 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.cipher == nil {
|
||
return nil, errors.New(errors.CodeWeComCredentialInvalid)
|
||
}
|
||
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)
|
||
}
|
||
secret, err := s.cipher.Encrypt(request.Secret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
callbackToken, err := s.cipher.Encrypt(request.CallbackToken)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
encodingAESKey, err := s.cipher.Encrypt(request.EncodingAESKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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.SecretCiphertext = secret
|
||
existing.CallbackTokenCiphertext = callbackToken
|
||
existing.EncodingAESKeyCiphertext = 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
|
||
}
|
||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||
OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置",
|
||
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
|
||
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
saved = existing
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
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, request.Secret, request.CallbackToken, request.EncodingAESKey)
|
||
return &response, nil
|
||
}
|
||
|
||
// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。
|
||
func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) {
|
||
if s == nil || s.repo == nil || s.cipher == nil {
|
||
return nil, errors.New(errors.CodeWeComCredentialInvalid)
|
||
}
|
||
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
|
||
}
|
||
result := make([]dto.WeComApplicationResponse, 0, len(applications))
|
||
for _, application := range applications {
|
||
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, toApplicationResponse(application, secret, callbackToken, encodingAESKey))
|
||
}
|
||
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.cipher == nil || s.members == 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 {
|
||
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
|
||
}
|
||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||
OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人",
|
||
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
|
||
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
|
||
})
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
var appErr *errors.AppError
|
||
if stdErrors.As(err, &appErr) {
|
||
return nil, appErr
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败")
|
||
}
|
||
secret, err := s.cipher.Decrypt(application.SecretCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := toApplicationResponse(*application, secret, callbackToken, encodingAESKey)
|
||
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": true,
|
||
"default_creator_userid": application.DefaultCreatorUserID,
|
||
"default_creator_name": application.DefaultCreatorName,
|
||
}
|
||
}
|
||
|
||
func toApplicationResponse(application model.WeComApplication, secret, callbackToken, encodingAESKey string) 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: secret, CallbackToken: callbackToken, EncodingAESKey: encodingAESKey,
|
||
DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName,
|
||
Status: application.Status, StatusName: statusName,
|
||
CredentialsSet: len(application.SecretCiphertext) > 0 && len(application.CallbackTokenCiphertext) > 0 && len(application.EncodingAESKeyCiphertext) > 0,
|
||
LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
|
||
}
|
||
}
|