This commit is contained in:
王性驊 2026-07-06 00:04:47 +00:00
parent e75e1b850b
commit d4fcdfedb6
7 changed files with 185 additions and 15 deletions

View File

@ -4,13 +4,14 @@ import (
"net/http" "net/http"
"haixun-backend/internal/handler/static" "haixun-backend/internal/handler/static"
threads_account "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc" "haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/rest"
) )
// RegisterExtraHandlers mounts routes that are not generated by goctl. // 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( server.AddRoutes(
[]rest.Route{ []rest.Route{
{ {
@ -21,4 +22,16 @@ func RegisterExtraHandlers(server *rest.Server, _ *svc.ServiceContext) {
}, },
rest.WithPrefix("/api/v1"), 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"),
)
} }

View File

@ -14,6 +14,8 @@ import { PersonasPage } from "./pages/PersonasPage";
import { PublishPage } from "./pages/PublishPage"; import { PublishPage } from "./pages/PublishPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { BrandsPage } from "./pages/BrandsPage"; 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 validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]);
const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]); const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]);
@ -36,6 +38,21 @@ export function App() {
return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged); 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(() => { useEffect(() => {
if (!member) { if (!member) {
setAccountCount(null); setAccountCount(null);
@ -126,7 +143,7 @@ function AccountRequiredPage({ navigate }: { navigate: (key: string) => void })
<p className="muted"> <p className="muted">
Threads OAuth API Console Threads OAuth API Console
</p> </p>
<Button variant="primary" onClick={() => navigate("accounts")}></Button> <Button variant="primary" onClick={() => navigate("accounts")}> Threads</Button>
</Card> </Card>
</div> </div>
); );

View File

@ -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 }), 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 }), oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }),
startOauth: (accountId?: string) => 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<unknown>(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }), smokeTest: (id: string) => apiRequest<unknown>(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }),
threadsDiagnostics: (id: string) => apiRequest<ThreadsDiagnostics>(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }), threadsDiagnostics: (id: string) => apiRequest<ThreadsDiagnostics>(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }),
publishDashboardSummary: () => apiRequest<PublishDashboardSummary>("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }), publishDashboardSummary: () => apiRequest<PublishDashboardSummary>("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }),

View File

@ -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;
}

View File

