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;