Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
121 lines
3.9 KiB
Go
121 lines
3.9 KiB
Go
package wechat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AppID is part of the key; environments can use different key prefixes or DBs.
|
|
func (c *Client) tokenKey() string { return c.KeyPrefix + "wechat:{" + c.AppID + "}:access_token" }
|
|
|
|
var compareDelete = redis.NewScript(`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`)
|
|
|
|
// AccessToken shares cached tokens and a refresh lock across processes.
|
|
// Redis failures never fall back to the old plaintext file or repeated refreshes.
|
|
func (c *Client) AccessToken() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.Redis == nil {
|
|
zap.L().Error("微信 token 缓存未初始化")
|
|
return ""
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
key := c.tokenKey()
|
|
lockKey, owner := key+":lock", uuid.NewString()
|
|
for {
|
|
token, err := c.Redis.Get(ctx, key).Result()
|
|
if err == nil && token != "" {
|
|
return token
|
|
}
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
|
|
return ""
|
|
}
|
|
locked, err := c.Redis.SetNX(ctx, lockKey, owner, 30*time.Second).Result()
|
|
if err != nil {
|
|
zap.L().Error("获取微信 token 刷新锁失败", zap.Error(err))
|
|
return ""
|
|
}
|
|
if locked {
|
|
defer func() {
|
|
unlockCtx, done := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer done()
|
|
if err := compareDelete.Run(unlockCtx, c.Redis, []string{lockKey}, owner).Err(); err != nil {
|
|
zap.L().Warn("释放微信 token 刷新锁失败", zap.Error(err))
|
|
}
|
|
}()
|
|
// Another process may have filled the cache between our GET and SET NX.
|
|
token, err = c.Redis.Get(ctx, key).Result()
|
|
if err == nil && token != "" {
|
|
return token
|
|
}
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
|
|
return ""
|
|
}
|
|
return c.fetchToken(ctx, key)
|
|
}
|
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
zap.L().Warn("等待微信 token 刷新超时")
|
|
return ""
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) fetchToken(ctx context.Context, key string) string {
|
|
endpoint := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
zap.L().Error("微信 token 请求失败")
|
|
return ""
|
|
} // Do not log the URL containing AppSecret.
|
|
defer resp.Body.Close()
|
|
var data struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
Errcode int `json:"errcode"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || resp.StatusCode != http.StatusOK || data.Errcode != 0 || data.AccessToken == "" || data.ExpiresIn <= 0 {
|
|
zap.L().Error("微信 token 返回无效", zap.Int("http_status", resp.StatusCode), zap.Int("errcode", data.Errcode))
|
|
return ""
|
|
}
|
|
ttl := time.Duration(data.ExpiresIn) * time.Second
|
|
// Refresh early without making short-lived tokens immediately expire.
|
|
margin := min(ttl/10, 5*time.Minute)
|
|
ttl -= margin
|
|
if err := c.Redis.Set(ctx, key, data.AccessToken, ttl).Err(); err != nil {
|
|
zap.L().Error("写入微信 token 缓存失败", zap.Error(err))
|
|
return ""
|
|
}
|
|
return data.AccessToken
|
|
}
|
|
|
|
// Invalidate only the token rejected by WeChat, preserving a concurrent refresh.
|
|
func (c *Client) clearTokenCache(token string) {
|
|
if c.Redis == nil || token == "" {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := compareDelete.Run(ctx, c.Redis, []string{c.tokenKey()}, token).Err(); err != nil {
|
|
zap.L().Warn("清理失效微信 token 失败", zap.Error(err))
|
|
}
|
|
}
|