07955ddf07
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感), 不接管消息推送,副作用最小。 流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 → 回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized → PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。 - 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets; - ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis); - User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一: 存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败 —— 与之前 NULL 余额同类的坑,这次提前避开。 单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、 建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了, 留待部署后真机扫码。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
8.2 KiB
Go
243 lines
8.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
|
)
|
|
|
|
// 微信公众号(服务号)扫码登录。设计见 internal/wechat/mp.go。
|
|
//
|
|
// 端点:
|
|
// POST /api/v1/wx/mp/ticket 建 ticket,返回二维码里要放的 URL
|
|
// GET /wx/mp?t=<ticket> 用户扫码后微信打开这个,302 到微信授权页
|
|
// GET /api/v1/wx/mp/callback 微信授权后回调,换 openid、找/建用户、置 ticket 已授权
|
|
// GET /api/v1/wx/mp/poll?t= PC 端轮询,已授权则签发 JWT
|
|
|
|
const (
|
|
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
|
|
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
|
|
)
|
|
|
|
// wxTicketState 是 ticket 在 Redis 里的值。
|
|
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()
|
|
}
|
|
|
|
func newTicket() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// WxMPTicket: POST /api/v1/wx/mp/ticket —— 建一个待授权 ticket,返回二维码内容 URL。
|
|
func (h *Handler) WxMPTicket(c *gin.Context) {
|
|
cfg := h.loadWechatMP(c.Request.Context())
|
|
if !cfg.Enabled() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
|
|
return
|
|
}
|
|
ticket := newTicket()
|
|
st, _ := json.Marshal(wxTicketState{Status: "pending"})
|
|
if err := h.cache.WxTicketSet(c.Request.Context(), ticket, string(st), wxTicketTTL); err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
|
|
return
|
|
}
|
|
// 二维码里放的是本服务的中转地址,不是微信授权地址本身 —— 授权地址带 secret 相关参数,
|
|
// 且要在用户扫码那一刻才拼(redirect_uri 要精确匹配),所以扫码后再由 /wx/mp 现拼现跳。
|
|
base := strings.TrimRight(cfg.BaseURL, "/")
|
|
if base == "" {
|
|
base = "https://" + c.Request.Host
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ticket": ticket,
|
|
"qr_url": base + "/wx/mp?t=" + ticket,
|
|
"expires_in": int(wxTicketTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
// WxMPEntry: GET /wx/mp?t=<ticket> —— 用户微信扫码后打开,302 到微信 OAuth 授权页。
|
|
// 这是公开路由(微信内置浏览器访问,无鉴权头)。
|
|
func (h *Handler) WxMPEntry(c *gin.Context) {
|
|
ticket := c.Query("t")
|
|
if ticket == "" {
|
|
c.String(http.StatusBadRequest, "缺少登录票据")
|
|
return
|
|
}
|
|
cfg := h.loadWechatMP(c.Request.Context())
|
|
if !cfg.Enabled() {
|
|
c.String(http.StatusServiceUnavailable, "微信登录未配置")
|
|
return
|
|
}
|
|
// ticket 必须存在且仍 pending,否则可能是过期或伪造
|
|
if h.cache.WxTicketGet(c.Request.Context(), ticket) == "" {
|
|
c.String(http.StatusBadRequest, "登录二维码已过期,请刷新重试")
|
|
return
|
|
}
|
|
base := strings.TrimRight(cfg.BaseURL, "/")
|
|
if base == "" {
|
|
base = "https://" + c.Request.Host
|
|
}
|
|
redirectURI := base + "/api/v1/wx/mp/callback"
|
|
c.Redirect(http.StatusFound, cfg.AuthorizeURL(redirectURI, ticket))
|
|
}
|
|
|
|
// WxMPCallback: GET /api/v1/wx/mp/callback?code&state —— 微信授权后回调(公开路由)。
|
|
// 用 code 换 openid → 找/建用户 → ticket 置 authorized。
|
|
func (h *Handler) WxMPCallback(c *gin.Context) {
|
|
code, ticket := c.Query("code"), c.Query("state")
|
|
if code == "" || ticket == "" {
|
|
c.String(http.StatusBadRequest, "授权参数缺失")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
// ticket 必须仍有效(防止拿别人的回调套自己的 ticket)
|
|
if h.cache.WxTicketGet(ctx, ticket) == "" {
|
|
c.String(http.StatusBadRequest, "登录已过期,请在电脑上重新扫码")
|
|
return
|
|
}
|
|
cfg := h.loadWechatMP(ctx)
|
|
if !cfg.Enabled() {
|
|
c.String(http.StatusServiceUnavailable, "微信登录未配置")
|
|
return
|
|
}
|
|
info, err := cfg.ExchangeCode(ctx, code)
|
|
if err != nil {
|
|
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err)
|
|
c.String(http.StatusBadGateway, "微信授权失败,请重试")
|
|
return
|
|
}
|
|
|
|
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套)
|
|
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID)
|
|
if err != nil {
|
|
c.String(http.StatusBadGateway, "登录失败,请重试")
|
|
return
|
|
}
|
|
if u == nil {
|
|
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户")
|
|
if err != nil {
|
|
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err)
|
|
c.String(http.StatusBadGateway, "登录失败,请重试")
|
|
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})
|
|
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
|
|
c.String(http.StatusBadGateway, "登录失败,请重试")
|
|
return
|
|
}
|
|
// 微信内置浏览器里显示一句提示即可,登录动作在 PC 端完成
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:sans-serif;text-align:center;padding-top:30vh;color:#333"><h2>✅ 登录成功</h2><p style="color:#888">请回到电脑继续</p></body>`)
|
|
}
|
|
|
|
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket> —— PC 端轮询登录状态(公开路由)。
|
|
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
|
|
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}) // pending
|
|
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)
|
|
}
|
|
|
|
// ---- 管理端配置 ----
|
|
|
|
// AdminGetWechatMP: GET /api/v1/admin/wechat-mp —— 当前配置(secret 不回显,只报有无)。
|
|
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
|
cfg := h.loadWechatMP(c.Request.Context())
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"appid": cfg.AppID,
|
|
"base_url": cfg.BaseURL,
|
|
"has_app_secret": cfg.AppSecret != "",
|
|
"enabled": cfg.Enabled(),
|
|
})
|
|
}
|
|
|
|
// AdminSaveWechatMP: PUT /api/v1/admin/wechat-mp —— 保存配置。
|
|
// app_secret 留空 = 沿用已存(只写不回显,同微信支付 APIv3 密钥)。
|
|
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
|
var b struct {
|
|
AppID string `json:"appid"`
|
|
AppSecret string `json:"app_secret"`
|
|
BaseURL string `json:"base_url"`
|
|
}
|
|
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,
|
|
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
|
|
}
|
|
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
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
|
|
}
|