feat/rebone #3
|
|
@ -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"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 OAuth(THREADS_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_ID、THREADS_APP_SECRET、THREADS_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 必須是 https(Meta 會拒絕 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>
|
||||||
|
|
|
||||||
|
|
@ -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 OAuth(dev 可留空;要 OAuth callback 需搭配 ngrok 並填 https URL)---
|
# --- Meta Threads OAuth(dev 可留空;要 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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue