Files
sundynix-agentix/sundynix-gateway/internal/handler/wechat_login.go
T
Blizzard 0f2afdaac1 feat(admin): 微信用户观测页 + 唯一昵称 + 密钥明文回显(单管理员后台)
按需求四项:
1. 微信新用户昵称改「微信用户_XXXXX」:后缀从 openid 的 sha1 派生(去混淆字母表),
   openid 唯一 → 后缀实际不重复,且同 openid 每次一致(重登不换名)。用户可自行改名。
2. 后台加「平台 → 微信用户」列表:昵称 / openid(点击复制)/ 积分余额 / 加入时间。
   每人仍独立租户与积分(各买各的,确认过不共享积分池),此页只做统一观测。
3. 登录设置的 AppSecret 明文回显(不再只显示"已保存")。
4. 支付配置的 APIv3 密钥明文回显。
   —— 用户明确后台单人使用、RequireAdmin 已拦,接受这一安全降级;密文仍加密入库。

修一个 gorm Scan 坑:WechatUserRow 的 WechatOpenID/BalanceMicro 没加 column tag,
gorm 把 WechatOpenID 断成列名 wechat_open_id,与 SQL alias wechat_openid 对不上 →
openid 静默返回空。教训:Scan 到自定义结构 + SQL 用 alias 时,字段一律显式加 column tag。

本地验证:真库造两个微信用户,列表接口正确返回 openid(修 tag 前是空);
登录设置页 AppSecret 已是可见文本框;nickname 单测覆盖唯一/稳定/去混淆。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:36:27 +08:00

271 lines
8.8 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 handler
import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/wechat"
)
// 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
//
// 端点:
// POST /api/v1/wx/mp/ticket 建 login ticket + 微信二维码,返回二维码图 URL
// GET /wx/mp/callback 服务器配置 URL 验证(echostr
// POST /wx/mp/callback 事件推送:关注/扫码 → 置 ticket 已授权
// GET /api/v1/wx/mp/poll?t= PC 轮询登录态,已授权则签发 JWT
const (
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
)
// access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)。
var wxTokenMu sync.Mutex
type wxTicketState struct {
Status string `json:"status"` // pending / authorized / consumed
UserID string `json:"user_id"`
}
func (h *Handler) loadWechatMP(ctx context.Context) wechat.Config {
raw := h.db.GetSetting(ctx, SettingWechatMP)
if raw == "" {
return wechat.Config{}
}
var c wechat.Config
if json.Unmarshal([]byte(raw), &c) != nil {
return wechat.Config{}
}
return c.DecryptFromStore()
}
// accessToken 取(缓存优先)微信 access_token。
func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, error) {
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" {
return t, nil
}
wxTokenMu.Lock()
defer wxTokenMu.Unlock()
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
return t, nil
}
// 中控模式:配了 WECHAT_TOKEN_URL 就只从中控拉(腾讯云静态 IP 换 token),
// 绝不自己调 FetchAccessToken——微信要求单点刷新,多点各自换会互相顶掉 token。
// 没配则维持直连(本地出网换 token,受动态 IP 白名单约束)。
var (
token string
ttl int
err error
)
if tokenURL := os.Getenv("WECHAT_TOKEN_URL"); tokenURL != "" {
token, ttl, err = wechat.PullToken(ctx, tokenURL, os.Getenv("WECHAT_TOKEN_SECRET"))
} else {
token, ttl, err = cfg.FetchAccessToken(ctx)
}
if err != nil {
return "", err
}
if ttl > 200 {
ttl -= 200 // 安全边界,避免临界过期
}
h.cache.WxTokenSet(ctx, cfg.AppID, token, time.Duration(ttl)*time.Second)
return token, nil
}
// wechatNickname 从 openid 派生稳定昵称:微信用户_XXXXX。
// 后缀取 openid 的 sha1 前 5 位、映射到去混淆字母表 —— openid 唯一 → 后缀实际不重复,
// 且同一 openid 每次一致(重登不会换名)。用户可事后自己改名。
func wechatNickname(openID string) string {
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // 去掉 0/O、1/I/L 等易混
sum := sha1.Sum([]byte(openID))
var sb strings.Builder
sb.WriteString("微信用户_")
for i := 0; i < 5; i++ {
sb.WriteByte(alphabet[int(sum[i])%len(alphabet)])
}
return sb.String()
}
func newTicket() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// WxMPTicket: POST /api/v1/wx/mp/ticket
func (h *Handler) WxMPTicket(c *gin.Context) {
ctx := c.Request.Context()
cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
return
}
token, err := h.accessToken(ctx, cfg)
if err != nil {
log.Printf("[wxlogin] 取 access_token 失败: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "微信登录暂不可用"})
return
}
ticket := newTicket()
qrURL, err := cfg.CreateLoginQR(ctx, token, ticket, int(wxTicketTTL.Seconds()))
if err != nil {
log.Printf("[wxlogin] 建二维码失败: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败"})
return
}
st, _ := json.Marshal(wxTicketState{Status: "pending"})
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
}
// WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证。
func (h *Handler) WxMPVerify(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
c.String(http.StatusOK, c.Query("echostr"))
}
// WxMPEvent: POST /wx/mp/callback —— 事件推送。
// 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"。
func (h *Handler) WxMPEvent(c *gin.Context) {
ctx := c.Request.Context()
cfg := h.loadWechatMP(ctx)
// 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
body, _ := io.ReadAll(c.Request.Body)
ev, err := wechat.ParseEvent(body)
if err != nil || !ev.IsLoginScan() {
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
return
}
ticket, openID := ev.Scene(), ev.FromUserName
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
c.String(http.StatusOK, "success")
return
}
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
if err != nil {
c.String(http.StatusOK, "success")
return
}
if u == nil {
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
c.String(http.StatusOK, "success")
return
}
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e)
}
}
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
c.String(http.StatusOK, "success")
}
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
func (h *Handler) WxMPPoll(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少票据"})
return
}
ctx := c.Request.Context()
raw := h.cache.WxTicketGet(ctx, ticket)
if raw == "" {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
var st wxTicketState
if json.Unmarshal([]byte(raw), &st) != nil {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
if st.Status != "authorized" {
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
}
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
u, err := h.db.GetUserByID(ctx, st.UserID)
if err != nil || u == nil {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
issueToken(c, u)
}
// ---- 管理端配置 ----
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
// 单管理员后台:AppSecret 明文回显(RequireAdmin 已拦,方便核对/复制)。
c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID,
"token": cfg.Token,
"app_secret": cfg.AppSecret,
"enabled": cfg.Enabled(),
})
}
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
var b struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ctx := c.Request.Context()
secret := strings.TrimSpace(b.AppSecret)
if secret == "" {
secret = h.loadWechatMP(ctx).AppSecret
}
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token)}
stored, err := cfg.EncryptedForStore()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
return
}
raw, _ := json.Marshal(stored)
if err := h.db.SetSetting(ctx, SettingWechatMP, string(raw)); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
}
// AdminWechatUsers: GET /api/v1/admin/wechat-users —— 后台微信用户列表(openid/昵称/加入时间/余额)。
func (h *Handler) AdminWechatUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": h.db.ListWechatUsers(c.Request.Context(), 200)})
}