feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)

上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 08:58:31 +08:00
parent 07955ddf07
commit 883540bd7e
11 changed files with 607 additions and 188 deletions
+23
View File
@@ -227,6 +227,29 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
} }
// ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)---- // ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)----
// ---- 微信扫码登录配置 ----
export interface WechatMPConfig {
appid: string;
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
has_app_secret: boolean;
enabled: boolean;
}
export async function getWechatMP(): Promise<WechatMPConfig> {
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
return { appid: d.appid ?? "", token: d.token ?? "", has_app_secret: !!d.has_app_secret, enabled: !!d.enabled };
}
// app_secret 传空串 = 沿用已保存的。
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string }): Promise<{ enabled: boolean }> {
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
return { enabled: !!d.enabled };
}
export interface WechatPayConfig { export interface WechatPayConfig {
mchid: string; mchid: string;
cert_serial: string; cert_serial: string;
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api";
// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。
// AppSecret 只写不回显(密文入库,与微信支付 APIv3 密钥同一套加密)。
export function LoginConfigPage() {
const [cfg, setCfg] = useState<WechatMPConfig | null>(null);
const [appid, setAppid] = useState("");
const [token, setToken] = useState("");
const [secret, setSecret] = useState(""); // 留空=沿用已存
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [ok, setOk] = useState(false);
useEffect(() => {
getWechatMP()
.then((c) => {
setCfg(c);
setAppid(c.appid);
setToken(c.token);
})
.catch((e) => setErr((e as Error).message));
}, []);
const save = async () => {
if (busy) return;
setBusy(true);
setErr("");
setOk(false);
try {
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() });
setSecret("");
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), has_app_secret: c.has_app_secret || !!secret, enabled: r.enabled } : c));
setOk(true);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
// 回调 URL 用当前后端地址推断(生产 GATEWAY 为空串=同源,显示成本域名)
const origin = GATEWAY || (typeof window !== "undefined" ? window.location.origin : "");
const callbackURL = origin.replace(/:\d+$/, "") + "/wx/mp/callback";
return (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">AppSecret </p>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-700"></h4>
{cfg?.enabled ? (
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-600"></span>
) : (
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500"></span>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="text-xs text-gray-500">
AppID
<input value={appid} onChange={(e) => setAppid(e.target.value)} placeholder="wx..."
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
AppSecret{cfg?.has_app_secret && <span className="ml-1 text-emerald-600"></span>}
<input type="password" value={secret} onChange={(e) => setSecret(e.target.value)}
placeholder={cfg?.has_app_secret ? "留空则沿用已保存的" : "公众号开发密钥"}
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500 md:col-span-2">
Token
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
</div>
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
{ok && <p className="mt-2 text-xs text-emerald-600"></p>}
<div className="mt-3">
<button onClick={() => void save()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "保存中…" : "保存"}
</button>
</div>
</div>
{/* 配置清单:把「必须在公众平台做什么」写在眼前,省得来回翻文档 */}
<div className="rounded-xl border border-amber-100 bg-amber-50/40 p-5 text-xs leading-relaxed text-gray-600">
<h4 className="mb-2 text-sm font-semibold text-gray-700"></h4>
<ol className="list-decimal space-y-1.5 pl-4">
<li>(URL) <code className="break-all rounded bg-white px-1">{callbackURL}</code></li>
<li>(Token) <b></b></li>
<li> <b></b></li>
<li>IP IP access_token 40164</li>
<li></li>
</ol>
</div>
</div>
);
}
+8
View File
@@ -1,4 +1,5 @@
import { SubscriptionPage } from "./pages/SubscriptionPage"; import { SubscriptionPage } from "./pages/SubscriptionPage";
import { LoginConfigPage } from "./pages/LoginConfigPage";
import { lazy, type ReactNode } from "react"; import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。 // 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -77,6 +78,13 @@ export const routes: RouteDef[] = [
ready: true, ready: true,
element: <ModelConfigPage />, element: <ModelConfigPage />,
}, },
{
path: "login-config",
label: "登录设置",
group: "运维",
ready: true,
element: <LoginConfigPage />,
},
{ {
path: "datasources", path: "datasources",
label: "数据源 & RAG", label: "数据源 & RAG",
@@ -5,9 +5,11 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"io"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,20 +17,22 @@ import (
"github.com/sundynix/sundynix-gateway/internal/wechat" "github.com/sundynix/sundynix-gateway/internal/wechat"
) )
// 微信公众号(服务号)扫码登录。设计见 internal/wechat/mp.go。 // 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
// //
// 端点: // 端点:
// POST /api/v1/wx/mp/ticket 建 ticket,返回二维码里要放的 URL // POST /api/v1/wx/mp/ticket 建 login ticket + 微信二维码,返回二维码 URL
// GET /wx/mp?t=<ticket> 用户扫码后微信打开这个,302 到微信授权页 // GET /wx/mp/callback 服务器配置 URL 验证(echostr
// GET /api/v1/wx/mp/callback 微信授权后回调,换 openid、找/建用户、置 ticket 已授权 // POST /wx/mp/callback 事件推送:关注/扫码 → 置 ticket 已授权
// GET /api/v1/wx/mp/poll?t= PC 轮询,已授权则签发 JWT // GET /api/v1/wx/mp/poll?t= PC 轮询登录态,已授权则签发 JWT
const ( const (
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表) SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期 wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
) )
// wxTicketState 是 ticket 在 Redis 里的值 // access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)
var wxTokenMu sync.Mutex
type wxTicketState struct { type wxTicketState struct {
Status string `json:"status"` // pending / authorized / consumed Status string `json:"status"` // pending / authorized / consumed
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -46,120 +50,116 @@ func (h *Handler) loadWechatMP(ctx context.Context) wechat.Config {
return c.DecryptFromStore() 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
}
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
}
func newTicket() string { func newTicket() string {
b := make([]byte, 16) b := make([]byte, 16)
_, _ = rand.Read(b) _, _ = rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// WxMPTicket: POST /api/v1/wx/mp/ticket —— 建一个待授权 ticket,返回二维码内容 URL。 // WxMPTicket: POST /api/v1/wx/mp/ticket
func (h *Handler) WxMPTicket(c *gin.Context) { func (h *Handler) WxMPTicket(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context()) ctx := c.Request.Context()
cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() { if !cfg.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
return 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() 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"}) st, _ := json.Marshal(wxTicketState{Status: "pending"})
if err := h.cache.WxTicketSet(c.Request.Context(), ticket, string(st), wxTicketTTL); err != nil { if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"}) c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
return return
} }
// 二维码里放的是本服务的中转地址,不是微信授权地址本身 —— 授权地址带 secret 相关参数, c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
// 且要在用户扫码那一刻才拼(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 授权页 // WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证
// 这是公开路由(微信内置浏览器访问,无鉴权头)。 func (h *Handler) WxMPVerify(c *gin.Context) {
func (h *Handler) WxMPEntry(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
c.String(http.StatusBadRequest, "缺少登录票据")
return
}
cfg := h.loadWechatMP(c.Request.Context()) cfg := h.loadWechatMP(c.Request.Context())
if !cfg.Enabled() { if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusServiceUnavailable, "微信登录未配置") c.String(http.StatusForbidden, "signature check failed")
return return
} }
// ticket 必须存在且仍 pending,否则可能是过期或伪造 c.String(http.StatusOK, c.Query("echostr"))
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 —— 微信授权后回调(公开路由) // WxMPEvent: POST /wx/mp/callback —— 事件推送
// 用 code 换 openid → 找/建用户 → ticket 置 authorized // 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"
func (h *Handler) WxMPCallback(c *gin.Context) { func (h *Handler) WxMPEvent(c *gin.Context) {
code, ticket := c.Query("code"), c.Query("state")
if code == "" || ticket == "" {
c.String(http.StatusBadRequest, "授权参数缺失")
return
}
ctx := c.Request.Context() ctx := c.Request.Context()
// ticket 必须仍有效(防止拿别人的回调套自己的 ticket)
if h.cache.WxTicketGet(ctx, ticket) == "" {
c.String(http.StatusBadRequest, "登录已过期,请在电脑上重新扫码")
return
}
cfg := h.loadWechatMP(ctx) cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() { // 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
c.String(http.StatusServiceUnavailable, "微信登录未配置") if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return return
} }
info, err := cfg.ExchangeCode(ctx, code) body, _ := io.ReadAll(c.Request.Body)
if err != nil { ev, err := wechat.ParseEvent(body)
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err) if err != nil || !ev.IsLoginScan() {
c.String(http.StatusBadGateway, "微信授权失败,请重试") c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
return return
} }
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套) ticket, openID := ev.Scene(), ev.FromUserName
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID) if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
c.String(http.StatusOK, "success")
return
}
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
if err != nil { if err != nil {
c.String(http.StatusBadGateway, "登录失败,请重试") c.String(http.StatusOK, "success")
return return
} }
if u == nil { if u == nil {
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户") u, err = h.db.CreateWechatUser(ctx, openID, "微信用户")
if err != nil { if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err) log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
c.String(http.StatusBadGateway, "登录失败,请重试") c.String(http.StatusOK, "success")
return return
} }
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil { if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e) log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e)
} }
} }
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID}) st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil { _ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
c.String(http.StatusBadGateway, "登录失败,请重试") c.String(http.StatusOK, "success")
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 端轮询登录状态(公开路由)。 // WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
func (h *Handler) WxMPPoll(c *gin.Context) { func (h *Handler) WxMPPoll(c *gin.Context) {
ticket := c.Query("t") ticket := c.Query("t")
if ticket == "" { if ticket == "" {
@@ -178,10 +178,10 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
return return
} }
if st.Status != "authorized" { if st.Status != "authorized" {
c.JSON(http.StatusOK, gin.H{"status": st.Status}) // pending c.JSON(http.StatusOK, gin.H{"status": st.Status})
return return
} }
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌 // 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID}) consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute) _ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
@@ -195,24 +195,21 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
// ---- 管理端配置 ---- // ---- 管理端配置 ----
// AdminGetWechatMP: GET /api/v1/admin/wechat-mp —— 当前配置(secret 不回显,只报有无)。
func (h *Handler) AdminGetWechatMP(c *gin.Context) { func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context()) cfg := h.loadWechatMP(c.Request.Context())
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID, "appid": cfg.AppID,
"base_url": cfg.BaseURL, "token": cfg.Token,
"has_app_secret": cfg.AppSecret != "", "has_app_secret": cfg.AppSecret != "",
"enabled": cfg.Enabled(), "enabled": cfg.Enabled(),
}) })
} }
// AdminSaveWechatMP: PUT /api/v1/admin/wechat-mp —— 保存配置。
// app_secret 留空 = 沿用已存(只写不回显,同微信支付 APIv3 密钥)。
func (h *Handler) AdminSaveWechatMP(c *gin.Context) { func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
var b struct { var b struct {
AppID string `json:"appid"` AppID string `json:"appid"`
AppSecret string `json:"app_secret"` AppSecret string `json:"app_secret"`
BaseURL string `json:"base_url"` Token string `json:"token"`
} }
if err := c.ShouldBindJSON(&b); err != nil { if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
@@ -221,13 +218,9 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
secret := strings.TrimSpace(b.AppSecret) secret := strings.TrimSpace(b.AppSecret)
if secret == "" { if secret == "" {
secret = h.loadWechatMP(ctx).AppSecret // 留空沿用旧密钥(明文,下面统一加密) secret = h.loadWechatMP(ctx).AppSecret
}
cfg := wechat.Config{
AppID: strings.TrimSpace(b.AppID),
AppSecret: secret,
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
} }
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token)}
stored, err := cfg.EncryptedForStore() stored, err := cfg.EncryptedForStore()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
@@ -238,5 +231,6 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return return
} }
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()}) c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
} }
+6 -6
View File
@@ -47,17 +47,17 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/metrics", gin.WrapH(promhttp.Handler()))
r.GET("/healthz", h.Healthz) r.GET("/healthz", h.Healthz)
r.GET("/readyz", h.Readyz) r.GET("/readyz", h.Readyz)
// 微信扫码入口:用户在微信里打开二维码指向的这个地址,服务端 302 到微信授权页 // 微信公众号消息推送回调(服务器配置 URL):GET 验签回 echostrPOST 收关注/扫码事件
// 顶级路径(非 /api——二维码 URL 越短越好,且要落在「网页授权域名」根下 // 顶级路径(非 /api,微信服务器直接访问,无鉴权头
r.GET("/wx/mp", h.WxMPEntry) r.GET("/wx/mp/callback", h.WxMPVerify)
r.POST("/wx/mp/callback", h.WxMPEvent)
api := r.Group("/api/v1") api := r.Group("/api/v1")
{ {
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权) api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
// 微信扫码登录(全公开:ticket 是唯一凭证;微信浏览器/PC 轮询都无鉴权头) // 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头)
api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票,返回二维码 URL api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码
api.GET("/wx/mp/callback", h.WxMPCallback) // 微信授权回调
api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态 api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
+20
View File
@@ -182,6 +182,26 @@ func (r *Redis) WxTicketGet(ctx context.Context, ticket string) string {
return v return v
} }
// WxTokenGet/Set 缓存微信 access_token(跨实例共享,避免重复拉取互相失效)。
// 无 Redis 时返回空 → 调用方每次现拉(单实例开发可接受)。
func (r *Redis) WxTokenGet(ctx context.Context, appID string) string {
if r.rdb == nil {
return ""
}
v, err := r.rdb.Get(ctx, "wxtoken:"+appID).Result()
if err != nil {
return ""
}
return v
}
func (r *Redis) WxTokenSet(ctx context.Context, appID, token string, ttl time.Duration) {
if r.rdb == nil {
return
}
_ = r.rdb.Set(ctx, "wxtoken:"+appID, token, ttl).Err()
}
// Close 释放底层连接。 // Close 释放底层连接。
func (r *Redis) Close() { func (r *Redis) Close() {
if r.rdb != nil { if r.rdb != nil {
+134 -58
View File
@@ -1,45 +1,48 @@
// Package wechat 实现微信公众号(服务号)网页授权登录。 // Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
// //
// 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」, // 登录即引导关注公众号(涨粉),流程:
// 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配 // 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
// 「网页授权域名」,已认证服务号默认具备,副作用最小。 // 2. PC 显示这张微信二维码
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
// 两种都带 openid(FromUserName)
// 5. 后端按 openid 找/建用户 → ticket 置 authorized
// 6. PC 轮询 → 登录完成
// //
// 登录流程(PC 端): // 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。
// 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t=<ticket>
// 2. 用户微信扫码 → 微信内置浏览器打开该 URL → 后端 302 到微信 OAuth 授权页(state=ticket
// 3. 用户「允许」→ 微信回调 /api/v1/wx/mp/callback?code&state → 用 code 换 openid
// 4. openid 找/建用户 → ticket 置 authorized(userID)
// 5. PC 端轮询 ticket → authorized → 签发 JWT
//
// 配置(appid/secret)后台可改、密钥加密入库,与微信支付同一套 secrets。
package wechat package wechat
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strings" "strings"
"time" "time"
"github.com/sundynix/sundynix-shared/secrets" "github.com/sundynix/sundynix-shared/secrets"
) )
// Config 是公众号网页授权所需配置。AppSecret 加密入库只写不回显(同微信支付 APIv3 密钥)。 // Config 是公众号登录所需配置。AppSecret 加密入库只写不回显(同微信支付 APIv3 密钥)。
type Config struct { type Config struct {
AppID string `json:"appid"` AppID string `json:"appid"`
AppSecret string `json:"app_secret"` AppSecret string `json:"app_secret"`
// 授权回调基地址,如 https://agent.sundynix.cn。留空则用请求 Host 推断 // Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致
// 显式配置更稳:微信要求回调域名与「网页授权域名」完全一致,靠 Host 推断在反代下易错。 Token string `json:"token"`
BaseURL string `json:"base_url"`
} }
// Enabled 报告配置是否完整到可用。 // Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" } func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" }
// EncryptedForStore 返回一份 AppSecret 已加密的副本,用于落库。 // EncryptedForStore 返回 AppSecret 已加密的副本,用于落库。
func (c Config) EncryptedForStore() (Config, error) { func (c Config) EncryptedForStore() (Config, error) {
if c.AppSecret == "" { if c.AppSecret == "" {
return c, nil return c, nil
@@ -52,7 +55,7 @@ func (c Config) EncryptedForStore() (Config, error) {
return c, nil return c, nil
} }
// DecryptFromStore 把库内密文 AppSecret 还原为明文(无前缀的历史明文透传) // DecryptFromStore 把库内密文 AppSecret 还原为明文。
func (c Config) DecryptFromStore() Config { func (c Config) DecryptFromStore() Config {
if c.AppSecret != "" { if c.AppSecret != "" {
if plain, err := secrets.Decrypt(c.AppSecret); err == nil { if plain, err := secrets.Decrypt(c.AppSecret); err == nil {
@@ -62,57 +65,130 @@ func (c Config) DecryptFromStore() Config {
return c return c
} }
// AuthorizeURL 构造微信 OAuth 授权跳转地址 // CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))
// scope=snsapi_base:只拿 openid,不弹授权页、用户无感(登录只需要唯一标识,够用了) // 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它
// state 回传我们的 ticket,用于把回调关联回发起登录的那个 PC 会话。 func (c Config) CheckSignature(signature, timestamp, nonce string) bool {
func (c Config) AuthorizeURL(redirectURI, state string) string { if c.Token == "" {
q := url.Values{} return false
q.Set("appid", c.AppID) }
q.Set("redirect_uri", redirectURI) arr := []string{c.Token, timestamp, nonce}
q.Set("response_type", "code") sort.Strings(arr)
q.Set("scope", "snsapi_base") h := sha1.Sum([]byte(strings.Join(arr, "")))
q.Set("state", state) return hex.EncodeToString(h[:]) == signature
// #wechat_redirect 是微信要求的锚点,缺了不跳转
return "https://open.weixin.qq.com/connect/oauth2/authorize?" + q.Encode() + "#wechat_redirect"
} }
// UserInfo 是 code 换取的结果(snsapi_base 下只有 openid)。 // FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。
type UserInfo struct { // 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。
OpenID string func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
}
// ExchangeCode 用授权 code 换 openid(网页授权专用接口,不占 access_token 的 IP 白名单?——
// 实际上仍走 api.weixin.qq.com,出网 IP 必须在白名单里,否则报 40164)。
func (c Config) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
q := url.Values{} q := url.Values{}
q.Set("grant_type", "client_credential")
q.Set("appid", c.AppID) q.Set("appid", c.AppID)
q.Set("secret", c.AppSecret) q.Set("secret", c.AppSecret)
q.Set("code", code) endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode()
q.Set("grant_type", "authorization_code")
endpoint := "https://api.weixin.qq.com/sns/oauth2/access_token?" + q.Encode()
reqCtx, cancel := context.WithTimeout(ctx, 8*time.Second) body, err := httpGet(ctx, endpoint)
defer cancel()
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("请求微信换 openid 失败: %w", err) return "", 0, err
} }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 微信无论成败都返回 200 + JSON;errcode 非 0 才是失败。
var r struct { var r struct {
OpenID string `json:"openid"` AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.AccessToken == "" {
return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
return r.AccessToken, r.ExpiresIn, nil
}
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
reqBody := map[string]any{
"expire_seconds": expireSec,
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
"action_info": map[string]any{"scene": map[string]any{"scene_str": scene}},
}
raw, _ := json.Marshal(reqBody)
endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken)
body, err := httpPost(ctx, endpoint, raw)
if err != nil {
return "", err
}
var r struct {
Ticket string `json:"ticket"`
ErrCode int `json:"errcode"` ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"` ErrMsg string `json:"errmsg"`
} }
if err := json.Unmarshal(body, &r); err != nil { if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("解析微信响应失败: %s", strings.TrimSpace(string(body))) return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body)))
} }
if r.ErrCode != 0 || r.OpenID == "" { if r.ErrCode != 0 || r.Ticket == "" {
// 40163=code 已使用,40029=code 无效,40164=IP 不在白名单——原样带出便于排查 return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
return nil, fmt.Errorf("微信换 openid 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
} }
return &UserInfo{OpenID: r.OpenID}, nil // showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
}
// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。
type Event struct {
XMLName xml.Name `xml:"xml"`
MsgType string `xml:"MsgType"` // event
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>SCAN: <scene>
FromUserName string `xml:"FromUserName"` // 用户 openid
}
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
func (e Event) Scene() string {
return strings.TrimPrefix(e.EventKey, "qrscene_")
}
// IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。
func (e Event) IsLoginScan() bool {
if e.MsgType != "event" {
return false
}
return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != ""
}
// ParseEvent 解析明文事件 XML。
func ParseEvent(body []byte) (*Event, error) {
var e Event
if err := xml.Unmarshal(body, &e); err != nil {
return nil, err
}
return &e, nil
}
// ---- HTTP 小工具 ----
func httpGet(ctx context.Context, endpoint string) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
} }
+89 -30
View File
@@ -1,53 +1,112 @@
package wechat package wechat
import ( import (
"crypto/sha1"
"encoding/hex"
"sort"
"strings" "strings"
"testing" "testing"
) )
func TestConfig_Enabled(t *testing.T) { func TestConfig_Enabled(t *testing.T) {
if (Config{AppID: "x"}).Enabled() { // 三者缺一不可:appid+secret 建二维码,token 验签
t.Fatal("缺 secret 不该 enabled") cases := []struct {
c Config
want bool
}{
{Config{AppID: "a", AppSecret: "s", Token: "t"}, true},
{Config{AppID: "a", AppSecret: "s"}, false},
{Config{AppID: "a", Token: "t"}, false},
{Config{}, false},
} }
if !(Config{AppID: "x", AppSecret: "y"}).Enabled() { for _, tc := range cases {
t.Fatal("齐全应 enabled") if got := tc.c.Enabled(); got != tc.want {
} t.Fatalf("%+v Enabled=%v want %v", tc.c, got, tc.want)
}
func TestAuthorizeURL(t *testing.T) {
c := Config{AppID: "wxAPP"}
u := c.AuthorizeURL("https://a.b/cb", "tkt123")
// 必备参数与微信要求的锚点
for _, want := range []string{
"open.weixin.qq.com/connect/oauth2/authorize",
"appid=wxAPP",
"scope=snsapi_base",
"state=tkt123",
"redirect_uri=https%3A%2F%2Fa.b%2Fcb", // 必须 URL 编码
"#wechat_redirect", // 缺了微信不跳转
} {
if !strings.Contains(u, want) {
t.Fatalf("授权 URL 缺 %q%s", want, u)
} }
} }
// 锚点必须在最后 }
if !strings.HasSuffix(u, "#wechat_redirect") {
t.Fatalf("#wechat_redirect 必须在末尾:%s", u) // 验签必须与微信算法一致:sha1(sort(token,ts,nonce))。
func TestCheckSignature(t *testing.T) {
c := Config{Token: "mytoken"}
ts, nonce := "1700000000", "abc123"
arr := []string{c.Token, ts, nonce}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
good := hex.EncodeToString(sum[:])
if !c.CheckSignature(good, ts, nonce) {
t.Fatal("正确签名应通过")
}
if c.CheckSignature("deadbeef", ts, nonce) {
t.Fatal("错误签名不该通过")
}
if (Config{}).CheckSignature(good, ts, nonce) {
t.Fatal("无 token 一律不通过(防未配置时被绕过)")
}
}
// 事件解析 + scene 提取:subscribe 带 qrscene_ 前缀,SCAN 不带;两者都要能登录。
func TestParseEvent_SubscribeAndScan(t *testing.T) {
subscribe := `<xml><ToUserName><![CDATA[gh_x]]></ToUserName>
<FromUserName><![CDATA[openid_new]]></FromUserName>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<EventKey><![CDATA[qrscene_tkt-abc]]></EventKey></xml>`
ev, err := ParseEvent([]byte(subscribe))
if err != nil {
t.Fatal(err)
}
if !ev.IsLoginScan() {
t.Fatal("subscribe 带 qrscene 应识别为登录扫码")
}
if ev.Scene() != "tkt-abc" {
t.Fatalf("subscribe 应剥掉 qrscene_ 前缀,得 %q", ev.Scene())
}
if ev.FromUserName != "openid_new" {
t.Fatalf("openid 取错:%q", ev.FromUserName)
}
scan := `<xml><FromUserName><![CDATA[openid_old]]></FromUserName>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[SCAN]]></Event>
<EventKey><![CDATA[tkt-def]]></EventKey></xml>`
ev2, _ := ParseEvent([]byte(scan))
if !ev2.IsLoginScan() || ev2.Scene() != "tkt-def" {
t.Fatalf("SCAN 事件应识别,scene=%q", ev2.Scene())
}
}
// 非登录事件(取关、普通消息)不能被当成登录。
func TestParseEvent_IgnoresNonLogin(t *testing.T) {
unsub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[unsubscribe]]></Event></xml>`
ev, _ := ParseEvent([]byte(unsub))
if ev.IsLoginScan() {
t.Fatal("取关事件不该被当成登录")
}
// 无 scene 的 subscribe(用户直接搜号关注,不是扫登录码)也不登录
plainSub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[subscribe]]></Event><EventKey><![CDATA[]]></EventKey></xml>`
ev2, _ := ParseEvent([]byte(plainSub))
if ev2.IsLoginScan() {
t.Fatal("无 scene 的关注不该触发登录")
}
text := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hi]]></Content></xml>`
ev3, _ := ParseEvent([]byte(text))
if ev3.IsLoginScan() {
t.Fatal("普通文本消息不该触发登录")
} }
} }
// secret 加密往返:落库是密文,取出还原成明文。
func TestConfig_SecretRoundTrip(t *testing.T) { func TestConfig_SecretRoundTrip(t *testing.T) {
c := Config{AppID: "x", AppSecret: "the-plain-secret"} c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
stored, err := c.EncryptedForStore() stored, err := c.EncryptedForStore()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if stored.AppSecret == "the-plain-secret" { if stored.AppSecret == "plain-secret" {
t.Fatal("落库的 secret 不该是明文") t.Fatal("落库不该是明文")
} }
back := stored.DecryptFromStore() if back := stored.DecryptFromStore(); back.AppSecret != "plain-secret" {
if back.AppSecret != "the-plain-secret" {
t.Fatalf("还原失败:%q", back.AppSecret) t.Fatalf("还原失败:%q", back.AppSecret)
} }
} }
+19
View File
@@ -76,6 +76,25 @@ export async function authLogin(email: string, password: string): Promise<AuthUs
return d.user; return d.user;
} }
// ---- 微信扫码登录(带参二维码 + 关注/扫码)----
// PC 建票拿到微信二维码图 URL → 展示 → 轮询登录态。全公开接口。
export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" });
return jsonOrThrow<{ ticket: string; qr_image: string; expires_in: number }>(res, "创建登录二维码失败");
}
// 轮询:pending | authorized(带 token/user) | expired | consumed。
export async function wxPoll(ticket: string): Promise<{ status: string; user?: AuthUser }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`);
if (!res.ok) return { status: "expired" };
const d = (await res.json()) as { status?: string; token?: string; user?: AuthUser };
if (d.token && d.user) {
setToken(d.token); // authorized:后端走 issueToken 返回 token+user
return { status: "authorized", user: d.user };
}
return { status: d.status ?? "pending" };
}
export async function authMe(): Promise<AuthUser | null> { export async function authMe(): Promise<AuthUser | null> {
if (!authToken) return null; if (!authToken) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() }); const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
+14
View File
@@ -7,14 +7,24 @@ import { AuthPage } from "./AuthPage";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
authLogin: vi.fn(), authLogin: vi.fn(),
authRegister: vi.fn(), authRegister: vi.fn(),
// 微信是默认 tab:给 wxTicket 一个 pending promise,组件挂载不报错、也不产生副作用。
// 邮箱相关测试会先切到「邮箱」tab。
wxTicket: vi.fn(() => new Promise(() => {})),
wxPoll: vi.fn(() => new Promise(() => {})),
})); }));
import { authLogin, authRegister } from "../api"; import { authLogin, authRegister } from "../api";
// 所有邮箱表单测试的公共前置:默认在微信 tab,先切到邮箱。
async function gotoEmail() {
await userEvent.click(await screen.findByRole("button", { name: "邮箱" }));
}
describe("AuthPage", () => { describe("AuthPage", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("默认登录态:无名字字段,切到注册后出现", async () => { it("默认登录态:无名字字段,切到注册后出现", async () => {
render(<AuthPage onAuthed={vi.fn()} />); render(<AuthPage onAuthed={vi.fn()} />);
await gotoEmail();
// 登录态无「名字」标签 // 登录态无「名字」标签
expect(screen.queryByText(/名字/)).toBeNull(); expect(screen.queryByText(/名字/)).toBeNull();
await userEvent.click(screen.getByText(/还没有账户/)); await userEvent.click(screen.getByText(/还没有账户/));
@@ -26,6 +36,7 @@ describe("AuthPage", () => {
it("邮箱或密码为空时提交按钮禁用", async () => { it("邮箱或密码为空时提交按钮禁用", async () => {
render(<AuthPage onAuthed={vi.fn()} />); render(<AuthPage onAuthed={vi.fn()} />);
await gotoEmail();
const btn = screen.getByRole("button", { name: "登录" }); const btn = screen.getByRole("button", { name: "登录" });
expect(btn).toBeDisabled(); expect(btn).toBeDisabled();
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
@@ -39,6 +50,7 @@ describe("AuthPage", () => {
(authLogin as ReturnType<typeof vi.fn>).mockResolvedValue(user); (authLogin as ReturnType<typeof vi.fn>).mockResolvedValue(user);
const onAuthed = vi.fn(); const onAuthed = vi.fn();
render(<AuthPage onAuthed={onAuthed} />); render(<AuthPage onAuthed={onAuthed} />);
await gotoEmail();
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
await userEvent.type(screen.getByPlaceholderText("••••••"), "pass123"); await userEvent.type(screen.getByPlaceholderText("••••••"), "pass123");
await userEvent.click(screen.getByRole("button", { name: "登录" })); await userEvent.click(screen.getByRole("button", { name: "登录" }));
@@ -51,6 +63,7 @@ describe("AuthPage", () => {
(authLogin as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("邮箱或密码不正确")); (authLogin as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("邮箱或密码不正确"));
const onAuthed = vi.fn(); const onAuthed = vi.fn();
render(<AuthPage onAuthed={onAuthed} />); render(<AuthPage onAuthed={onAuthed} />);
await gotoEmail();
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
await userEvent.type(screen.getByPlaceholderText("••••••"), "wrong"); await userEvent.type(screen.getByPlaceholderText("••••••"), "wrong");
await userEvent.click(screen.getByRole("button", { name: "登录" })); await userEvent.click(screen.getByRole("button", { name: "登录" }));
@@ -63,6 +76,7 @@ describe("AuthPage", () => {
(authRegister as ReturnType<typeof vi.fn>).mockResolvedValue(user); (authRegister as ReturnType<typeof vi.fn>).mockResolvedValue(user);
const onAuthed = vi.fn(); const onAuthed = vi.fn();
render(<AuthPage onAuthed={onAuthed} />); render(<AuthPage onAuthed={onAuthed} />);
await gotoEmail();
await userEvent.click(screen.getByText(/还没有账户/)); await userEvent.click(screen.getByText(/还没有账户/));
await userEvent.type(screen.getByPlaceholderText(/怎么称呼你/), "小明"); await userEvent.type(screen.getByPlaceholderText(/怎么称呼你/), "小明");
await userEvent.type(screen.getByPlaceholderText(/you@example/), "c@d.com"); await userEvent.type(screen.getByPlaceholderText(/you@example/), "c@d.com");
+105 -5
View File
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { authLogin, authRegister, type AuthUser } from "../api"; import { authLogin, authRegister, wxTicket, wxPoll, type AuthUser } from "../api";
import { Button, Field, Input } from "../ui"; import { Button, Field, Input } from "../ui";
// 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。 // 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。
export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
const [tab, setTab] = useState<"wechat" | "email">("wechat");
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -32,9 +33,24 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
<div className="flex h-screen w-screen items-center justify-center bg-ink-950"> <div className="flex h-screen w-screen items-center justify-center bg-ink-950">
<div className="w-[340px] rounded-xl border border-line bg-ink-900 p-6 shadow-card"> <div className="w-[340px] rounded-xl border border-line bg-ink-900 p-6 shadow-card">
<div className="mb-1 text-lg font-semibold tracking-tight text-slate-100">sundynix</div> <div className="mb-1 text-lg font-semibold tracking-tight text-slate-100">sundynix</div>
<p className="mb-5 text-xs text-slate-500"> <p className="mb-4 text-xs text-slate-500"></p>
{mode === "login" ? "登录以管理你的组织、团队与账单" : "注册后自动创建你的个人工作区"}
</p> <div className="mb-4 flex gap-1 rounded-lg bg-ink-950 p-1 text-xs">
{(["wechat", "email"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-md py-1.5 transition ${tab === t ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300"}`}
>
{t === "wechat" ? "微信扫码" : "邮箱"}
</button>
))}
</div>
{tab === "wechat" ? (
<WechatLogin onAuthed={onAuthed} />
) : (
<>
<div className="space-y-3"> <div className="space-y-3">
{mode === "register" && ( {mode === "register" && (
<Field label="名字(可选)"> <Field label="名字(可选)">
@@ -66,7 +82,91 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
className="mt-4 text-xs text-slate-500 transition hover:text-slate-300"> className="mt-4 text-xs text-slate-500 transition hover:text-slate-300">
{mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"} {mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"}
</button> </button>
</>
)}
</div> </div>
</div> </div>
); );
} }
// WechatLogin 公众号扫码登录:建票拿微信二维码图 → 展示 → 轮询。
// 二维码是微信生成的图(showqrcode),扫码后弹关注页,关注即登录。
function WechatLogin({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [expired, setExpired] = useState(false);
const [nonce, setNonce] = useState(0);
const ticketRef = useRef("");
useEffect(() => {
let alive = true;
let timer: number | null = null;
setErr("");
setExpired(false);
setQr("");
void (async () => {
try {
const t = await wxTicket();
if (!alive) return;
ticketRef.current = t.ticket;
setQr(t.qr_image);
const deadline = Date.now() + t.expires_in * 1000;
const tick = async () => {
if (!alive) return;
if (Date.now() > deadline) {
setExpired(true);
return;
}
try {
const r = await wxPoll(ticketRef.current);
if (!alive) return;
if (r.status === "authorized" && r.user) {
onAuthed(r.user);
return;
}
if (r.status === "expired") {
setExpired(true);
return;
}
} catch {
/* 单次失败忽略,继续轮询 */
}
timer = window.setTimeout(() => void tick(), 2000);
};
timer = window.setTimeout(() => void tick(), 2000);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
if (timer) window.clearTimeout(timer);
};
}, [nonce, onAuthed]);
return (
<div className="flex flex-col items-center gap-3 py-2">
{err ? (
<p className="py-10 text-center text-xs text-danger">{err}</p>
) : qr ? (
<div className="relative rounded-lg bg-white p-2">
<img src={qr} alt="微信登录二维码" width={220} height={220} />
{expired && (
<button
onClick={() => setNonce((n) => n + 1)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-lg bg-black/70 text-xs text-white"
>
<span className="rounded bg-white/20 px-2 py-1"></span>
</button>
)}
</div>
) : (
<p className="py-16 text-xs text-slate-500"></p>
)}
<p className="text-center text-xs text-slate-500"></p>
</div>
);
}