@ -4,6 +4,11 @@ import { DevToolsPanel } from "../components/DevToolsPanel";
import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui"; import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui";
import { persistActiveThreadsAccountId } from "../lib/activeAccount"; import { persistActiveThreadsAccountId } from "../lib/activeAccount";
import { formatNano } from "../lib/format"; import { formatNano } from "../lib/format";
import {
consumeOAuthReturnMessage,
markOAuthPending,
pendingOAuthMessage,
} from "../lib/threadsOAuth";
export function AccountsPage() { export function AccountsPage() {
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]); const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
@ -14,6 +19,8 @@ export function AccountsPage() {
const [diagnostics, setDiagnostics] = useState<ThreadsDiagnostics>(); const [diagnostics, setDiagnostics] = useState<ThreadsDiagnostics>();
const [guard, setGuard] = useState<PublishGuardPolicy>(); const [guard, setGuard] = useState<PublishGuardPolicy>();
const [oauthEnabled, setOauthEnabled] = useState(false); const [oauthEnabled, setOauthEnabled] = useState(false);
const [oauthRequiresHttps, setOauthRequiresHttps] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [provider, setProvider] = useState("opencode-go"); const [provider, setProvider] = useState("opencode-go");
@ -39,6 +46,13 @@ export function AccountsPage() {
setAccounts(accountData.list || []); setAccounts(accountData.list || []);
setCaps(capData); setCaps(capData);
setOauthEnabled(oauth.enabled); 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 currentActive = preferredActiveId !== undefined ? preferredActiveId : activeId;
const id = currentActive || capData.active_threads_account_id || accountData.active_account_id || accountData.list?.[0]?.id || ""; const id = currentActive || capData.active_threads_account_id || accountData.active_account_id || accountData.list?.[0]?.id || "";
setActiveId(id); setActiveId(id);
@ -79,12 +93,34 @@ export function AccountsPage() {
return () => window.removeEventListener("haixun:placement-settings-changed", onPlacementSettingsChanged); 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() { async function createAccount() {
setCreating(true);
setError(undefined);
try {
const created = await api.createThreadsAccount({ display_name: newName || "巡樓帳號", activate: accounts.length === 0 }); const created = await api.createThreadsAccount({ display_name: newName || "巡樓帳號", activate: accounts.length === 0 });
setNewName(""); setNewName("");
setActiveId(created.id); setActiveId(created.id);
persistActiveThreadsAccountId(created.id);
window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); window.dispatchEvent(new CustomEvent("haixun:active-account-changed"));
if (oauthEnabled) {
await redirectToOAuth(created.id);
return;
}
setMessage("帳號已建立。後端尚未設定 Threads OAuthTHREADS_APP_ID / SECRET / REDIRECT_URI請補設定後再按「開始 OAuth」連線。");
await load(created.id); await load(created.id);
} catch (err) {
setError(err);
} finally {
setCreating(false);
}
} }
async function activate(id: string) { async function activate(id: string) {
@ -109,8 +145,12 @@ export function AccountsPage() {
async function startOauth() { async function startOauth() {
if (!activeId) return; if (!activeId) return;
const data = await api.startOauth(activeId); setError(undefined);
window.location.href = data.authorize_url; try {
await redirectToOAuth(activeId);
} catch (err) {
setError(err);
}
} }
async function saveConnection() { async function saveConnection() {
@ -178,7 +218,9 @@ export function AccountsPage() {
<div className="page-grid"> <div className="page-grid">
<div className="button-row"> <div className="button-row">
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="帳號顯示名稱" /> <Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="帳號顯示名稱" />
<Button variant="primary" onClick={() => void createAccount()}></Button> <Button variant="primary" loading={creating} onClick={() => void createAccount()}>
{oauthEnabled ? "建立並連線 Threads" : "建立帳號"}
</Button>
</div> </div>
{accounts.map((account) => ( {accounts.map((account) => (
<div <div
@ -236,7 +278,15 @@ export function AccountsPage() {
<div className="grid-2"> <div className="grid-2">
<Card title="OAuth / API 連線"> <Card title="OAuth / API 連線">
<div className="page-grid"> <div className="page-grid">
<p className="muted">使 Meta Threads API </p> <p className="muted">使 Meta Threads API Meta OAuth</p>
{!oauthEnabled ? (
<p className="muted hx-text-sm" style={{ margin: 0 }}>
OAuth <code>deploy/.env.dev</code> THREADS_APP_IDTHREADS_APP_SECRETTHREADS_REDIRECT_URI
callback ngrok https THREADS_OAUTH_SUCCESS_REDIRECT <code>https://threads-tool.30cm.net/#accounts</code>)。
</p>
) : oauthRequiresHttps ? (
<p className="muted hx-text-sm" style={{ margin: 0 }}>Redirect URI httpsMeta http callback</p>
) : null}
<div className="button-row"> <div className="button-row">
<Button variant="primary" disabled={!activeId || !oauthEnabled} onClick={() => void startOauth()}> OAuth</Button> <Button variant="primary" disabled={!activeId || !oauthEnabled} onClick={() => void startOauth()}> OAuth</Button>
<Button disabled={!activeId} onClick={() => void smokeTest()}> API smoke test</Button> <Button disabled={!activeId} onClick={() => void smokeTest()}> API smoke test</Button>

View File

@ -30,8 +30,9 @@ HAIXUN_WORKER_POLL_MS=3000
HAIXUN_8D_TARGET_SAMPLES=4 HAIXUN_8D_TARGET_SAMPLES=4
PLAYWRIGHT_HEADLESS=true PLAYWRIGHT_HEADLESS=true
# --- Meta Threads OAuthdev 可留空;要 OAuth callback 需搭配 ngrok 並填 https URL--- # --- Meta Threads OAuthdev 可留空;要 OAuth callback 需 https URL---
THREADS_APP_ID= THREADS_APP_ID=
THREADS_APP_SECRET= THREADS_APP_SECRET=
THREADS_REDIRECT_URI= # 須與 Meta App Dashboard → Redirect Callback URLs 完全一致
THREADS_OAUTH_SUCCESS_REDIRECT= THREADS_REDIRECT_URI=https://threads-tool.30cm.net/api/v1/threads-accounts/oauth/callback
THREADS_OAUTH_SUCCESS_REDIRECT=https://threads-tool.30cm.net/#accounts

View File

@ -66,6 +66,16 @@ server {
proxy_send_timeout 3600s; 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 ========== # ========== 後端 API ==========
location /api/ { location /api/ {
proxy_pass http://haixun_backend; proxy_pass http://haixun_backend;