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:
@@ -227,6 +227,29 @@ export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
|
||||
}
|
||||
|
||||
// ---- 微信支付配置(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 {
|
||||
mchid: 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||
import { LoginConfigPage } from "./pages/LoginConfigPage";
|
||||
import { lazy, type ReactNode } from "react";
|
||||
|
||||
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
||||
@@ -77,6 +78,13 @@ export const routes: RouteDef[] = [
|
||||
ready: true,
|
||||
element: <ModelConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "login-config",
|
||||
label: "登录设置",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <LoginConfigPage />,
|
||||
},
|
||||
{
|
||||
path: "datasources",
|
||||
label: "数据源 & RAG",
|
||||
|
||||
Reference in New Issue
Block a user