optimize: WeChat QR code auth flow and channel reliability
- Generate QR code server-side when iLink API returns SPA page URL (added qrcode + image crates for PNG generation) - Strip bot_token from frontend response (new WeixinQrcodeStatusPublic type) - Add request timeouts and shared HTTP client for QR code endpoints - Fix TOCTOU race on reply_context double-lock (single lock scope) - Extract do_send() helper to deduplicate sendmessage logic; resend now checks ret field for context expiry instead of HTTP status only - Cap pending_messages buffer at 50 to prevent unbounded memory growth - Generate stable X-WECHAT-UIN per backend instance instead of per request - Extract ILINK_CHANNEL_VERSION constant (was hardcoded in 4 places) - Add 5-minute client-side QR expiry timeout in frontend dialog - Track consecutive polling errors and show warning after 3 failures - Stabilise onAuthSuccess/onClose callback refs to prevent polling restarts - Replace dead i18n key weixinOpenQrcode with weixinPollError Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ExternalLink, Loader2, RefreshCw } from "lucide-react"
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api"
|
||||
|
||||
/** Client-side QR code expiry (5 minutes). */
|
||||
const QR_EXPIRY_MS = 5 * 60 * 1000
|
||||
/** Show a warning after this many consecutive polling failures. */
|
||||
const POLL_ERROR_WARN_THRESHOLD = 3
|
||||
|
||||
interface WeixinQrcodeDialogProps {
|
||||
open: boolean
|
||||
channelId: number
|
||||
@@ -31,29 +36,40 @@ function WeixinQrcodeContent({
|
||||
}) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
const [qrcodeImg, setQrcodeImg] = useState<string | null>(null)
|
||||
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null)
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
const [qrcodeId, setQrcodeId] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<"loading" | "waiting" | "expired">(
|
||||
"loading"
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pollErrors, setPollErrors] = useState(0)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Stabilise callbacks via ref so the polling effect doesn't re-trigger
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess)
|
||||
const onCloseRef = useRef(onClose)
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess
|
||||
onCloseRef.current = onClose
|
||||
})
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
if (expiryRef.current) {
|
||||
clearTimeout(expiryRef.current)
|
||||
expiryRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchQrcode = useCallback(async () => {
|
||||
setStatus("loading")
|
||||
setError(null)
|
||||
setQrcodeImg(null)
|
||||
setQrcodeUrl(null)
|
||||
setImgFailed(false)
|
||||
setQrcodeId(null)
|
||||
setPollErrors(0)
|
||||
stopPolling()
|
||||
|
||||
try {
|
||||
@@ -62,15 +78,9 @@ function WeixinQrcodeContent({
|
||||
|
||||
if (result.qrcode_img_content) {
|
||||
const raw = result.qrcode_img_content
|
||||
// Keep the original URL for fallback link
|
||||
if (raw.startsWith("http")) {
|
||||
setQrcodeUrl(raw)
|
||||
}
|
||||
const imgSrc = raw.startsWith("data:")
|
||||
? raw
|
||||
: raw.startsWith("http")
|
||||
? raw
|
||||
: `data:image/png;base64,${raw}`
|
||||
: `data:image/png;base64,${raw}`
|
||||
setQrcodeImg(imgSrc)
|
||||
}
|
||||
|
||||
@@ -82,35 +92,42 @@ function WeixinQrcodeContent({
|
||||
}
|
||||
}, [stopPolling])
|
||||
|
||||
// Fetch QR code on mount + cleanup polling on unmount
|
||||
// Fetch QR code on mount + cleanup on unmount
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
|
||||
fetchQrcode()
|
||||
return () => stopPolling()
|
||||
}, [fetchQrcode, stopPolling])
|
||||
|
||||
// Start polling when we have a qrcodeId
|
||||
// Start polling + expiry timer when we have a qrcodeId
|
||||
useEffect(() => {
|
||||
if (!qrcodeId || status !== "waiting") return
|
||||
|
||||
// Client-side expiry guard
|
||||
expiryRef.current = setTimeout(() => {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}, QR_EXPIRY_MS)
|
||||
|
||||
pollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await weixinCheckQrcode(channelId, qrcodeId)
|
||||
setPollErrors(0)
|
||||
if (result.status === "confirmed") {
|
||||
stopPolling()
|
||||
onAuthSuccess(channelId)
|
||||
onClose()
|
||||
onAuthSuccessRef.current(channelId)
|
||||
onCloseRef.current()
|
||||
} else if (result.status === "expired") {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}
|
||||
} catch {
|
||||
// Polling error — keep trying
|
||||
setPollErrors((n) => n + 1)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => stopPolling()
|
||||
}, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose])
|
||||
}, [qrcodeId, status, channelId, stopPolling])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
@@ -126,32 +143,23 @@ function WeixinQrcodeContent({
|
||||
|
||||
{status === "waiting" && qrcodeImg && (
|
||||
<>
|
||||
{!imgFailed ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrcodeImg}
|
||||
alt="WeChat QR Code"
|
||||
className="h-48 w-48 rounded-md"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
</>
|
||||
) : qrcodeUrl ? (
|
||||
<a
|
||||
href={qrcodeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-48 w-48 flex-col items-center justify-center gap-2 rounded-md border border-dashed text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="h-6 w-6" />
|
||||
{t("weixinOpenQrcode")}
|
||||
</a>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrcodeImg}
|
||||
alt="WeChat QR Code"
|
||||
className="h-48 w-48 rounded-md"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t("weixinWaitingScan")}
|
||||
</p>
|
||||
{pollErrors >= POLL_ERROR_WARN_THRESHOLD && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/5 px-3 py-1.5 text-xs text-yellow-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{t("weixinPollError")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "انتهت صلاحية رمز QR.",
|
||||
"weixinRefreshQrcode": "تحديث",
|
||||
"weixinWaitingScan": "في انتظار المسح...",
|
||||
"weixinOpenQrcode": "فتح رمز QR في المتصفح",
|
||||
"weixinPollError": "الاتصال غير مستقر، جاري إعادة المحاولة...",
|
||||
"connect": "اتصال",
|
||||
"disconnect": "قطع الاتصال",
|
||||
"test": "اختبار الاتصال",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR-Code abgelaufen.",
|
||||
"weixinRefreshQrcode": "Aktualisieren",
|
||||
"weixinWaitingScan": "Warten auf Scan...",
|
||||
"weixinOpenQrcode": "QR-Code im Browser öffnen",
|
||||
"weixinPollError": "Verbindung instabil, erneuter Versuch...",
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Trennen",
|
||||
"test": "Verbindung testen",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR code expired.",
|
||||
"weixinRefreshQrcode": "Refresh",
|
||||
"weixinWaitingScan": "Waiting for scan...",
|
||||
"weixinOpenQrcode": "Open QR code in browser",
|
||||
"weixinPollError": "Connection unstable, retrying...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"test": "Test Connection",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "El código QR ha expirado.",
|
||||
"weixinRefreshQrcode": "Actualizar",
|
||||
"weixinWaitingScan": "Esperando escaneo...",
|
||||
"weixinOpenQrcode": "Abrir código QR en el navegador",
|
||||
"weixinPollError": "Conexión inestable, reintentando...",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"test": "Probar conexión",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR code expiré.",
|
||||
"weixinRefreshQrcode": "Actualiser",
|
||||
"weixinWaitingScan": "En attente du scan...",
|
||||
"weixinOpenQrcode": "Ouvrir le QR code dans le navigateur",
|
||||
"weixinPollError": "Connexion instable, nouvelle tentative...",
|
||||
"connect": "Connecter",
|
||||
"disconnect": "Déconnecter",
|
||||
"test": "Tester la connexion",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QRコードの有効期限が切れました。",
|
||||
"weixinRefreshQrcode": "更新",
|
||||
"weixinWaitingScan": "スキャン待ち...",
|
||||
"weixinOpenQrcode": "ブラウザでQRコードを開く",
|
||||
"weixinPollError": "接続が不安定です。再試行中...",
|
||||
"connect": "接続",
|
||||
"disconnect": "切断",
|
||||
"test": "接続テスト",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
|
||||
"weixinRefreshQrcode": "새로고침",
|
||||
"weixinWaitingScan": "스캔 대기 중...",
|
||||
"weixinOpenQrcode": "브라우저에서 QR 코드 열기",
|
||||
"weixinPollError": "연결이 불안정합니다. 재시도 중...",
|
||||
"connect": "연결",
|
||||
"disconnect": "연결 해제",
|
||||
"test": "연결 테스트",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "Código QR expirado.",
|
||||
"weixinRefreshQrcode": "Atualizar",
|
||||
"weixinWaitingScan": "Aguardando escaneamento...",
|
||||
"weixinOpenQrcode": "Abrir código QR no navegador",
|
||||
"weixinPollError": "Conexão instável, tentando novamente...",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"test": "Testar conexão",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "二维码已过期。",
|
||||
"weixinRefreshQrcode": "刷新二维码",
|
||||
"weixinWaitingScan": "等待扫码...",
|
||||
"weixinOpenQrcode": "在浏览器中打开二维码",
|
||||
"weixinPollError": "连接不稳定,正在重试...",
|
||||
"connect": "连接",
|
||||
"disconnect": "断开",
|
||||
"test": "测试连接",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "二維碼已過期。",
|
||||
"weixinRefreshQrcode": "重新整理",
|
||||
"weixinWaitingScan": "等待掃碼...",
|
||||
"weixinOpenQrcode": "在瀏覽器中打開二維碼",
|
||||
"weixinPollError": "連接不穩定,正在重試...",
|
||||
"connect": "連線",
|
||||
"disconnect": "斷開",
|
||||
"test": "測試連線",
|
||||
|
||||
@@ -1455,8 +1455,6 @@ export async function weixinCheckQrcode(
|
||||
qrcode: string
|
||||
): Promise<{
|
||||
status: string
|
||||
bot_token?: string
|
||||
base_url?: string
|
||||
}> {
|
||||
return getTransport().call("weixin_check_qrcode", { channelId, qrcode })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user