From d4fcdfedb6210cc93527248ebc50b0c2755e28e6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 6 Jul 2026 00:04:47 +0000 Subject: [PATCH] fix --- backend/internal/handler/register_extra.go | 15 ++++- backend/web/src/App.tsx | 19 +++++- backend/web/src/api/haixun.ts | 5 +- backend/web/src/lib/threadsOAuth.ts | 76 ++++++++++++++++++++++ backend/web/src/pages/AccountsPage.tsx | 68 ++++++++++++++++--- deploy/.env.dev.example | 7 +- infra/nginx/haixun-dev.conf | 10 +++ 7 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 backend/web/src/lib/threadsOAuth.ts diff --git a/backend/internal/handler/register_extra.go b/backend/internal/handler/register_extra.go index b1147aa..e973666 100644 --- a/backend/internal/handler/register_extra.go +++ b/backend/internal/handler/register_extra.go @@ -4,13 +4,14 @@ import ( "net/http" "haixun-backend/internal/handler/static" + threads_account "haixun-backend/internal/handler/threads_account" "haixun-backend/internal/svc" "github.com/zeromicro/go-zero/rest" ) // RegisterExtraHandlers mounts routes that are not generated by goctl. -func RegisterExtraHandlers(server *rest.Server, _ *svc.ServiceContext) { +func RegisterExtraHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( []rest.Route{ { @@ -21,4 +22,16 @@ func RegisterExtraHandlers(server *rest.Server, _ *svc.ServiceContext) { }, rest.WithPrefix("/api/v1"), ) + + // Legacy Meta redirect URI (deploy/.env.dev 曾誤設為 /api/threads/oauth/callback)。 + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/oauth/callback", + Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/threads"), + ) } diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx index a2d3406..f47d478 100644 --- a/backend/web/src/App.tsx +++ b/backend/web/src/App.tsx @@ -14,6 +14,8 @@ import { PersonasPage } from "./pages/PersonasPage"; import { PublishPage } from "./pages/PublishPage"; import { SettingsPage } from "./pages/SettingsPage"; import { BrandsPage } from "./pages/BrandsPage"; +import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth"; +import { persistActiveThreadsAccountId } from "./lib/activeAccount"; const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]); @@ -36,6 +38,21 @@ export function App() { return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged); }, []); + useEffect(() => { + if (!member) return; + const result = parseOAuthReturn(window.location.search); + if (!result) return; + + clearOAuthPending(); + stashOAuthReturnMessage(result.message); + if (result.accountId) persistActiveThreadsAccountId(result.accountId); + stripOAuthReturnFromUrl(); + window.location.hash = "accounts"; + setPage("accounts"); + setAccountVersion((value) => value + 1); + window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); + }, [member]); + useEffect(() => { if (!member) { setAccountCount(null); @@ -126,7 +143,7 @@ function AccountRequiredPage({ navigate }: { navigate: (key: string) => void })

請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。

- + ); diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts index 5956284..59c0cae 100644 --- a/backend/web/src/api/haixun.ts +++ b/backend/web/src/api/haixun.ts @@ -842,7 +842,10 @@ export const api = { oauthConfig: () => apiRequest<{ enabled: boolean; redirect_uri: string; success_redirect: string; requires_https: boolean }>("/api/v1/threads-accounts/oauth/config", { auth: true }), oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }), startOauth: (accountId?: string) => - apiRequest<{ authorize_url: string }>(`/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`, { auth: true }), + apiRequest<{ authorize_url: string; account_id?: string; state?: string }>( + `/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`, + { auth: true } + ), smokeTest: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }), threadsDiagnostics: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }), publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }), diff --git a/backend/web/src/lib/threadsOAuth.ts b/backend/web/src/lib/threadsOAuth.ts new file mode 100644 index 0000000..20cd27e --- /dev/null +++ b/backend/web/src/lib/threadsOAuth.ts @@ -0,0 +1,76 @@ +const PENDING_KEY = "threads_oauth_pending"; +const RETURN_MESSAGE_KEY = "haixun.threads_oauth_return_message"; + +export type ThreadsOAuthReturn = { + status: "ok" | "error"; + accountId?: string; + message: string; +}; + +export function markOAuthPending(accountId: string) { + sessionStorage.setItem( + PENDING_KEY, + JSON.stringify({ at: Date.now(), account_id: accountId }) + ); +} + +export function clearOAuthPending() { + sessionStorage.removeItem(PENDING_KEY); +} + +export function pendingOAuthMessage(): string | null { + const raw = sessionStorage.getItem(PENDING_KEY); + if (!raw) return null; + try { + const pending = JSON.parse(raw) as { at?: number }; + if (!pending.at || Date.now() - pending.at > 15 * 60 * 1000) { + clearOAuthPending(); + return null; + } + return "Threads 授權進行中:請在 Meta 頁面完成登入,並等待自動導回巡樓(本機 ngrok 若出現 Visit Site 請點進去)。"; + } catch { + clearOAuthPending(); + return null; + } +} + +export function stashOAuthReturnMessage(message: string) { + sessionStorage.setItem(RETURN_MESSAGE_KEY, message); +} + +export function consumeOAuthReturnMessage(): string | null { + const message = sessionStorage.getItem(RETURN_MESSAGE_KEY); + if (message) sessionStorage.removeItem(RETURN_MESSAGE_KEY); + return message; +} + +export function parseOAuthReturn(search: string): ThreadsOAuthReturn | null { + const params = new URLSearchParams(search); + const status = params.get("threads_oauth"); + if (status !== "ok" && status !== "error") return null; + + const accountId = params.get("account_id") || undefined; + if (status === "ok") { + return { + status: "ok", + accountId, + message: accountId ? "Threads OAuth 授權成功,帳號已連線。" : "Threads OAuth 授權成功。", + }; + } + + const detail = decodeURIComponent((params.get("message") || "未知錯誤").replace(/\+/g, " ")); + return { status: "error", accountId, message: `Threads OAuth 失敗:${detail}` }; +} + +export function stripOAuthReturnFromUrl() { + const params = new URLSearchParams(window.location.search); + if (!params.has("threads_oauth")) return false; + + params.delete("threads_oauth"); + params.delete("account_id"); + params.delete("message"); + const query = params.toString(); + const next = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; + window.history.replaceState({}, "", next); + return true; +} \ No newline at end of file diff --git a/backend/web/src/pages/AccountsPage.tsx b/backend/web/src/pages/AccountsPage.tsx index e9b2da4..00c83f3 100644 --- a/backend/web/src/pages/AccountsPage.tsx +++ b/backend/web/src/pages/AccountsPage.tsx @@ -4,6 +4,11 @@ import { DevToolsPanel } from "../components/DevToolsPanel"; import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui"; import { persistActiveThreadsAccountId } from "../lib/activeAccount"; import { formatNano } from "../lib/format"; +import { + consumeOAuthReturnMessage, + markOAuthPending, + pendingOAuthMessage, +} from "../lib/threadsOAuth"; export function AccountsPage() { const [accounts, setAccounts] = useState([]); @@ -14,6 +19,8 @@ export function AccountsPage() { const [diagnostics, setDiagnostics] = useState(); const [guard, setGuard] = useState(); const [oauthEnabled, setOauthEnabled] = useState(false); + const [oauthRequiresHttps, setOauthRequiresHttps] = useState(false); + const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [apiKey, setApiKey] = useState(""); const [provider, setProvider] = useState("opencode-go"); @@ -39,6 +46,13 @@ export function AccountsPage() { setAccounts(accountData.list || []); setCaps(capData); setOauthEnabled(oauth.enabled); + setOauthRequiresHttps(oauth.requires_https); + const returnMessage = consumeOAuthReturnMessage(); + if (returnMessage) setMessage(returnMessage); + else { + const pendingMessage = pendingOAuthMessage(); + if (pendingMessage) setMessage(pendingMessage); + } const currentActive = preferredActiveId !== undefined ? preferredActiveId : activeId; const id = currentActive || capData.active_threads_account_id || accountData.active_account_id || accountData.list?.[0]?.id || ""; setActiveId(id); @@ -79,12 +93,34 @@ export function AccountsPage() { return () => window.removeEventListener("haixun:placement-settings-changed", onPlacementSettingsChanged); }, []); + async function redirectToOAuth(accountId: string) { + const data = await api.startOauth(accountId); + const targetId = data.account_id || accountId; + markOAuthPending(targetId); + persistActiveThreadsAccountId(targetId); + window.location.href = data.authorize_url; + } + async function createAccount() { - const created = await api.createThreadsAccount({ display_name: newName || "巡樓帳號", activate: accounts.length === 0 }); - setNewName(""); - setActiveId(created.id); - window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); - await load(created.id); + setCreating(true); + setError(undefined); + try { + const created = await api.createThreadsAccount({ display_name: newName || "巡樓帳號", activate: accounts.length === 0 }); + setNewName(""); + setActiveId(created.id); + persistActiveThreadsAccountId(created.id); + window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); + if (oauthEnabled) { + await redirectToOAuth(created.id); + return; + } + setMessage("帳號已建立。後端尚未設定 Threads OAuth(THREADS_APP_ID / SECRET / REDIRECT_URI),請補設定後再按「開始 OAuth」連線。"); + await load(created.id); + } catch (err) { + setError(err); + } finally { + setCreating(false); + } } async function activate(id: string) { @@ -109,8 +145,12 @@ export function AccountsPage() { async function startOauth() { if (!activeId) return; - const data = await api.startOauth(activeId); - window.location.href = data.authorize_url; + setError(undefined); + try { + await redirectToOAuth(activeId); + } catch (err) { + setError(err); + } } async function saveConnection() { @@ -178,7 +218,9 @@ export function AccountsPage() {
setNewName(e.target.value)} placeholder="帳號顯示名稱" /> - +
{accounts.map((account) => (
-

使用 Meta Threads API 發文與讀成效,避免模擬登入風險。

+

使用 Meta Threads API 發文與讀成效,避免模擬登入風險。建立新帳號後會自動導向 Meta 授權(需後端已設定 OAuth)。

+ {!oauthEnabled ? ( +

+ OAuth 尚未設定:請在 deploy/.env.dev(或正式環境變數)填入 THREADS_APP_ID、THREADS_APP_SECRET、THREADS_REDIRECT_URI; + 本機 callback 需 ngrok https,並設定 THREADS_OAUTH_SUCCESS_REDIRECT 指回巡樓(例如 https://threads-tool.30cm.net/#accounts)。 +

+ ) : oauthRequiresHttps ? ( +

Redirect URI 必須是 https(Meta 會拒絕 http callback)。

+ ) : null}
diff --git a/deploy/.env.dev.example b/deploy/.env.dev.example index 261ce03..c4729d4 100644 --- a/deploy/.env.dev.example +++ b/deploy/.env.dev.example @@ -30,8 +30,9 @@ HAIXUN_WORKER_POLL_MS=3000 HAIXUN_8D_TARGET_SAMPLES=4 PLAYWRIGHT_HEADLESS=true -# --- Meta Threads OAuth(dev 可留空;要 OAuth callback 需搭配 ngrok 並填 https URL)--- +# --- Meta Threads OAuth(dev 可留空;要 OAuth callback 需 https URL)--- THREADS_APP_ID= THREADS_APP_SECRET= -THREADS_REDIRECT_URI= -THREADS_OAUTH_SUCCESS_REDIRECT= \ No newline at end of file +# 須與 Meta App Dashboard → Redirect Callback URLs 完全一致 +THREADS_REDIRECT_URI=https://threads-tool.30cm.net/api/v1/threads-accounts/oauth/callback +THREADS_OAUTH_SUCCESS_REDIRECT=https://threads-tool.30cm.net/#accounts \ No newline at end of file diff --git a/infra/nginx/haixun-dev.conf b/infra/nginx/haixun-dev.conf index 63c87ae..fd5e956 100644 --- a/infra/nginx/haixun-dev.conf +++ b/infra/nginx/haixun-dev.conf @@ -66,6 +66,16 @@ server { proxy_send_timeout 3600s; } + # 舊版 Threads OAuth callback(與 /api/v1/threads-accounts/oauth/callback 等價) + location = /api/threads/oauth/callback { + proxy_pass http://haixun_backend/api/v1/threads-accounts/oauth/callback$is_args$args; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # ========== 後端 API ========== location /api/ { proxy_pass http://haixun_backend;