七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
This commit is contained in:
296
internal/infrastructure/wecom/token_provider.go
Normal file
296
internal/infrastructure/wecom/token_provider.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
const releaseTokenLockScript = `
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
// TokenApplicationRepository 定义 access_token Provider 所需的应用配置查询边界。
|
||||
type TokenApplicationRepository interface {
|
||||
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
|
||||
MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error
|
||||
}
|
||||
|
||||
// TokenCredentialCipher 定义 access_token Provider 所需的凭据解密边界。
|
||||
type TokenCredentialCipher interface {
|
||||
Decrypt(ciphertext []byte) (string, error)
|
||||
}
|
||||
|
||||
// TokenIntegrationLog 定义企微外呼的 Integration Log 边界。
|
||||
type TokenIntegrationLog interface {
|
||||
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
|
||||
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
|
||||
}
|
||||
|
||||
// TokenProvider 按应用缓存企业微信 access_token,并用 Redis 短锁抑制并发回源。
|
||||
type TokenProvider struct {
|
||||
repo TokenApplicationRepository
|
||||
cipher TokenCredentialCipher
|
||||
redis *redis.Client
|
||||
integration TokenIntegrationLog
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewTokenProvider 创建企业微信 access_token Provider。
|
||||
func NewTokenProvider(
|
||||
repo TokenApplicationRepository,
|
||||
cipher TokenCredentialCipher,
|
||||
redisClient *redis.Client,
|
||||
integration TokenIntegrationLog,
|
||||
baseURL string,
|
||||
timeout time.Duration,
|
||||
logger *zap.Logger,
|
||||
) *TokenProvider {
|
||||
if timeout <= 0 {
|
||||
timeout = constants.WeComDefaultHTTPTimeout
|
||||
}
|
||||
return &TokenProvider{
|
||||
repo: repo, cipher: cipher, redis: redisClient, integration: integration,
|
||||
httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"),
|
||||
logger: logger, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。
|
||||
func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) {
|
||||
if p == nil || p.repo == nil || p.cipher == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 {
|
||||
return "", errors.New(errors.CodeWeComCredentialInvalid)
|
||||
}
|
||||
cacheKey := constants.RedisWeComAccessTokenKey(applicationID)
|
||||
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
|
||||
return token, nil
|
||||
} else if err != nil {
|
||||
p.warnRedis("读取企业微信 access_token 缓存失败,改为受控直连", err, applicationID)
|
||||
return p.fetchAndCache(ctx, applicationID, cacheKey)
|
||||
}
|
||||
if p.redis == nil {
|
||||
return p.fetchAndCache(ctx, applicationID, cacheKey)
|
||||
}
|
||||
lockKey := constants.RedisWeComAccessTokenLockKey(applicationID)
|
||||
lockValue := uuid.NewString()
|
||||
acquired, err := p.redis.SetNX(ctx, lockKey, lockValue, constants.WeComTokenLockTTL).Result()
|
||||
if err != nil {
|
||||
p.warnRedis("获取企业微信 access_token 回源锁失败,改为受控直连", err, applicationID)
|
||||
return p.fetchAndCache(ctx, applicationID, cacheKey)
|
||||
}
|
||||
if !acquired {
|
||||
return p.waitForToken(ctx, applicationID, cacheKey)
|
||||
}
|
||||
defer p.releaseLock(ctx, lockKey, lockValue, applicationID)
|
||||
if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found {
|
||||
return token, nil
|
||||
}
|
||||
return p.fetchAndCache(ctx, applicationID, cacheKey)
|
||||
}
|
||||
|
||||
// Invalidate 删除指定应用的 access_token 缓存。
|
||||
func (p *TokenProvider) Invalidate(ctx context.Context, applicationID uint) {
|
||||
if p == nil || p.redis == nil || applicationID == 0 {
|
||||
return
|
||||
}
|
||||
if err := p.redis.Del(ctx, constants.RedisWeComAccessTokenKey(applicationID)).Err(); err != nil {
|
||||
p.warnRedis("删除企业微信 access_token 缓存失败", err, applicationID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TokenProvider) getCachedToken(ctx context.Context, cacheKey string) (string, bool, error) {
|
||||
if p.redis == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
token, err := p.redis.Get(ctx, cacheKey).Result()
|
||||
if err == redis.Nil {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return token, token != "", nil
|
||||
}
|
||||
|
||||
func (p *TokenProvider) waitForToken(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
|
||||
timer := time.NewTimer(constants.WeComTokenWaitTimeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(constants.WeComTokenWaitPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", errors.Wrap(errors.CodeServiceUnavailable, ctx.Err(), "等待企业微信 access_token 已取消")
|
||||
case <-timer.C:
|
||||
return "", errors.New(errors.CodeServiceUnavailable, "企业微信 access_token 正在刷新,请稍后重试")
|
||||
case <-ticker.C:
|
||||
token, found, err := p.getCachedToken(ctx, cacheKey)
|
||||
if err != nil {
|
||||
p.warnRedis("等待企业微信 access_token 时 Redis 不可用,改为受控直连", err, applicationID)
|
||||
return p.fetchAndCache(ctx, applicationID, cacheKey)
|
||||
}
|
||||
if found {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, cacheKey string) (string, error) {
|
||||
application, err := p.repo.GetEnabled(ctx, applicationID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, err := p.cipher.Decrypt(application.SecretCiphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := p.newTokenRequest(ctx, application.CorpID, secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(applicationID), 10)
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
|
||||
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType,
|
||||
ResourceID: &resourceID, RequestSummary: map[string]any{
|
||||
"application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID,
|
||||
}, RequestID: requestID, CorrelationID: requestID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
startedAt := p.now()
|
||||
response, err := p.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", p.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信 Token 请求失败", startedAt)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
tokenResponse, err := p.readTokenResponse(response)
|
||||
if err != nil {
|
||||
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信 Token 响应无效", startedAt)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || tokenResponse.ErrCode != 0 || tokenResponse.AccessToken == "" {
|
||||
providerCode := strconv.FormatInt(tokenResponse.ErrCode, 10)
|
||||
if tokenResponse.ErrCode == 0 {
|
||||
providerCode = strconv.Itoa(response.StatusCode)
|
||||
}
|
||||
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, tokenResponse.ErrMsg, startedAt)
|
||||
}
|
||||
ttl := time.Duration(tokenResponse.ExpiresIn)*time.Second - constants.WeComTokenRefreshAdvance
|
||||
if ttl <= 0 {
|
||||
return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_expires_in", "企业微信 access_token 有效期无效", startedAt)
|
||||
}
|
||||
if err := p.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, tokenResponse, startedAt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.redis != nil {
|
||||
if err := p.redis.Set(ctx, cacheKey, tokenResponse.AccessToken, ttl).Err(); err != nil {
|
||||
p.warnRedis("缓存企业微信 access_token 失败,本次继续使用直连结果", err, applicationID)
|
||||
}
|
||||
}
|
||||
if err := p.repo.MarkConnected(ctx, applicationID, p.now()); err != nil && p.logger != nil {
|
||||
p.logger.Error("记录企业微信最近连接时间失败,本次 token 仍可使用", zap.Uint("application_id", applicationID), zap.Error(err))
|
||||
}
|
||||
return tokenResponse.AccessToken, nil
|
||||
}
|
||||
|
||||
func (p *TokenProvider) newTokenRequest(ctx context.Context, corpID, secret string) (*http.Request, error) {
|
||||
endpoint, err := url.Parse(p.baseURL + "/cgi-bin/gettoken")
|
||||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
||||
return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效")
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("corpid", corpID)
|
||||
query.Set("corpsecret", secret)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信 Token 请求失败")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (p *TokenProvider) readTokenResponse(response *http.Response) (tokenResponse, error) {
|
||||
var result tokenResponse
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
|
||||
if err != nil {
|
||||
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信 Token 响应失败")
|
||||
}
|
||||
if int64(len(body)) > constants.WeComMaxResponseBodyBytes {
|
||||
return result, errors.New(errors.CodeServiceUnavailable, "企业微信 Token 响应过大")
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &result); err != nil {
|
||||
return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信 Token 响应失败")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *TokenProvider) completeSuccess(ctx context.Context, integrationID string, status int, result tokenResponse, startedAt time.Time) error {
|
||||
_, err := p.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, "expires_in": result.ExpiresIn, "token_present": result.AccessToken != ""},
|
||||
DurationMS: p.now().Sub(startedAt).Milliseconds(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *TokenProvider) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error {
|
||||
if providerMessage == "" {
|
||||
providerMessage = "企业微信 Token 接口返回失败"
|
||||
}
|
||||
_, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode,
|
||||
ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false},
|
||||
DurationMS: p.now().Sub(startedAt).Milliseconds(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信连接失败,请检查应用配置和可信 IP")
|
||||
}
|
||||
|
||||
func (p *TokenProvider) releaseLock(ctx context.Context, lockKey, lockValue string, applicationID uint) {
|
||||
if p.redis == nil {
|
||||
return
|
||||
}
|
||||
if err := p.redis.Eval(ctx, releaseTokenLockScript, []string{lockKey}, lockValue).Err(); err != nil {
|
||||
p.warnRedis("释放企业微信 access_token 回源锁失败", err, applicationID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TokenProvider) warnRedis(message string, err error, applicationID uint) {
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(message, zap.Uint("application_id", applicationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user