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:
@@ -76,6 +76,25 @@ export async function authLogin(email: string, password: string): Promise<AuthUs
|
||||
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> {
|
||||
if (!authToken) return null;
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
|
||||
|
||||
@@ -7,14 +7,24 @@ import { AuthPage } from "./AuthPage";
|
||||
vi.mock("../api", () => ({
|
||||
authLogin: 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";
|
||||
|
||||
// 所有邮箱表单测试的公共前置:默认在微信 tab,先切到邮箱。
|
||||
async function gotoEmail() {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "邮箱" }));
|
||||
}
|
||||
|
||||
describe("AuthPage", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("默认登录态:无名字字段,切到注册后出现", async () => {
|
||||
render(<AuthPage onAuthed={vi.fn()} />);
|
||||
await gotoEmail();
|
||||
// 登录态无「名字」标签
|
||||
expect(screen.queryByText(/名字/)).toBeNull();
|
||||
await userEvent.click(screen.getByText(/还没有账户/));
|
||||
@@ -26,6 +36,7 @@ describe("AuthPage", () => {
|
||||
|
||||
it("邮箱或密码为空时提交按钮禁用", async () => {
|
||||
render(<AuthPage onAuthed={vi.fn()} />);
|
||||
await gotoEmail();
|
||||
const btn = screen.getByRole("button", { name: "登录" });
|
||||
expect(btn).toBeDisabled();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
@@ -39,6 +50,7 @@ describe("AuthPage", () => {
|
||||
(authLogin as ReturnType<typeof vi.fn>).mockResolvedValue(user);
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
await userEvent.type(screen.getByPlaceholderText("••••••"), "pass123");
|
||||
await userEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||
@@ -51,6 +63,7 @@ describe("AuthPage", () => {
|
||||
(authLogin as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("邮箱或密码不正确"));
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com");
|
||||
await userEvent.type(screen.getByPlaceholderText("••••••"), "wrong");
|
||||
await userEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||
@@ -63,6 +76,7 @@ describe("AuthPage", () => {
|
||||
(authRegister as ReturnType<typeof vi.fn>).mockResolvedValue(user);
|
||||
const onAuthed = vi.fn();
|
||||
render(<AuthPage onAuthed={onAuthed} />);
|
||||
await gotoEmail();
|
||||
await userEvent.click(screen.getByText(/还没有账户/));
|
||||
await userEvent.type(screen.getByPlaceholderText(/怎么称呼你/), "小明");
|
||||
await userEvent.type(screen.getByPlaceholderText(/you@example/), "c@d.com");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { authLogin, authRegister, type AuthUser } from "../api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authLogin, authRegister, wxTicket, wxPoll, type AuthUser } from "../api";
|
||||
import { Button, Field, Input } from "../ui";
|
||||
|
||||
// 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。
|
||||
export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||
const [tab, setTab] = useState<"wechat" | "email">("wechat");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = 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="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>
|
||||
<p className="mb-5 text-xs text-slate-500">
|
||||
{mode === "login" ? "登录以管理你的组织、团队与账单" : "注册后自动创建你的个人工作区"}
|
||||
</p>
|
||||
<p className="mb-4 text-xs text-slate-500">登录以管理你的组织、团队与账单</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">
|
||||
{mode === "register" && (
|
||||
<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">
|
||||
{mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user