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

297 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
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))
}
}