Files
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

288 lines
11 KiB
Go
Raw Permalink 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
}
// 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
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,
redisClient *redis.Client,
integration TokenIntegrationLog,
baseURL string,
timeout time.Duration,
logger *zap.Logger,
) *TokenProvider {
if timeout <= 0 {
timeout = constants.WeComDefaultHTTPTimeout
}
return &TokenProvider{
repo: repo, 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.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
}
request, err := p.newTokenRequest(ctx, application.CorpID, application.Secret)
if err != nil {
return "", err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
requestID := middleware.GetRequestIDFromContext(ctx)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(requestID)
attempt, err := p.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: integrationID,
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: correlationID, TriggerSeries: triggerSeries,
})
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))
}
}