feat: add WeChat Pay, Redis logging, storage tiers and personal albums

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.
This commit is contained in:
2026-11-02 23:40:49 +08:00
parent 4934a69581
commit 4c9708b562
69 changed files with 2692 additions and 1153 deletions
+120
View File
@@ -0,0 +1,120 @@
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))
}
}