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

304 lines
12 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/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,
}
}