- {shortJobTitle(job)}
+ {originPath ? (
+
+ {title}
+
+ ) : (
+ {title}
+ )}
{jobStatusLabel(job.status)}
-
- Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
-
+
{job.progress?.percentage ?? 0}%
{canCancel ? (
@@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
})}
- {job.status === 'succeeded' && job.template_type === 'expand-graph' ? (
+ {originPath ? (
- 前往研究頁勾選 tag →
+ {jobOriginLinkLabel(job)}
) : null}
- {job.status === 'succeeded' && job.template_type === 'placement-scan' ? (
-
- 前往獲客台 →
-
- ) : null}
-
-
- 查看完整事件與原始資料
-
)
}
@@ -330,8 +294,8 @@ export function JobMonitor() {
})
}, [])
- const load = useCallback(async () => {
- setLoading(true)
+ const load = useCallback(async (opts?: { silent?: boolean }) => {
+ if (!opts?.silent) setLoading(true)
try {
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
auth: true,
@@ -343,15 +307,36 @@ export function JobMonitor() {
} catch {
// 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
} finally {
- setLoading(false)
+ if (!opts?.silent) setLoading(false)
}
}, [])
+ const hasActiveJob = jobs.some((job) => isActiveJobStatus(job.status))
+
useEffect(() => {
load().catch(() => undefined)
- const timer = window.setInterval(() => load().catch(() => undefined), 2000)
- return () => window.clearInterval(timer)
- }, [load])
+ // 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。
+ const intervalMs = hasActiveJob ? 2000 : 8000
+ const timer = window.setInterval(() => {
+ if (document.visibilityState === 'hidden') return
+ load({ silent: true }).catch(() => undefined)
+ }, intervalMs)
+
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') load({ silent: true }).catch(() => undefined)
+ }
+ const onJobCreated = () => {
+ load({ silent: true }).catch(() => undefined)
+ }
+ document.addEventListener('visibilitychange', onVisible)
+ window.addEventListener(JOB_MONITOR_REFRESH, onJobCreated)
+
+ return () => {
+ window.clearInterval(timer)
+ document.removeEventListener('visibilitychange', onVisible)
+ window.removeEventListener(JOB_MONITOR_REFRESH, onJobCreated)
+ }
+ }, [load, hasActiveJob])
const syncClampedPosition = useCallback(() => {
setPosition((prev) => {
@@ -426,7 +411,7 @@ export function JobMonitor() {
const cancelJob = async (id: string) => {
await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
- await load()
+ await load({ silent: true })
}
const dockTone =
@@ -450,9 +435,7 @@ export function JobMonitor() {
{activeCount > 0 && !expanded && current ? (
-
- {current.progress?.summary || phaseHint(current)}
-
+
{jobActivitySummary(current)}
) : null}
diff --git a/web/src/components/KnowledgeGraphPanel.tsx b/frontend/src/components/KnowledgeGraphPanel.tsx
similarity index 100%
rename from web/src/components/KnowledgeGraphPanel.tsx
rename to frontend/src/components/KnowledgeGraphPanel.tsx
diff --git a/web/src/components/KnowledgeLayerRipple.tsx b/frontend/src/components/KnowledgeLayerRipple.tsx
similarity index 100%
rename from web/src/components/KnowledgeLayerRipple.tsx
rename to frontend/src/components/KnowledgeLayerRipple.tsx
diff --git a/web/src/components/KnowledgeOrbitDiagram.tsx b/frontend/src/components/KnowledgeOrbitDiagram.tsx
similarity index 100%
rename from web/src/components/KnowledgeOrbitDiagram.tsx
rename to frontend/src/components/KnowledgeOrbitDiagram.tsx
diff --git a/web/src/components/KnowledgeOverviewPanel.tsx b/frontend/src/components/KnowledgeOverviewPanel.tsx
similarity index 100%
rename from web/src/components/KnowledgeOverviewPanel.tsx
rename to frontend/src/components/KnowledgeOverviewPanel.tsx
diff --git a/web/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
similarity index 100%
rename from web/src/components/Layout.tsx
rename to frontend/src/components/Layout.tsx
diff --git a/web/src/components/LegacyBrandRouteRedirect.tsx b/frontend/src/components/LegacyBrandRouteRedirect.tsx
similarity index 100%
rename from web/src/components/LegacyBrandRouteRedirect.tsx
rename to frontend/src/components/LegacyBrandRouteRedirect.tsx
diff --git a/web/src/components/MatrixEntryRoute.tsx b/frontend/src/components/MatrixEntryRoute.tsx
similarity index 100%
rename from web/src/components/MatrixEntryRoute.tsx
rename to frontend/src/components/MatrixEntryRoute.tsx
diff --git a/web/src/components/MemberMenu.tsx b/frontend/src/components/MemberMenu.tsx
similarity index 100%
rename from web/src/components/MemberMenu.tsx
rename to frontend/src/components/MemberMenu.tsx
diff --git a/web/src/components/MobileBottomNav.tsx b/frontend/src/components/MobileBottomNav.tsx
similarity index 100%
rename from web/src/components/MobileBottomNav.tsx
rename to frontend/src/components/MobileBottomNav.tsx
diff --git a/web/src/components/OnboardingBanner.tsx b/frontend/src/components/OnboardingBanner.tsx
similarity index 100%
rename from web/src/components/OnboardingBanner.tsx
rename to frontend/src/components/OnboardingBanner.tsx
diff --git a/web/src/components/OnboardingGuide.tsx b/frontend/src/components/OnboardingGuide.tsx
similarity index 100%
rename from web/src/components/OnboardingGuide.tsx
rename to frontend/src/components/OnboardingGuide.tsx
diff --git a/web/src/components/OnboardingRouteGuard.tsx b/frontend/src/components/OnboardingRouteGuard.tsx
similarity index 82%
rename from web/src/components/OnboardingRouteGuard.tsx
rename to frontend/src/components/OnboardingRouteGuard.tsx
index 1b1e433..d943d69 100644
--- a/web/src/components/OnboardingRouteGuard.tsx
+++ b/frontend/src/components/OnboardingRouteGuard.tsx
@@ -7,7 +7,9 @@ export function OnboardingRouteGuard({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const { loading, isComplete } = useOnboarding()
- const redirectTo = !loading ? onboardingRedirectPath(pathname, isComplete) : null
+ if (loading) return children
+
+ const redirectTo = onboardingRedirectPath(pathname, isComplete)
if (redirectTo) return
return children
diff --git a/web/src/components/OutreachEntryRoute.tsx b/frontend/src/components/OutreachEntryRoute.tsx
similarity index 100%
rename from web/src/components/OutreachEntryRoute.tsx
rename to frontend/src/components/OutreachEntryRoute.tsx
diff --git a/web/src/components/PatrolKeywordsEditor.tsx b/frontend/src/components/PatrolKeywordsEditor.tsx
similarity index 100%
rename from web/src/components/PatrolKeywordsEditor.tsx
rename to frontend/src/components/PatrolKeywordsEditor.tsx
diff --git a/web/src/components/PatrolNodeTagsEditor.tsx b/frontend/src/components/PatrolNodeTagsEditor.tsx
similarity index 100%
rename from web/src/components/PatrolNodeTagsEditor.tsx
rename to frontend/src/components/PatrolNodeTagsEditor.tsx
diff --git a/web/src/components/PersonaIslandArt.tsx b/frontend/src/components/PersonaIslandArt.tsx
similarity index 100%
rename from web/src/components/PersonaIslandArt.tsx
rename to frontend/src/components/PersonaIslandArt.tsx
diff --git a/web/src/components/PersonaScanSchedulePanel.tsx b/frontend/src/components/PersonaScanSchedulePanel.tsx
similarity index 100%
rename from web/src/components/PersonaScanSchedulePanel.tsx
rename to frontend/src/components/PersonaScanSchedulePanel.tsx
diff --git a/web/src/components/PlacementFlowNav.tsx b/frontend/src/components/PlacementFlowNav.tsx
similarity index 100%
rename from web/src/components/PlacementFlowNav.tsx
rename to frontend/src/components/PlacementFlowNav.tsx
diff --git a/web/src/components/PlacementFlowPipeline.tsx b/frontend/src/components/PlacementFlowPipeline.tsx
similarity index 100%
rename from web/src/components/PlacementFlowPipeline.tsx
rename to frontend/src/components/PlacementFlowPipeline.tsx
diff --git a/web/src/components/PlacementResearchSettings.tsx b/frontend/src/components/PlacementResearchSettings.tsx
similarity index 100%
rename from web/src/components/PlacementResearchSettings.tsx
rename to frontend/src/components/PlacementResearchSettings.tsx
diff --git a/web/src/components/PlacementScanJobPanel.tsx b/frontend/src/components/PlacementScanJobPanel.tsx
similarity index 100%
rename from web/src/components/PlacementScanJobPanel.tsx
rename to frontend/src/components/PlacementScanJobPanel.tsx
diff --git a/web/src/components/ProductContextForm.tsx b/frontend/src/components/ProductContextForm.tsx
similarity index 100%
rename from web/src/components/ProductContextForm.tsx
rename to frontend/src/components/ProductContextForm.tsx
diff --git a/web/src/components/ProductItemForm.tsx b/frontend/src/components/ProductItemForm.tsx
similarity index 100%
rename from web/src/components/ProductItemForm.tsx
rename to frontend/src/components/ProductItemForm.tsx
diff --git a/web/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
similarity index 100%
rename from web/src/components/ProtectedRoute.tsx
rename to frontend/src/components/ProtectedRoute.tsx
diff --git a/web/src/components/RegisterForm.tsx b/frontend/src/components/RegisterForm.tsx
similarity index 100%
rename from web/src/components/RegisterForm.tsx
rename to frontend/src/components/RegisterForm.tsx
diff --git a/web/src/components/ReplyPersonaPicker.tsx b/frontend/src/components/ReplyPersonaPicker.tsx
similarity index 100%
rename from web/src/components/ReplyPersonaPicker.tsx
rename to frontend/src/components/ReplyPersonaPicker.tsx
diff --git a/web/src/components/ResearchMapEditor.tsx b/frontend/src/components/ResearchMapEditor.tsx
similarity index 100%
rename from web/src/components/ResearchMapEditor.tsx
rename to frontend/src/components/ResearchMapEditor.tsx
diff --git a/web/src/components/ResearchMapOverview.tsx b/frontend/src/components/ResearchMapOverview.tsx
similarity index 100%
rename from web/src/components/ResearchMapOverview.tsx
rename to frontend/src/components/ResearchMapOverview.tsx
diff --git a/web/src/components/ResearchMapPanel.tsx b/frontend/src/components/ResearchMapPanel.tsx
similarity index 100%
rename from web/src/components/ResearchMapPanel.tsx
rename to frontend/src/components/ResearchMapPanel.tsx
diff --git a/web/src/components/ScheduleFrequencyPicker.tsx b/frontend/src/components/ScheduleFrequencyPicker.tsx
similarity index 100%
rename from web/src/components/ScheduleFrequencyPicker.tsx
rename to frontend/src/components/ScheduleFrequencyPicker.tsx
diff --git a/web/src/components/SourceLinkPreview.tsx b/frontend/src/components/SourceLinkPreview.tsx
similarity index 100%
rename from web/src/components/SourceLinkPreview.tsx
rename to frontend/src/components/SourceLinkPreview.tsx
diff --git a/web/src/components/StringListEditor.tsx b/frontend/src/components/StringListEditor.tsx
similarity index 100%
rename from web/src/components/StringListEditor.tsx
rename to frontend/src/components/StringListEditor.tsx
diff --git a/web/src/components/Style8DAnalysisMeta.tsx b/frontend/src/components/Style8DAnalysisMeta.tsx
similarity index 100%
rename from web/src/components/Style8DAnalysisMeta.tsx
rename to frontend/src/components/Style8DAnalysisMeta.tsx
diff --git a/web/src/components/Style8DDimensionEditor.tsx b/frontend/src/components/Style8DDimensionEditor.tsx
similarity index 100%
rename from web/src/components/Style8DDimensionEditor.tsx
rename to frontend/src/components/Style8DDimensionEditor.tsx
diff --git a/web/src/components/Style8DJobPanel.tsx b/frontend/src/components/Style8DJobPanel.tsx
similarity index 100%
rename from web/src/components/Style8DJobPanel.tsx
rename to frontend/src/components/Style8DJobPanel.tsx
diff --git a/web/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx
similarity index 100%
rename from web/src/components/ThemeToggle.tsx
rename to frontend/src/components/ThemeToggle.tsx
diff --git a/web/src/components/ThreadsAccountWorkspace.tsx b/frontend/src/components/ThreadsAccountWorkspace.tsx
similarity index 100%
rename from web/src/components/ThreadsAccountWorkspace.tsx
rename to frontend/src/components/ThreadsAccountWorkspace.tsx
diff --git a/web/src/components/ThreadsPostCharMeter.tsx b/frontend/src/components/ThreadsPostCharMeter.tsx
similarity index 100%
rename from web/src/components/ThreadsPostCharMeter.tsx
rename to frontend/src/components/ThreadsPostCharMeter.tsx
diff --git a/web/src/components/TopicSwitcher.tsx b/frontend/src/components/TopicSwitcher.tsx
similarity index 100%
rename from web/src/components/TopicSwitcher.tsx
rename to frontend/src/components/TopicSwitcher.tsx
diff --git a/web/src/components/VerifiedAuthorBadge.tsx b/frontend/src/components/VerifiedAuthorBadge.tsx
similarity index 100%
rename from web/src/components/VerifiedAuthorBadge.tsx
rename to frontend/src/components/VerifiedAuthorBadge.tsx
diff --git a/web/src/components/ViralScanJobPanel.tsx b/frontend/src/components/ViralScanJobPanel.tsx
similarity index 100%
rename from web/src/components/ViralScanJobPanel.tsx
rename to frontend/src/components/ViralScanJobPanel.tsx
diff --git a/web/src/components/islander/IslanderAvatar.tsx b/frontend/src/components/islander/IslanderAvatar.tsx
similarity index 100%
rename from web/src/components/islander/IslanderAvatar.tsx
rename to frontend/src/components/islander/IslanderAvatar.tsx
diff --git a/web/src/components/islander/IslanderChatPanel.tsx b/frontend/src/components/islander/IslanderChatPanel.tsx
similarity index 100%
rename from web/src/components/islander/IslanderChatPanel.tsx
rename to frontend/src/components/islander/IslanderChatPanel.tsx
diff --git a/web/src/components/islander/IslanderCompanion.tsx b/frontend/src/components/islander/IslanderCompanion.tsx
similarity index 95%
rename from web/src/components/islander/IslanderCompanion.tsx
rename to frontend/src/components/islander/IslanderCompanion.tsx
index 2ee8f84..7b559ea 100644
--- a/web/src/components/islander/IslanderCompanion.tsx
+++ b/frontend/src/components/islander/IslanderCompanion.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { api, streamIslanderChat } from '../../api/client'
import {
@@ -47,6 +47,14 @@ export function IslanderCompanion() {
const pageMeta = pageCtx?.runtimeMeta ?? null
+ // 取消尚在進行的 SSE 串流(送出新訊息或元件卸載時)。
+ const streamAbortRef = useRef
(null)
+ useEffect(() => {
+ return () => {
+ streamAbortRef.current?.abort()
+ }
+ }, [])
+
const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setExpanded((prev) => {
const next = typeof value === 'function' ? value(prev) : value
@@ -89,6 +97,10 @@ export function IslanderCompanion() {
setStreaming(true)
setActing(false)
+ streamAbortRef.current?.abort()
+ const abortController = new AbortController()
+ streamAbortRef.current = abortController
+
const userMessage: IslanderChatMessage = {
id: nextMessageId(),
role: 'user',
@@ -141,6 +153,7 @@ export function IslanderCompanion() {
(delta) => onDelta(delta),
() => resolve(),
(message) => reject(new Error(message)),
+ abortController.signal,
)
})
},
diff --git a/web/src/components/islander/IslanderMarkdown.tsx b/frontend/src/components/islander/IslanderMarkdown.tsx
similarity index 92%
rename from web/src/components/islander/IslanderMarkdown.tsx
rename to frontend/src/components/islander/IslanderMarkdown.tsx
index 6c418da..bf764fd 100644
--- a/web/src/components/islander/IslanderMarkdown.tsx
+++ b/frontend/src/components/islander/IslanderMarkdown.tsx
@@ -5,7 +5,8 @@ import { Link } from 'react-router-dom'
const markdownComponents: Components = {
a: ({ href, children }) => {
- if (href?.startsWith('/')) {
+ // 僅單斜線開頭視為站內路徑;`//evil.com` 等 protocol-relative 連結走外部分支,避免 open redirect。
+ if (href?.startsWith('/') && !href.startsWith('//')) {
return (
{children}
diff --git a/web/src/components/ui.tsx b/frontend/src/components/ui.tsx
similarity index 100%
rename from web/src/components/ui.tsx
rename to frontend/src/components/ui.tsx
diff --git a/web/src/hooks/useActiveBrand.ts b/frontend/src/hooks/useActiveBrand.ts
similarity index 100%
rename from web/src/hooks/useActiveBrand.ts
rename to frontend/src/hooks/useActiveBrand.ts
diff --git a/web/src/hooks/useActiveCopyPersona.ts b/frontend/src/hooks/useActiveCopyPersona.ts
similarity index 100%
rename from web/src/hooks/useActiveCopyPersona.ts
rename to frontend/src/hooks/useActiveCopyPersona.ts
diff --git a/web/src/hooks/useActiveTopic.ts b/frontend/src/hooks/useActiveTopic.ts
similarity index 100%
rename from web/src/hooks/useActiveTopic.ts
rename to frontend/src/hooks/useActiveTopic.ts
diff --git a/web/src/hooks/useIslanderUnlock.ts b/frontend/src/hooks/useIslanderUnlock.ts
similarity index 100%
rename from web/src/hooks/useIslanderUnlock.ts
rename to frontend/src/hooks/useIslanderUnlock.ts
diff --git a/web/src/index.css b/frontend/src/index.css
similarity index 100%
rename from web/src/index.css
rename to frontend/src/index.css
diff --git a/web/src/lib/acAssets.ts b/frontend/src/lib/acAssets.ts
similarity index 100%
rename from web/src/lib/acAssets.ts
rename to frontend/src/lib/acAssets.ts
diff --git a/web/src/lib/aiCredentials.ts b/frontend/src/lib/aiCredentials.ts
similarity index 90%
rename from web/src/lib/aiCredentials.ts
rename to frontend/src/lib/aiCredentials.ts
index bf3694b..28fa018 100644
--- a/web/src/lib/aiCredentials.ts
+++ b/frontend/src/lib/aiCredentials.ts
@@ -64,6 +64,24 @@ function modelsForProvider(provider: ProviderId): readonly string[] {
return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
}
+export function fallbackModelsForProvider(provider: ProviderId, currentModel?: string): string[] {
+ const staticModels = [...modelsForProvider(provider)]
+ const trimmed = currentModel?.trim()
+ if (trimmed && !staticModels.includes(trimmed)) {
+ staticModels.unshift(trimmed)
+ }
+ return staticModels
+}
+
+export function resolveProviderApiToken(
+ providerId: ProviderId,
+ keyInputs: ProviderApiKeys,
+): string | null {
+ const value = keyInputs[providerId]?.trim()
+ if (!value || isMaskedKey(value)) return null
+ return value
+}
+
function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
const models = modelsForProvider(provider)
if (model && models.includes(model)) return model
diff --git a/web/src/lib/brandContext.ts b/frontend/src/lib/brandContext.ts
similarity index 100%
rename from web/src/lib/brandContext.ts
rename to frontend/src/lib/brandContext.ts
diff --git a/web/src/lib/connectionMode.ts b/frontend/src/lib/connectionMode.ts
similarity index 100%
rename from web/src/lib/connectionMode.ts
rename to frontend/src/lib/connectionMode.ts
diff --git a/web/src/lib/copyFlow.ts b/frontend/src/lib/copyFlow.ts
similarity index 100%
rename from web/src/lib/copyFlow.ts
rename to frontend/src/lib/copyFlow.ts
diff --git a/web/src/lib/copyMissionJobs.ts b/frontend/src/lib/copyMissionJobs.ts
similarity index 100%
rename from web/src/lib/copyMissionJobs.ts
rename to frontend/src/lib/copyMissionJobs.ts
diff --git a/web/src/lib/easterEggCatalog.ts b/frontend/src/lib/easterEggCatalog.ts
similarity index 100%
rename from web/src/lib/easterEggCatalog.ts
rename to frontend/src/lib/easterEggCatalog.ts
diff --git a/web/src/lib/extensionInstall.ts b/frontend/src/lib/extensionInstall.ts
similarity index 100%
rename from web/src/lib/extensionInstall.ts
rename to frontend/src/lib/extensionInstall.ts
diff --git a/web/src/lib/extensionSync.ts b/frontend/src/lib/extensionSync.ts
similarity index 88%
rename from web/src/lib/extensionSync.ts
rename to frontend/src/lib/extensionSync.ts
index d241c7c..51329a8 100644
--- a/web/src/lib/extensionSync.ts
+++ b/frontend/src/lib/extensionSync.ts
@@ -28,8 +28,11 @@ export function normalizeExtensionSyncResult(raw: ExtensionSyncResult): Extensio
}
}
+// 內容腳本注入在本頁,與本頁同 origin;限定 targetOrigin 避免把訊息(含 JWT)廣播給其他來源。
+const SAME_ORIGIN = window.location.origin
+
export function pingExtensionBridge() {
- window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, '*')
+ window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, SAME_ORIGIN)
}
export function waitForExtensionBridge(timeoutMs = 8000): Promise {
@@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise {
const started = Date.now()
const onMessage = (event: MessageEvent) => {
+ if (event.origin !== SAME_ORIGIN) return
if (event.source !== window) return
if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return
cleanup()
@@ -84,6 +88,7 @@ export function requestExtensionSync(input: {
}, 30000)
function onMessage(event: MessageEvent) {
+ if (event.origin !== SAME_ORIGIN) return
if (event.source !== window) return
if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return
window.clearTimeout(timeout)
@@ -100,7 +105,7 @@ export function requestExtensionSync(input: {
accessToken: input.accessToken,
apiVersion: 'go-v1',
},
- '*',
+ SAME_ORIGIN,
)
})
}
\ No newline at end of file
diff --git a/web/src/lib/formatDate.ts b/frontend/src/lib/formatDate.ts
similarity index 100%
rename from web/src/lib/formatDate.ts
rename to frontend/src/lib/formatDate.ts
diff --git a/web/src/lib/islander/IslanderPageContext.tsx b/frontend/src/lib/islander/IslanderPageContext.tsx
similarity index 100%
rename from web/src/lib/islander/IslanderPageContext.tsx
rename to frontend/src/lib/islander/IslanderPageContext.tsx
diff --git a/web/src/lib/islander/actionExecutor.ts b/frontend/src/lib/islander/actionExecutor.ts
similarity index 91%
rename from web/src/lib/islander/actionExecutor.ts
rename to frontend/src/lib/islander/actionExecutor.ts
index 618fee5..2fbe37f 100644
--- a/web/src/lib/islander/actionExecutor.ts
+++ b/frontend/src/lib/islander/actionExecutor.ts
@@ -189,6 +189,18 @@ type ExecuteOptions = {
snapshot?: PageSnapshot
}
+function isDangerousAction(type: string): boolean {
+ return (ISLANDER_CONFIG.dangerousActionTypes as readonly string[]).includes(type)
+}
+
+// 對危險動作要求真人確認;AI 自填的 confirm 不足以放行。
+function confirmDangerousAction(type: string): boolean {
+ if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false
+ return window.confirm(
+ `島民想替你執行「${type}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`,
+ )
+}
+
export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
results: IslanderActionResult[]
snapshotText: string
@@ -197,6 +209,10 @@ export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
const results: IslanderActionResult[] = []
for (const action of opts.actions) {
+ if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) {
+ results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' })
+ break
+ }
const result = await runAction(action, opts.navigate, snapshot)
results.push(result)
if (!result.ok && action.type !== 'wait') break
diff --git a/web/src/lib/islander/buildIslanderContext.ts b/frontend/src/lib/islander/buildIslanderContext.ts
similarity index 100%
rename from web/src/lib/islander/buildIslanderContext.ts
rename to frontend/src/lib/islander/buildIslanderContext.ts
diff --git a/web/src/lib/islander/config.ts b/frontend/src/lib/islander/config.ts
similarity index 70%
rename from web/src/lib/islander/config.ts
rename to frontend/src/lib/islander/config.ts
index cd9262e..29bcb8c 100644
--- a/web/src/lib/islander/config.ts
+++ b/frontend/src/lib/islander/config.ts
@@ -38,6 +38,18 @@ export const ISLANDER_CONFIG = {
`[data-islander-label]`,
],
blockedClickPatterns: [/登出/i, /logout/i],
+ // 這些 action 會實際對外發布、啟動背景/付費任務或寫入後端,
+ // 必須由真人確認(window.confirm),不能只靠 AI 在 JSON 自填 confirm。
+ // 避免被海巡抓回的不可信貼文做 prompt injection 觸發自動發文。
+ dangerousActionTypes: [
+ 'publishOutreach',
+ 'publishCopyDraft',
+ 'startViralScan',
+ 'startCopyMissionAnalyze',
+ 'startCopyMissionScan',
+ 'generateCopyMatrix',
+ 'generateCopyDraft',
+ ] as string[],
defaultSuggestions: [] as string[],
highlightClass: 'ac-islander-target-highlight',
highlightDurationMs: 1800,
diff --git a/web/src/lib/islander/copyActions.ts b/frontend/src/lib/islander/copyActions.ts
similarity index 100%
rename from web/src/lib/islander/copyActions.ts
rename to frontend/src/lib/islander/copyActions.ts
diff --git a/web/src/lib/islander/handoffStore.ts b/frontend/src/lib/islander/handoffStore.ts
similarity index 100%
rename from web/src/lib/islander/handoffStore.ts
rename to frontend/src/lib/islander/handoffStore.ts
diff --git a/web/src/lib/islander/index.ts b/frontend/src/lib/islander/index.ts
similarity index 100%
rename from web/src/lib/islander/index.ts
rename to frontend/src/lib/islander/index.ts
diff --git a/web/src/lib/islander/islanderActions.ts b/frontend/src/lib/islander/islanderActions.ts
similarity index 100%
rename from web/src/lib/islander/islanderActions.ts
rename to frontend/src/lib/islander/islanderActions.ts
diff --git a/web/src/lib/islander/islanderAgent.ts b/frontend/src/lib/islander/islanderAgent.ts
similarity index 100%
rename from web/src/lib/islander/islanderAgent.ts
rename to frontend/src/lib/islander/islanderAgent.ts
diff --git a/web/src/lib/islander/outreachActions.ts b/frontend/src/lib/islander/outreachActions.ts
similarity index 100%
rename from web/src/lib/islander/outreachActions.ts
rename to frontend/src/lib/islander/outreachActions.ts
diff --git a/web/src/lib/islander/pageContextIntent.ts b/frontend/src/lib/islander/pageContextIntent.ts
similarity index 100%
rename from web/src/lib/islander/pageContextIntent.ts
rename to frontend/src/lib/islander/pageContextIntent.ts
diff --git a/web/src/lib/islander/pageRegistry.ts b/frontend/src/lib/islander/pageRegistry.ts
similarity index 100%
rename from web/src/lib/islander/pageRegistry.ts
rename to frontend/src/lib/islander/pageRegistry.ts
diff --git a/web/src/lib/islander/pageSnapshot.ts b/frontend/src/lib/islander/pageSnapshot.ts
similarity index 100%
rename from web/src/lib/islander/pageSnapshot.ts
rename to frontend/src/lib/islander/pageSnapshot.ts
diff --git a/web/src/lib/islander/researchActions.ts b/frontend/src/lib/islander/researchActions.ts
similarity index 96%
rename from web/src/lib/islander/researchActions.ts
rename to frontend/src/lib/islander/researchActions.ts
index 0363156..bd66893 100644
--- a/web/src/lib/islander/researchActions.ts
+++ b/frontend/src/lib/islander/researchActions.ts
@@ -1,4 +1,5 @@
import { api } from '../../api/client'
+import { requestJobMonitorRefresh } from '../jobMonitorRefresh'
import { registerIslanderActionHandler } from './actionExecutor'
import type { IslanderAction, IslanderExecutorContext } from './types'
import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph'
@@ -46,6 +47,7 @@ export function ensureResearchActionHandlers() {
{ seed_query: seed, supplemental: !!typed.supplemental },
{ auth: true },
)
+ requestJobMonitorRefresh()
handlers.onExpandStarted(data.job_id, data.message)
return {
action,
@@ -90,6 +92,7 @@ export function ensureResearchActionHandlers() {
{ dual_track: true, graph_id: saved.id },
{ auth: true },
)
+ requestJobMonitorRefresh()
handlers.onScanStarted(data.job_id, data.message)
window.dispatchEvent(
new CustomEvent(RESEARCH_SCAN_EVENT, {
diff --git a/web/src/lib/islander/siteGuide.ts b/frontend/src/lib/islander/siteGuide.ts
similarity index 100%
rename from web/src/lib/islander/siteGuide.ts
rename to frontend/src/lib/islander/siteGuide.ts
diff --git a/web/src/lib/islander/types.ts b/frontend/src/lib/islander/types.ts
similarity index 100%
rename from web/src/lib/islander/types.ts
rename to frontend/src/lib/islander/types.ts
diff --git a/web/src/lib/islander/unlock.ts b/frontend/src/lib/islander/unlock.ts
similarity index 100%
rename from web/src/lib/islander/unlock.ts
rename to frontend/src/lib/islander/unlock.ts
diff --git a/web/src/lib/islander/useIslanderPosition.ts b/frontend/src/lib/islander/useIslanderPosition.ts
similarity index 100%
rename from web/src/lib/islander/useIslanderPosition.ts
rename to frontend/src/lib/islander/useIslanderPosition.ts
diff --git a/frontend/src/lib/jobMonitorRefresh.ts b/frontend/src/lib/jobMonitorRefresh.ts
new file mode 100644
index 0000000..b9ff367
--- /dev/null
+++ b/frontend/src/lib/jobMonitorRefresh.ts
@@ -0,0 +1,6 @@
+export const JOB_MONITOR_REFRESH = 'haixun:job-monitor-refresh'
+
+export function requestJobMonitorRefresh() {
+ if (typeof window === 'undefined') return
+ window.dispatchEvent(new Event(JOB_MONITOR_REFRESH))
+}
\ No newline at end of file
diff --git a/frontend/src/lib/jobMonitorUi.ts b/frontend/src/lib/jobMonitorUi.ts
new file mode 100644
index 0000000..6eb8593
--- /dev/null
+++ b/frontend/src/lib/jobMonitorUi.ts
@@ -0,0 +1,206 @@
+import type { JobData } from '../types/api'
+import {
+ ANALYZE_COPY_MISSION_PIPELINE_STEPS,
+ GENERATE_COPY_DRAFT_PIPELINE_STEPS,
+ GENERATE_COPY_MATRIX_PIPELINE_STEPS,
+ VIRAL_SCAN_PIPELINE_STEPS,
+ copyMissionDetailPath,
+} from './copyFlow'
+import { jobErrorMessage, isTerminalJobStatus } from './jobStatus'
+import { jobTemplateLabel } from './jobTemplate'
+import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from './knowledgeGraph'
+import { STYLE_8D_PIPELINE_STEPS } from './styleProfile'
+import { placementFlowPath } from './placementFlow'
+import { topicResearchMapPath } from './placementTopics'
+
+const STEP_TITLE_BY_ID = new Map(
+ [
+ ...EXPAND_GRAPH_PIPELINE_STEPS,
+ ...PLACEMENT_SCAN_PIPELINE_STEPS,
+ ...VIRAL_SCAN_PIPELINE_STEPS,
+ ...ANALYZE_COPY_MISSION_PIPELINE_STEPS,
+ ...GENERATE_COPY_MATRIX_PIPELINE_STEPS,
+ ...GENERATE_COPY_DRAFT_PIPELINE_STEPS,
+ ...STYLE_8D_PIPELINE_STEPS,
+ { id: 'prepare', title: '準備' },
+ { id: 'execute', title: '執行' },
+ { id: 'finalize', title: '收尾' },
+ ].map((step) => [step.id, step.title]),
+)
+
+const PHASE_LABEL: Record = {
+ expand: '產生研究地圖',
+ crawl: '雙軌海巡',
+ viral_crawl: '爆款海巡',
+ copy_mission_map: '研究地圖',
+ copy_matrix_generate: '內容矩陣',
+ copy_draft_generate: '深仿寫',
+ session: '確認連線',
+ samples: '抓取樣本',
+ style: 'AI 8D 分析',
+ store: '寫入人設',
+ prepare: '準備',
+ execute: '執行',
+ finalize: '收尾',
+}
+
+const TERMINAL_ACTIVITY: Record = {
+ 'expand-graph': '研究地圖已產生完成',
+ 'placement-scan': '雙軌海巡已完成',
+ 'scan-viral': '爆款掃描已完成',
+ 'analyze-copy-mission': '拷貝研究地圖已產生完成',
+ 'generate-copy-matrix': '內容矩陣已產生完成',
+ 'generate-copy-draft': '仿寫草稿已產生完成',
+ 'style-8d': '8D 風格分析已完成',
+}
+
+function stepTitle(stepId: string): string {
+ return STEP_TITLE_BY_ID.get(stepId) ?? PHASE_LABEL[stepId] ?? stepId
+}
+
+function hasCjk(text: string): boolean {
+ return /[\u3400-\u9fff]/.test(text)
+}
+
+function translateGenericSummary(summary: string, job: JobData): string | null {
+ const trimmed = summary.trim()
+ if (!trimmed) return null
+
+ const waiting = trimmed.match(/^waiting for worker$/i)
+ if (waiting) return '等待 Worker 接手…'
+
+ const running = trimmed.match(/^running step (.+)$/i)
+ if (running) return `正在執行:${stepTitle(running[1])}`
+
+ const completed = trimmed.match(/^completed step (.+)$/i)
+ if (completed) {
+ if (isTerminalJobStatus(job.status)) {
+ return TERMINAL_ACTIVITY[job.template_type] ?? `${stepTitle(completed[1])}已完成`
+ }
+ return `已完成:${stepTitle(completed[1])}`
+ }
+
+ if (/^step failed$/i.test(trimmed)) return '步驟失敗'
+
+ return hasCjk(trimmed) ? trimmed : null
+}
+
+function activePhaseHint(job: JobData): string {
+ if (job.template_type === 'expand-graph') {
+ return '研究地圖產生中…'
+ }
+ if (job.template_type === 'placement-scan') {
+ return '雙軌海巡進行中…'
+ }
+ if (job.template_type === 'scan-viral') {
+ return '依關鍵字搜尋 Threads 爆款候選…'
+ }
+ if (job.template_type === 'analyze-copy-mission') {
+ return '產出研究地圖與預設搜尋標籤…'
+ }
+ if (job.template_type === 'generate-copy-matrix') {
+ return '依爆款樣本產出內容矩陣…'
+ }
+ if (job.template_type === 'generate-copy-draft') {
+ return '分析爆款結構並產出仿寫草稿…'
+ }
+ if (job.template_type === 'style-8d') {
+ switch (job.phase) {
+ case 'session':
+ return '確認 Chrome session 與 Worker 心跳…'
+ case 'samples':
+ return '讀取對標帳號近期貼文樣本…'
+ case 'style':
+ return '交給 AI 分析 D1–D8 風格策略…'
+ case 'store':
+ return '寫回人設,讓後續產文套用…'
+ default:
+ return '8D 風格分析進行中…'
+ }
+ }
+ if (job.phase) {
+ const label = PHASE_LABEL[job.phase]
+ return label ? `目前階段:${label}` : '任務執行中…'
+ }
+ return '等待 Worker 回報進度…'
+}
+
+/** 任務觀察站「現在在做」— 終態會改為完成/失敗等繁中說明。 */
+export function jobActivitySummary(job: JobData): string {
+ if (job.status === 'cancel_requested') return '正在取消任務…'
+ if (job.status === 'cancelled') return '任務已取消'
+ if (job.status === 'expired') return '任務已過期(執行逾時)'
+ if (job.status === 'failed') {
+ return jobErrorMessage(job) ?? `${jobTemplateLabel(job.template_type)}失敗`
+ }
+ if (job.status === 'succeeded') {
+ return TERMINAL_ACTIVITY[job.template_type] ?? `${jobTemplateLabel(job.template_type)}已完成`
+ }
+
+ const steps = job.progress?.steps ?? []
+ const activeStep = steps.find((step) => step.status === 'running')
+ if (activeStep?.message && hasCjk(activeStep.message)) {
+ return activeStep.message
+ }
+
+ const summary = job.progress?.summary?.trim()
+ if (summary) {
+ const translated = translateGenericSummary(summary, job)
+ if (translated) return translated
+ }
+
+ return activePhaseHint(job)
+}
+
+export function jobStepDetailMessage(message?: string): string | null {
+ const trimmed = message?.trim()
+ if (!trimmed) return null
+ if (/^(running|done)$/i.test(trimmed)) return null
+ if (hasCjk(trimmed)) return trimmed
+ if (/^running$/i.test(trimmed)) return null
+ return trimmed
+}
+
+/** 回到發起任務的頁面(不顯示 job id)。 */
+export function jobOriginPath(job: JobData): string | null {
+ const id = job.scope_id?.trim()
+ if (!id) return null
+
+ switch (job.template_type) {
+ case 'expand-graph':
+ if (job.scope === 'placement_topic') return topicResearchMapPath(id)
+ return placementFlowPath('/research', id, 'brand')
+ case 'placement-scan':
+ if (job.scope === 'placement_topic') return placementFlowPath('/outreach', id, 'topic')
+ return placementFlowPath('/outreach', id, 'brand')
+ case 'analyze-copy-mission':
+ return copyMissionDetailPath(id, '#copy-research')
+ case 'scan-viral':
+ case 'generate-copy-matrix':
+ case 'generate-copy-draft':
+ if (job.scope === 'copy_mission') return copyMissionDetailPath(id, '#copy-output')
+ return `/matrix/missions/${encodeURIComponent(id)}`
+ case 'style-8d':
+ return `/personas/${encodeURIComponent(id)}`
+ default:
+ return null
+ }
+}
+
+export function jobOriginLinkLabel(job: JobData): string {
+ switch (job.template_type) {
+ case 'expand-graph':
+ return job.status === 'succeeded' ? '回到研究地圖勾選 tag →' : '回到研究地圖 →'
+ case 'placement-scan':
+ return job.status === 'succeeded' ? '回到獲客台 →' : '回到海巡頁 →'
+ case 'analyze-copy-mission':
+ case 'scan-viral':
+ case 'generate-copy-matrix':
+ case 'generate-copy-draft':
+ return '回到拷貝任務 →'
+ case 'style-8d':
+ return '回到人設頁 →'
+ default:
+ return '回到任務頁 →'
+ }
+}
\ No newline at end of file
diff --git a/web/src/lib/jobStatus.ts b/frontend/src/lib/jobStatus.ts
similarity index 100%
rename from web/src/lib/jobStatus.ts
rename to frontend/src/lib/jobStatus.ts
diff --git a/web/src/lib/jobTemplate.ts b/frontend/src/lib/jobTemplate.ts
similarity index 100%
rename from web/src/lib/jobTemplate.ts
rename to frontend/src/lib/jobTemplate.ts
diff --git a/web/src/lib/knowledgeGraph.ts b/frontend/src/lib/knowledgeGraph.ts
similarity index 100%
rename from web/src/lib/knowledgeGraph.ts
rename to frontend/src/lib/knowledgeGraph.ts
diff --git a/web/src/lib/memberRole.ts b/frontend/src/lib/memberRole.ts
similarity index 100%
rename from web/src/lib/memberRole.ts
rename to frontend/src/lib/memberRole.ts
diff --git a/web/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts
similarity index 100%
rename from web/src/lib/onboarding.ts
rename to frontend/src/lib/onboarding.ts
diff --git a/web/src/lib/pageJobScope.ts b/frontend/src/lib/pageJobScope.ts
similarity index 100%
rename from web/src/lib/pageJobScope.ts
rename to frontend/src/lib/pageJobScope.ts
diff --git a/web/src/lib/placementFlow.ts b/frontend/src/lib/placementFlow.ts
similarity index 100%
rename from web/src/lib/placementFlow.ts
rename to frontend/src/lib/placementFlow.ts
diff --git a/web/src/lib/placementResearch.ts b/frontend/src/lib/placementResearch.ts
similarity index 100%
rename from web/src/lib/placementResearch.ts
rename to frontend/src/lib/placementResearch.ts
diff --git a/web/src/lib/placementTopics.ts b/frontend/src/lib/placementTopics.ts
similarity index 100%
rename from web/src/lib/placementTopics.ts
rename to frontend/src/lib/placementTopics.ts
diff --git a/web/src/lib/productCatalog.ts b/frontend/src/lib/productCatalog.ts
similarity index 100%
rename from web/src/lib/productCatalog.ts
rename to frontend/src/lib/productCatalog.ts
diff --git a/web/src/lib/productContext.ts b/frontend/src/lib/productContext.ts
similarity index 100%
rename from web/src/lib/productContext.ts
rename to frontend/src/lib/productContext.ts
diff --git a/web/src/lib/productMatch.ts b/frontend/src/lib/productMatch.ts
similarity index 100%
rename from web/src/lib/productMatch.ts
rename to frontend/src/lib/productMatch.ts
diff --git a/web/src/lib/publishPersona.ts b/frontend/src/lib/publishPersona.ts
similarity index 100%
rename from web/src/lib/publishPersona.ts
rename to frontend/src/lib/publishPersona.ts
diff --git a/web/src/lib/scheduleCatalog.ts b/frontend/src/lib/scheduleCatalog.ts
similarity index 100%
rename from web/src/lib/scheduleCatalog.ts
rename to frontend/src/lib/scheduleCatalog.ts
diff --git a/web/src/lib/scheduleCron.ts b/frontend/src/lib/scheduleCron.ts
similarity index 100%
rename from web/src/lib/scheduleCron.ts
rename to frontend/src/lib/scheduleCron.ts
diff --git a/web/src/lib/searchSourceMode.ts b/frontend/src/lib/searchSourceMode.ts
similarity index 97%
rename from web/src/lib/searchSourceMode.ts
rename to frontend/src/lib/searchSourceMode.ts
index 64fad46..4a04340 100644
--- a/web/src/lib/searchSourceMode.ts
+++ b/frontend/src/lib/searchSourceMode.ts
@@ -57,6 +57,7 @@ export function normalizeSearchSourceMode(mode: string | undefined): SearchSourc
}
if (mode === 'threads_brave') return 'mixed'
if (mode === 'brave_crawler') return 'threads_crawler'
+ if (mode === 'browser') return 'threads_crawler'
return 'mixed'
}
diff --git a/web/src/lib/sourcePreview.ts b/frontend/src/lib/sourcePreview.ts
similarity index 100%
rename from web/src/lib/sourcePreview.ts
rename to frontend/src/lib/sourcePreview.ts
diff --git a/web/src/lib/storage.ts b/frontend/src/lib/storage.ts
similarity index 88%
rename from web/src/lib/storage.ts
rename to frontend/src/lib/storage.ts
index a86998d..b1d96dd 100644
--- a/web/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -25,5 +25,8 @@ export const storage = {
localStorage.removeItem(KEYS.refreshToken)
localStorage.removeItem(KEYS.uid)
localStorage.removeItem(KEYS.activeThreadsAccountId)
+ // 高敏感的 AI provider key 不可在登出後殘留(共用裝置)。
+ localStorage.removeItem(KEYS.aiProviderToken)
+ localStorage.removeItem(KEYS.tenantId)
},
}
\ No newline at end of file
diff --git a/web/src/lib/styleEvidence.ts b/frontend/src/lib/styleEvidence.ts
similarity index 100%
rename from web/src/lib/styleEvidence.ts
rename to frontend/src/lib/styleEvidence.ts
diff --git a/web/src/lib/styleProfile.ts b/frontend/src/lib/styleProfile.ts
similarity index 100%
rename from web/src/lib/styleProfile.ts
rename to frontend/src/lib/styleProfile.ts
diff --git a/web/src/lib/threadsAccount.ts b/frontend/src/lib/threadsAccount.ts
similarity index 100%
rename from web/src/lib/threadsAccount.ts
rename to frontend/src/lib/threadsAccount.ts
diff --git a/web/src/lib/threadsLinks.ts b/frontend/src/lib/threadsLinks.ts
similarity index 100%
rename from web/src/lib/threadsLinks.ts
rename to frontend/src/lib/threadsLinks.ts
diff --git a/web/src/lib/threadsPostLimits.ts b/frontend/src/lib/threadsPostLimits.ts
similarity index 100%
rename from web/src/lib/threadsPostLimits.ts
rename to frontend/src/lib/threadsPostLimits.ts
diff --git a/web/src/lib/viralSignals.ts b/frontend/src/lib/viralSignals.ts
similarity index 100%
rename from web/src/lib/viralSignals.ts
rename to frontend/src/lib/viralSignals.ts
diff --git a/web/src/main.tsx b/frontend/src/main.tsx
similarity index 100%
rename from web/src/main.tsx
rename to frontend/src/main.tsx
diff --git a/web/src/onboarding/OnboardingContext.tsx b/frontend/src/onboarding/OnboardingContext.tsx
similarity index 88%
rename from web/src/onboarding/OnboardingContext.tsx
rename to frontend/src/onboarding/OnboardingContext.tsx
index 44307c0..c69a4fb 100644
--- a/web/src/onboarding/OnboardingContext.tsx
+++ b/frontend/src/onboarding/OnboardingContext.tsx
@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
+ useRef,
useState,
type ReactNode,
} from 'react'
@@ -30,9 +31,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
const [personaCount, setPersonaCount] = useState(0)
const [connectionLoading, setConnectionLoading] = useState(false)
const [connectionReady, setConnectionReady] = useState(false)
+ const hasLoadedRef = useRef(false)
const refresh = useCallback(async () => {
- setPersonasLoading(true)
+ const silent = hasLoadedRef.current
+ if (!silent) setPersonasLoading(true)
let nextPersonaCount = 0
try {
const personas = await api.get('/api/v1/personas', { auth: true })
@@ -41,12 +44,13 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
} catch {
setPersonaCount(0)
} finally {
- setPersonasLoading(false)
+ if (!silent) setPersonasLoading(false)
}
if (!activeAccountId) {
setConnectionReady(false)
- setConnectionLoading(false)
+ if (!silent) setConnectionLoading(false)
+ hasLoadedRef.current = true
return buildOnboardingSnapshot({
accountsLoading,
personasLoading: false,
@@ -57,7 +61,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
})
}
- setConnectionLoading(true)
+ if (!silent) setConnectionLoading(true)
let nextConnectionReady = false
try {
const connection = await api.get(
@@ -69,9 +73,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
} catch {
setConnectionReady(false)
} finally {
- setConnectionLoading(false)
+ if (!silent) setConnectionLoading(false)
}
+ hasLoadedRef.current = true
return buildOnboardingSnapshot({
accountsLoading: false,
personasLoading: false,
@@ -84,7 +89,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
useEffect(() => {
refresh().catch(() => undefined)
- }, [accounts, activeAccountId, pathname, refresh])
+ }, [activeAccountId, pathname, accounts.length, refresh])
const snapshot = useMemo(
() =>
diff --git a/web/src/pages/BrandDetailPage.tsx b/frontend/src/pages/BrandDetailPage.tsx
similarity index 100%
rename from web/src/pages/BrandDetailPage.tsx
rename to frontend/src/pages/BrandDetailPage.tsx
diff --git a/web/src/pages/BrandProductEditPage.tsx b/frontend/src/pages/BrandProductEditPage.tsx
similarity index 100%
rename from web/src/pages/BrandProductEditPage.tsx
rename to frontend/src/pages/BrandProductEditPage.tsx
diff --git a/web/src/pages/BrandsPage.tsx b/frontend/src/pages/BrandsPage.tsx
similarity index 100%
rename from web/src/pages/BrandsPage.tsx
rename to frontend/src/pages/BrandsPage.tsx
diff --git a/web/src/pages/CopyMatrixPage.tsx b/frontend/src/pages/CopyMatrixPage.tsx
similarity index 99%
rename from web/src/pages/CopyMatrixPage.tsx
rename to frontend/src/pages/CopyMatrixPage.tsx
index a868244..5dc842a 100644
--- a/web/src/pages/CopyMatrixPage.tsx
+++ b/frontend/src/pages/CopyMatrixPage.tsx
@@ -11,6 +11,7 @@ import {
} from '../lib/copyFlow'
import type { ListPersonasData, PersonaData } from '../types/api'
import type { CopyMissionData } from '../types/copyMission'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { hasPersona8D } from '../lib/styleProfile'
import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission'
@@ -171,6 +172,7 @@ export function CopyMatrixPage() {
{},
{ auth: true },
)
+ requestJobMonitorRefresh()
setLabel('')
setSeedQuery('')
setBrief('')
diff --git a/web/src/pages/CopyMissionDetailPage.tsx b/frontend/src/pages/CopyMissionDetailPage.tsx
similarity index 99%
rename from web/src/pages/CopyMissionDetailPage.tsx
rename to frontend/src/pages/CopyMissionDetailPage.tsx
index 7fd6e4a..625be84 100644
--- a/web/src/pages/CopyMissionDetailPage.tsx
+++ b/frontend/src/pages/CopyMissionDetailPage.tsx
@@ -22,6 +22,7 @@ import { threadsAuthorPostUrl, threadsProfileUrl } from '../lib/threadsLinks'
import { hasPersona8D } from '../lib/styleProfile'
import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions'
import { useIslanderPage } from '../lib/islander'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus'
import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api'
import {
@@ -382,6 +383,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) {
rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id })
setAnalyzeJobId(started.job_id)
+ requestJobMonitorRefresh()
await refreshJobById(started.job_id)
}
setMessage(started.message || '研究地圖產生中…')
@@ -452,6 +454,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) {
rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id })
setScanJobId(started.job_id)
+ requestJobMonitorRefresh()
await refreshJobById(started.job_id)
}
setPosts([])
@@ -492,6 +495,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) {
rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id })
setMatrixJobId(started.job_id)
+ requestJobMonitorRefresh()
await refreshJobById(started.job_id)
}
setMessage(started.message || '內容矩陣產出中…')
@@ -615,6 +619,7 @@ export function CopyMissionDetailPage() {
)
if (started.job_id) {
rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id)
+ requestJobMonitorRefresh()
await refreshJobById(started.job_id)
}
setMessage(started.message || '深仿寫產出中…')
diff --git a/web/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
similarity index 100%
rename from web/src/pages/DashboardPage.tsx
rename to frontend/src/pages/DashboardPage.tsx
diff --git a/web/src/pages/EasterEggsPage.tsx b/frontend/src/pages/EasterEggsPage.tsx
similarity index 100%
rename from web/src/pages/EasterEggsPage.tsx
rename to frontend/src/pages/EasterEggsPage.tsx
diff --git a/web/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx
similarity index 100%
rename from web/src/pages/JobDetailPage.tsx
rename to frontend/src/pages/JobDetailPage.tsx
diff --git a/web/src/pages/JobSchedulesPage.tsx b/frontend/src/pages/JobSchedulesPage.tsx
similarity index 100%
rename from web/src/pages/JobSchedulesPage.tsx
rename to frontend/src/pages/JobSchedulesPage.tsx
diff --git a/web/src/pages/JobTemplatesPage.tsx b/frontend/src/pages/JobTemplatesPage.tsx
similarity index 100%
rename from web/src/pages/JobTemplatesPage.tsx
rename to frontend/src/pages/JobTemplatesPage.tsx
diff --git a/web/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx
similarity index 100%
rename from web/src/pages/JobsPage.tsx
rename to frontend/src/pages/JobsPage.tsx
diff --git a/web/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
similarity index 100%
rename from web/src/pages/LoginPage.tsx
rename to frontend/src/pages/LoginPage.tsx
diff --git a/web/src/pages/PermissionsPage.tsx b/frontend/src/pages/PermissionsPage.tsx
similarity index 100%
rename from web/src/pages/PermissionsPage.tsx
rename to frontend/src/pages/PermissionsPage.tsx
diff --git a/web/src/pages/PersonaDetailPage.tsx b/frontend/src/pages/PersonaDetailPage.tsx
similarity index 99%
rename from web/src/pages/PersonaDetailPage.tsx
rename to frontend/src/pages/PersonaDetailPage.tsx
index 7aa065d..9ea7180 100644
--- a/web/src/pages/PersonaDetailPage.tsx
+++ b/frontend/src/pages/PersonaDetailPage.tsx
@@ -26,6 +26,7 @@ import {
SuccessText,
Textarea,
} from '../components/ui'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { isTerminalJobStatus } from '../lib/jobStatus'
import {
createEmptyStyle8DProfile,
@@ -186,6 +187,7 @@ export function PersonaDetailPage() {
)
setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
setStyleJobId(data.job_id)
+ requestJobMonitorRefresh()
await refreshStyleJob(data.job_id)
} catch (e) {
setError(e instanceof ApiError ? e.message : '8D 分析失敗')
diff --git a/web/src/pages/PersonaMatrixPage.tsx b/frontend/src/pages/PersonaMatrixPage.tsx
similarity index 100%
rename from web/src/pages/PersonaMatrixPage.tsx
rename to frontend/src/pages/PersonaMatrixPage.tsx
diff --git a/web/src/pages/PersonaOutreachPage.tsx b/frontend/src/pages/PersonaOutreachPage.tsx
similarity index 100%
rename from web/src/pages/PersonaOutreachPage.tsx
rename to frontend/src/pages/PersonaOutreachPage.tsx
diff --git a/web/src/pages/PersonaResearchPage.tsx b/frontend/src/pages/PersonaResearchPage.tsx
similarity index 99%
rename from web/src/pages/PersonaResearchPage.tsx
rename to frontend/src/pages/PersonaResearchPage.tsx
index e6647f0..716d8e7 100644
--- a/web/src/pages/PersonaResearchPage.tsx
+++ b/frontend/src/pages/PersonaResearchPage.tsx
@@ -20,6 +20,7 @@ import {
Notice,
SuccessText,
} from '../components/ui'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { isTerminalJobStatus } from '../lib/jobStatus'
import { placementFlowPath } from '../lib/placementFlow'
import {
@@ -257,6 +258,7 @@ export function PersonaResearchPage() {
)
setMessage(data.message ?? '圖譜擴展已在背景執行')
setExpandJobId(data.job_id)
+ requestJobMonitorRefresh()
await refreshExpandJob(data.job_id)
} catch (e) {
setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗')
@@ -298,6 +300,7 @@ export function PersonaResearchPage() {
)
setMessage(data.message ?? '雙軌海巡已在背景執行')
setScanJobId(data.job_id)
+ requestJobMonitorRefresh()
await refreshScanJob(data.job_id)
} catch (e) {
setError(e instanceof ApiError ? e.message : '啟動海巡失敗')
diff --git a/web/src/pages/PersonasPage.tsx b/frontend/src/pages/PersonasPage.tsx
similarity index 100%
rename from web/src/pages/PersonasPage.tsx
rename to frontend/src/pages/PersonasPage.tsx
diff --git a/web/src/pages/PlacementTopicResearchMapPage.tsx b/frontend/src/pages/PlacementTopicResearchMapPage.tsx
similarity index 99%
rename from web/src/pages/PlacementTopicResearchMapPage.tsx
rename to frontend/src/pages/PlacementTopicResearchMapPage.tsx
index 59935c7..33b0a2d 100644
--- a/web/src/pages/PlacementTopicResearchMapPage.tsx
+++ b/frontend/src/pages/PlacementTopicResearchMapPage.tsx
@@ -13,6 +13,7 @@ import {
type ExpandKnowledgeGraphData,
type KnowledgeGraphData,
} from '../lib/knowledgeGraph'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { placementFlowPath } from '../lib/placementFlow'
import {
activeExpandJobHint,
@@ -260,6 +261,7 @@ export function PlacementTopicResearchMapPage() {
)
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
setScanJobId(data.job_id)
+ requestJobMonitorRefresh()
await refreshScanJob(data.job_id)
} catch (e) {
const msg = e instanceof ApiError ? e.message : '啟動海巡失敗'
@@ -286,6 +288,7 @@ export function PlacementTopicResearchMapPage() {
{ auth: true },
)
setExpandJobId(job.job_id)
+ requestJobMonitorRefresh()
await refreshExpandJob(job.job_id)
setMessage(job.message || '研究地圖產生中…')
} catch (e) {
diff --git a/web/src/pages/PlacementTopicSettingsPage.tsx b/frontend/src/pages/PlacementTopicSettingsPage.tsx
similarity index 98%
rename from web/src/pages/PlacementTopicSettingsPage.tsx
rename to frontend/src/pages/PlacementTopicSettingsPage.tsx
index 8cf826c..78a0bd7 100644
--- a/web/src/pages/PlacementTopicSettingsPage.tsx
+++ b/frontend/src/pages/PlacementTopicSettingsPage.tsx
@@ -5,6 +5,7 @@ import { BrandProductPicker } from '../components/BrandProductPicker'
import { rememberTopicId } from '../lib/brandContext'
import { brandHasCatalogProducts } from '../lib/productCatalog'
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
+import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { formatActiveJobConflictError } from '../lib/jobStatus'
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
import type { BrandData, ListBrandsData } from '../types/brand'
@@ -123,6 +124,7 @@ export function PlacementTopicSettingsPage() {
{ seed_query: seedQuery.trim(), regenerate_map: true },
{ auth: true },
)
+ requestJobMonitorRefresh()
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
} catch (e) {
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'
diff --git a/web/src/pages/PlacementTopicsPage.tsx b/frontend/src/pages/PlacementTopicsPage.tsx
similarity index 81%
rename from web/src/pages/PlacementTopicsPage.tsx
rename to frontend/src/pages/PlacementTopicsPage.tsx
index 6842c0d..e18617a 100644
--- a/web/src/pages/PlacementTopicsPage.tsx
+++ b/frontend/src/pages/PlacementTopicsPage.tsx
@@ -35,43 +35,50 @@ export function PlacementTopicsPage() {
suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'],
})
- const load = useCallback(async () => {
+ const load = useCallback(async (opts?: { silent?: boolean }) => {
setError('')
- const [topicsRes, brandsRes] = await Promise.allSettled([
- api.get('/api/v1/placement/topics/', { auth: true }),
- api.get('/api/v1/brands/', { auth: true }),
- ])
- if (topicsRes.status === 'fulfilled') {
- setTopics(topicsRes.value.list ?? [])
- } else {
- const reason = topicsRes.reason
- setError(reason instanceof ApiError ? reason.message : '載入主題失敗')
- setTopics([])
- }
- if (brandsRes.status === 'fulfilled') {
- setCatalogBrands(brandsRes.value.list ?? [])
+ if (!opts?.silent) setLoading(true)
+ try {
+ const [topicsRes, brandsRes] = await Promise.allSettled([
+ api.get('/api/v1/placement/topics/', { auth: true }),
+ api.get('/api/v1/brands/', { auth: true }),
+ ])
+ if (topicsRes.status === 'fulfilled') {
+ setTopics(topicsRes.value.list ?? [])
+ } else {
+ const reason = topicsRes.reason
+ setError(reason instanceof ApiError ? reason.message : '載入主題失敗')
+ setTopics([])
+ }
+ if (brandsRes.status === 'fulfilled') {
+ setCatalogBrands(brandsRes.value.list ?? [])
+ }
+ } finally {
+ if (!opts?.silent) setLoading(false)
}
}, [])
useEffect(() => {
- setLoading(true)
load()
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
- .finally(() => setLoading(false))
}, [load])
useEffect(() => {
- const refresh = () => {
+ let debounceTimer: ReturnType | null = null
+ const scheduleRefresh = () => {
if (document.visibilityState !== 'visible') return
- load().catch(() => undefined)
+ if (createOpen) return
+ if (debounceTimer) clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => {
+ load({ silent: true }).catch(() => undefined)
+ }, 800)
}
- window.addEventListener('focus', refresh)
- document.addEventListener('visibilitychange', refresh)
+ document.addEventListener('visibilitychange', scheduleRefresh)
return () => {
- window.removeEventListener('focus', refresh)
- document.removeEventListener('visibilitychange', refresh)
+ if (debounceTimer) clearTimeout(debounceTimer)
+ document.removeEventListener('visibilitychange', scheduleRefresh)
}
- }, [load])
+ }, [load, createOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
@@ -81,7 +88,7 @@ export function PlacementTopicsPage() {
const onCreated = async (data: CreatePlacementTopicData) => {
rememberTopicId(data.topic.id)
- await load().catch(() => undefined)
+ await load({ silent: true }).catch(() => undefined)
navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } })
}
diff --git a/web/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
similarity index 100%
rename from web/src/pages/ProfilePage.tsx
rename to frontend/src/pages/ProfilePage.tsx
diff --git a/web/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
similarity index 100%
rename from web/src/pages/SettingsPage.tsx
rename to frontend/src/pages/SettingsPage.tsx
diff --git a/web/src/pages/ThreadsAccountConnectionsPage.tsx b/frontend/src/pages/ThreadsAccountConnectionsPage.tsx
similarity index 100%
rename from web/src/pages/ThreadsAccountConnectionsPage.tsx
rename to frontend/src/pages/ThreadsAccountConnectionsPage.tsx
diff --git a/web/src/pages/ThreadsAccountPublishPage.tsx b/frontend/src/pages/ThreadsAccountPublishPage.tsx
similarity index 100%
rename from web/src/pages/ThreadsAccountPublishPage.tsx
rename to frontend/src/pages/ThreadsAccountPublishPage.tsx
diff --git a/web/src/theme/ThemeContext.tsx b/frontend/src/theme/ThemeContext.tsx
similarity index 100%
rename from web/src/theme/ThemeContext.tsx
rename to frontend/src/theme/ThemeContext.tsx
diff --git a/web/src/threads/ThreadsAccountContext.tsx b/frontend/src/threads/ThreadsAccountContext.tsx
similarity index 100%
rename from web/src/threads/ThreadsAccountContext.tsx
rename to frontend/src/threads/ThreadsAccountContext.tsx
diff --git a/web/src/types/api.ts b/frontend/src/types/api.ts
similarity index 92%
rename from web/src/types/api.ts
rename to frontend/src/types/api.ts
index 11f2a10..8290840 100644
--- a/web/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -250,6 +250,32 @@ export interface ImportThreadsAccountSessionData {
update_at: number
}
+export interface AIProviderOption {
+ id: string
+ label: string
+ streams: boolean
+}
+
+export interface AIProvidersData {
+ providers: AIProviderOption[]
+}
+
+export interface AIProviderModelsData {
+ id: string
+ label: string
+ models: string[]
+ streams: boolean
+ error?: string
+}
+
+export interface ThreadsAccountAiProviderModelsData {
+ id: string
+ label: string
+ models: string[]
+ streams: boolean
+ error?: string
+}
+
export interface ThreadsAccountAiSettingsData {
account_id: string
provider: string
diff --git a/web/src/types/brand.ts b/frontend/src/types/brand.ts
similarity index 100%
rename from web/src/types/brand.ts
rename to frontend/src/types/brand.ts
diff --git a/web/src/types/copyMission.ts b/frontend/src/types/copyMission.ts
similarity index 100%
rename from web/src/types/copyMission.ts
rename to frontend/src/types/copyMission.ts
diff --git a/web/src/types/placementTopic.ts b/frontend/src/types/placementTopic.ts
similarity index 100%
rename from web/src/types/placementTopic.ts
rename to frontend/src/types/placementTopic.ts
diff --git a/web/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
similarity index 100%
rename from web/src/vite-env.d.ts
rename to frontend/src/vite-env.d.ts
diff --git a/web/tsconfig.app.json b/frontend/tsconfig.app.json
similarity index 100%
rename from web/tsconfig.app.json
rename to frontend/tsconfig.app.json
diff --git a/web/tsconfig.json b/frontend/tsconfig.json
similarity index 100%
rename from web/tsconfig.json
rename to frontend/tsconfig.json
diff --git a/web/tsconfig.node.json b/frontend/tsconfig.node.json
similarity index 100%
rename from web/tsconfig.node.json
rename to frontend/tsconfig.node.json
diff --git a/web/vite.config.ts b/frontend/vite.config.ts
similarity index 100%
rename from web/vite.config.ts
rename to frontend/vite.config.ts
diff --git a/infra/.env.example b/infra/.env.example
new file mode 100644
index 0000000..4780b12
--- /dev/null
+++ b/infra/.env.example
@@ -0,0 +1,20 @@
+# Infra docker compose 環境變數範本
+# 複製成 infra/.env 後填入實際值;.env 不要 commit。
+
+# --- Mongo ---
+MONGO_PORT=27017
+MONGO_ROOT_USER=haixun
+MONGO_ROOT_PASSWORD=1qaz@WSX3edc$RFV
+MONGO_DATABASE=haixun
+
+# --- Redis ---
+REDIS_PORT=6379
+REDIS_PASSWORD=change-me-redis-pass
+
+INIT_ADMIN_EMAIL=admin@30cm.net
+PASSWORD=Fafafafa54088
+
+HAIXUN_JWT_ACCESS_SECRET=1qaz@WSX3edc$RFV
+HAIXUN_JWT_REFRESH_SECRET=1qaz@WSX3edc$RFV
+HAIXUN_SECRETS_KEY=1qaz@WSX3edc$RFV
+HAIXUN_WORKER_SECRET=1qaz@WSX3edc$RFV
diff --git a/infra/README.md b/infra/README.md
new file mode 100644
index 0000000..afb57fd
--- /dev/null
+++ b/infra/README.md
@@ -0,0 +1,103 @@
+# 巡樓部署 (infra)
+
+部署拓樸:
+
+```
+瀏覽器 → nginx(systemd, :80/:443)
+ ├─ 靜態前端 /var/www/haixun (frontend/dist)
+ └─ /api 反向代理 → Go gateway (systemd, 127.0.0.1:8890)
+Go gateway / Go worker (systemd) → Mongo / Redis (docker compose, 綁 127.0.0.1)
+Node playwright worker (systemd) → 透過 HTTP 打 gateway
+```
+
+- 資料服務(Mongo/Redis)用 docker compose,只綁 `127.0.0.1`。
+- Go gateway / Go worker / Node worker 都是 systemd 原生服務。
+- secret 一律放 `/opt/haixun/etc/haixun.env`(不進 repo),yaml 用 `${VAR}` 讀取。
+
+## 目錄
+
+```
+infra/
+ docker-compose.yml # mongo + redis
+ .env.example # compose 用環境變數
+ etc/haixun.env.example # systemd EnvironmentFile 範本(secret)
+ nginx/haixun.conf # 靜態前端 + /api 反代 + SSE
+ systemd/
+ haixun-gateway.service
+ haixun-worker.service
+ haixun-node-worker.service
+```
+
+## 1. 起資料服務 (docker)
+
+```bash
+cd infra
+cp .env.example .env # 填入 Mongo/Redis 密碼
+docker compose --env-file .env up -d
+docker compose ps
+```
+
+## 2. 建置產物(本機或 CI)
+
+```bash
+make build # 前端 dist + 兩個 linux Go binary(backend/bin/)
+```
+
+## 3. 安裝到目標主機
+
+於目標主機(需 root):
+
+```bash
+sudo make install
+```
+
+`make install` 會:
+
+1. 建立使用者 `haixun` 與目錄 `/opt/haixun/{bin,etc,node-worker}`、`/var/www/haixun`。
+2. 複製 `backend/bin/{gateway,worker}`、`backend/etc/gateway.prod.yaml`、`backend/etc/gateway.worker.prod.yaml`。
+3. 複製 `frontend/dist/*` → `/var/www/haixun`。
+4. 複製 `backend/worker/*`(Node worker)→ `/opt/haixun/node-worker`,並 `npm ci` + `npx playwright install`。
+5. 安裝 `infra/systemd/*.service` 與 `infra/nginx/haixun.conf`。
+
+接著手動建立 secret 檔(**只做一次**):
+
+```bash
+sudo cp infra/etc/haixun.env.example /opt/haixun/etc/haixun.env
+sudo chmod 600 /opt/haixun/etc/haixun.env
+sudoedit /opt/haixun/etc/haixun.env # 填入實際 secret
+```
+
+## 4. 初始化資料庫與 admin 帳號(只做一次)
+
+Mongo 起來、secret 填好後,建立索引 / 權限 catalog / role_permissions,並建立第一個 admin:
+
+```bash
+# 可在 haixun.env 內設定 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD,或在這裡用環境變數覆寫
+sudo make prod-init
+# 等同:source /opt/haixun/etc/haixun.env 後執行 /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml
+```
+
+之後一般使用者可走 `POST /api/v1/auth/register` 自助註冊(前端登入頁)。
+
+## 5. 啟用服務
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now haixun-gateway haixun-worker haixun-node-worker
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+## 6. 健康檢查
+
+```bash
+curl http://127.0.0.1:8890/api/v1/health
+sudo systemctl status haixun-gateway haixun-worker haixun-node-worker
+journalctl -u haixun-gateway -f
+```
+
+## 產生 secret
+
+```bash
+openssl rand -base64 48 # JWT access / refresh / worker secret
+openssl rand -base64 32 # HAIXUN_SECRETS_KEY(機敏資料落地加密)
+```
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
new file mode 100644
index 0000000..fec1b16
--- /dev/null
+++ b/infra/docker-compose.yml
@@ -0,0 +1,42 @@
+# 巡樓資料服務:Mongo + Redis
+# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。
+# 啟動:docker compose -f infra/docker-compose.yml --env-file infra/.env up -d
+name: haixun-infra
+
+services:
+ mongo:
+ image: mongo:7
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:${MONGO_PORT:-27017}:27017"
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-haixun}
+ MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD is required}
+ MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-haixun}
+ volumes:
+ - mongo_data:/data/db
+ healthcheck:
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
+
+ redis:
+ image: redis:7
+ restart: unless-stopped
+ command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "--appendonly", "yes"]
+ ports:
+ - "127.0.0.1:${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
+volumes:
+ mongo_data:
+ redis_data:
diff --git a/infra/etc/haixun.env.example b/infra/etc/haixun.env.example
new file mode 100644
index 0000000..903366c
--- /dev/null
+++ b/infra/etc/haixun.env.example
@@ -0,0 +1,23 @@
+# 部署到目標主機的 /opt/haixun/etc/haixun.env(chmod 600,不要 commit 實值)
+# gateway.prod.yaml / gateway.worker.yaml 用 ${VAR} 讀取這些值(go-zero conf.UseEnv)。
+
+# Mongo(含 docker compose 設定的帳密;authSource=admin)
+HAIXUN_MONGO_URI=mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
+HAIXUN_MONGO_DB=haixun
+
+# Redis
+HAIXUN_REDIS_ADDR=127.0.0.1:6379
+HAIXUN_REDIS_PASSWORD=change-me-redis-pass
+
+# JWT secret(請用 openssl rand -base64 48 產生,兩把不同)
+HAIXUN_JWT_ACCESS_SECRET=replace-with-strong-random
+HAIXUN_JWT_REFRESH_SECRET=replace-with-another-strong-random
+
+# 內部 worker secret(gateway 與 node worker 必須一致)
+HAIXUN_WORKER_SECRET=replace-with-strong-random
+
+# 機敏資料落地加密金鑰(base64 編碼的 32 bytes;openssl rand -base64 32)
+HAIXUN_SECRETS_KEY=replace-with-base64-32-bytes
+
+# Node worker 連線設定
+HAIXUN_BACKEND_URL=http://127.0.0.1:8890
diff --git a/infra/nginx/haixun.conf b/infra/nginx/haixun.conf
new file mode 100644
index 0000000..69db9e0
--- /dev/null
+++ b/infra/nginx/haixun.conf
@@ -0,0 +1,56 @@
+# 巡樓 Console nginx 設定
+# 安裝:cp infra/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf && nginx -t && systemctl reload nginx
+# 前端靜態檔部署在 /var/www/haixun(make install 會放 frontend/dist 內容)。
+# /api 反向代理到本機 systemd 跑的 Go gateway (127.0.0.1:8890),含 SSE 串流設定。
+
+upstream haixun_gateway {
+ server 127.0.0.1:8890;
+ keepalive 32;
+}
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name _;
+
+ root /var/www/haixun;
+ index index.html;
+
+ # 安全標頭
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header X-XSS-Protection "0" always;
+
+ # 靜態資源快取(vite build 帶 hash 檔名)
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ try_files $uri =404;
+ }
+
+ # API 反向代理(一般 JSON 與 SSE 共用)
+ location /api/ {
+ proxy_pass http://haixun_gateway;
+ 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;
+
+ # Authorization(provider token)與 X-Member-Authorization(會員 JWT)由 nginx 預設轉發;
+ # 以下確保 SSE 串流不被緩衝、長連線不被提前中斷。
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ chunked_transfer_encoding off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ # SPA:所有非檔案路徑都回 index.html,交給 react-router
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/infra/systemd/haixun-gateway.service b/infra/systemd/haixun-gateway.service
new file mode 100644
index 0000000..9030f3f
--- /dev/null
+++ b/infra/systemd/haixun-gateway.service
@@ -0,0 +1,25 @@
+[Unit]
+Description=Haixun Gateway API (go-zero)
+After=network-online.target docker.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=haixun
+Group=haixun
+WorkingDirectory=/opt/haixun
+# secrets(JWT / Mongo URI / Redis 密碼 / worker secret / 加密金鑰)放這裡,不進 repo
+EnvironmentFile=/opt/haixun/etc/haixun.env
+ExecStart=/opt/haixun/bin/gateway -f /opt/haixun/etc/gateway.prod.yaml
+Restart=always
+RestartSec=5
+LimitNOFILE=65535
+
+# 加固
+NoNewPrivileges=true
+ProtectSystem=full
+ProtectHome=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/infra/systemd/haixun-node-worker.service b/infra/systemd/haixun-node-worker.service
new file mode 100644
index 0000000..7221767
--- /dev/null
+++ b/infra/systemd/haixun-node-worker.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Haixun Node Playwright Worker (style-8d)
+After=network-online.target haixun-gateway.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=haixun
+Group=haixun
+WorkingDirectory=/opt/haixun/node-worker
+# 至少需要 HAIXUN_BACKEND_URL 與 HAIXUN_WORKER_SECRET(與 gateway 的 InternalWorker.Secret 一致)
+EnvironmentFile=/opt/haixun/etc/haixun.env
+ExecStart=/usr/bin/npx tsx style-8d-worker.ts
+Restart=always
+RestartSec=10
+LimitNOFILE=65535
+
+NoNewPrivileges=true
+ProtectSystem=full
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/infra/systemd/haixun-worker.service b/infra/systemd/haixun-worker.service
new file mode 100644
index 0000000..df7a3e6
--- /dev/null
+++ b/infra/systemd/haixun-worker.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Haixun Go Job Worker
+After=network-online.target docker.service haixun-gateway.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=haixun
+Group=haixun
+WorkingDirectory=/opt/haixun
+EnvironmentFile=/opt/haixun/etc/haixun.env
+ExecStart=/opt/haixun/bin/worker -f /opt/haixun/etc/gateway.worker.prod.yaml
+Restart=always
+RestartSec=5
+LimitNOFILE=65535
+
+NoNewPrivileges=true
+ProtectSystem=full
+ProtectHome=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go
deleted file mode 100644
index 1ac202c..0000000
--- a/internal/middleware/auth_middleware.go
+++ /dev/null
@@ -1,22 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package middleware
-
-import "net/http"
-
-type AuthMiddleware struct {
-}
-
-func NewAuthMiddleware() *AuthMiddleware {
- return &AuthMiddleware{}
-}
-
-func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- // TODO generate middleware implement function, delete after code implementation
-
- // Passthrough to next handler if need
- next(w, r)
- }
-}
diff --git a/internal/model/scan_post/domain/entity/post.go b/internal/model/scan_post/domain/entity/post.go
deleted file mode 100644
index dbbd81f..0000000
--- a/internal/model/scan_post/domain/entity/post.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package entity
-
-const CollectionName = "scan_posts"
-
-const FlowViral = "viral"
-const FlowPlacement = "placement"
-
-type ScanReply struct {
- ExternalID string `bson:"external_id,omitempty"`
- Author string `bson:"author,omitempty"`
- Text string `bson:"text"`
- Permalink string `bson:"permalink,omitempty"`
- LikeCount int `bson:"like_count,omitempty"`
- PostedAt string `bson:"posted_at,omitempty"`
-}
-
-type ScanPost struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- TopicID string `bson:"topic_id,omitempty"`
- LegacyPersonaID string `bson:"persona_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- Flow string `bson:"flow,omitempty"`
- GraphID string `bson:"graph_id"`
- ScanJobID string `bson:"scan_job_id"`
- GraphNodeID string `bson:"graph_node_id"`
- SearchTag string `bson:"search_tag"`
- QueryDimension string `bson:"query_dimension"`
- ExternalID string `bson:"external_id"`
- Permalink string `bson:"permalink"`
- Author string `bson:"author"`
- AuthorVerified bool `bson:"author_verified,omitempty"`
- FollowerCount int `bson:"follower_count,omitempty"`
- Text string `bson:"text"`
- Priority string `bson:"priority"`
- LikeCount int `bson:"like_count,omitempty"`
- ReplyCount int `bson:"reply_count,omitempty"`
- EngagementScore int `bson:"engagement_score,omitempty"`
- PlacementScore int `bson:"placement_score"`
- ProductFitScore int `bson:"product_fit_score"`
- SolvedByProduct bool `bson:"solved_by_product"`
- Source string `bson:"source"`
- OutreachStatus string `bson:"outreach_status,omitempty"`
- PublishedReplyID string `bson:"published_reply_id,omitempty"`
- PublishedPermalink string `bson:"published_permalink,omitempty"`
- OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"`
- PostedAt string `bson:"posted_at,omitempty"`
- Replies []ScanReply `bson:"replies,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
-
-const (
- OutreachStatusPending = "pending"
- OutreachStatusDrafted = "drafted"
- OutreachStatusPublished = "published"
- OutreachStatusHandled = "handled"
- OutreachStatusSkipped = "skipped"
-)
diff --git a/scripts/debug-opencode-raw.sh b/scripts/debug-opencode-raw.sh
deleted file mode 100755
index b92e53c..0000000
--- a/scripts/debug-opencode-raw.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env bash
-# 直接對比 OpenCode upstream 與本機 gateway 的原始 JSON/SSE,方便排查空回應
-#
-# 用法:
-# OPENCODE_TOKEN=sk-xxx ./scripts/debug-opencode-raw.sh
-
-set -u
-
-BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
-OPENCODE_TOKEN="${OPENCODE_TOKEN:-}"
-MODEL="${MODEL:-deepseek-v4-flash}"
-MESSAGE="${MESSAGE:-Introduce yourself in one sentence.}"
-
-if [[ -z "$OPENCODE_TOKEN" ]]; then
- echo "OPENCODE_TOKEN is required"
- exit 1
-fi
-
-require_cmd() {
- command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
-}
-
-require_cmd curl
-require_cmd jq
-
-payload="$(jq -n \
- --arg model "$MODEL" \
- --arg message "$MESSAGE" \
- '{
- model: $model,
- messages: [{role: "user", content: $message}],
- max_tokens: 2048,
- thinking: {type: "disabled"}
- }')"
-
-echo "=== OpenCode upstream (non-stream) ==="
-curl -sS -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \
- -H "Authorization: Bearer ${OPENCODE_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$payload" | jq .
-
-echo ""
-echo "=== Local gateway (non-stream) ==="
-curl -sS -m 120 -X POST "${BASE_URL}/api/v1/ai/chat" \
- -H "Authorization: Bearer ${OPENCODE_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$(jq -n \
- --arg provider "opencode-go" \
- --arg model "$MODEL" \
- --arg message "$MESSAGE" \
- '{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | jq .
-
-echo ""
-echo "=== OpenCode upstream (stream, first 20 lines) ==="
-curl -sS -N -m 120 -X POST "https://opencode.ai/zen/go/v1/chat/completions" \
- -H "Authorization: Bearer ${OPENCODE_TOKEN}" \
- -H "Content-Type: application/json" \
- -H "Accept: text/event-stream" \
- -d "$(echo "$payload" | jq '.stream=true')" | head -n 20
-
-echo ""
-echo "=== Local gateway (stream, first 20 lines) ==="
-curl -sS -N -m 120 -X POST "${BASE_URL}/api/v1/ai/chat/stream" \
- -H "Authorization: Bearer ${OPENCODE_TOKEN}" \
- -H "Content-Type: application/json" \
- -H "Accept: text/event-stream" \
- -d "$(jq -n \
- --arg provider "opencode-go" \
- --arg model "$MODEL" \
- --arg message "$MESSAGE" \
- '{provider:$provider,model:$model,messages:[{role:"user",content:$message}],max_tokens:2048}')" | head -n 20
\ No newline at end of file
diff --git a/scripts/dev-with-style-8d.sh b/scripts/dev-with-style-8d.sh
deleted file mode 100644
index 94997b6..0000000
--- a/scripts/dev-with-style-8d.sh
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-BACKEND_DIR="$ROOT_DIR/haixun-backend"
-CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
-BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
-
-pids=()
-owned_pids=()
-
-cleanup() {
- local code=$?
- if ((${#owned_pids[@]} > 0)); then
- echo ""
- echo "[dev-8d] stopping backend and worker..."
- kill "${owned_pids[@]}" 2>/dev/null || true
- wait "${owned_pids[@]}" 2>/dev/null || true
- fi
- exit "$code"
-}
-
-trap cleanup EXIT INT TERM
-
-if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- echo "[dev-8d] backend already running: $BACKEND_URL"
-else
- echo "[dev-8d] starting Go backend: $CONFIG_FILE"
- (
- cd "$BACKEND_DIR"
- go run ./gateway.go -f "$CONFIG_FILE"
- ) &
- pids+=("$!")
- owned_pids+=("$!")
-
- echo "[dev-8d] waiting for backend health..."
- for _ in $(seq 1 30); do
- if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- break
- fi
- if ! kill -0 "${pids[0]}" 2>/dev/null; then
- wait "${pids[0]}"
- fi
- sleep 1
- done
-
- if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- echo "[dev-8d] backend health check timed out: $BACKEND_URL" >&2
- exit 1
- fi
-fi
-
-echo "[dev-8d] starting Node 8D worker"
-(
- cd "$ROOT_DIR"
- npm run worker:style-8d
-) &
-pids+=("$!")
-owned_pids+=("$!")
-
-echo "[dev-8d] running pids=${pids[*]}"
-echo "[dev-8d] press Ctrl+C to stop both"
-
-while true; do
- for pid in "${owned_pids[@]}"; do
- if ! kill -0 "$pid" 2>/dev/null; then
- wait "$pid"
- exit $?
- fi
- done
- sleep 1
-done
diff --git a/scripts/package-extension.sh b/scripts/package-extension.sh
deleted file mode 100755
index 47f378c..0000000
--- a/scripts/package-extension.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-EXT_DIR="$ROOT_DIR/extension/haixun-threads-sync"
-OUT_DIR="$ROOT_DIR/haixun-backend/web/public/downloads"
-OUT_FILE="$OUT_DIR/haixun-threads-sync.zip"
-
-if [[ ! -f "$EXT_DIR/manifest.json" ]]; then
- echo "extension not found: $EXT_DIR" >&2
- exit 1
-fi
-
-mkdir -p "$OUT_DIR"
-rm -f "$OUT_FILE"
-
-(
- cd "$(dirname "$EXT_DIR")"
- zip -qr "$OUT_FILE" "$(basename "$EXT_DIR")" \
- -x "*.DS_Store" -x "*__MACOSX*"
-)
-
-VERSION="$(python3 -c "import json; print(json.load(open('$EXT_DIR/manifest.json'))['version'])")"
-echo "packed haixun-threads-sync v$VERSION -> $OUT_FILE"
\ No newline at end of file
diff --git a/scripts/prod-common.sh b/scripts/prod-common.sh
deleted file mode 100755
index 51e9577..0000000
--- a/scripts/prod-common.sh
+++ /dev/null
@@ -1,201 +0,0 @@
-#!/usr/bin/env bash
-# shellcheck disable=SC2034
-# Shared helpers for production Docker scripts.
-
-_PROD_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-BACKEND_DIR="$(cd "$_PROD_SCRIPT_DIR/.." && pwd)"
-DEPLOY_DIR="$BACKEND_DIR/deploy"
-COMPOSE_FILE="$DEPLOY_DIR/docker-compose.prod.yml"
-ENV_FILE="$DEPLOY_DIR/.env"
-ENV_EXAMPLE="$DEPLOY_DIR/.env.example"
-
-prod_common_init() {
- :
-}
-
-prod_load_env() {
- prod_common_init
-
- if [[ ! -f "$ENV_FILE" ]]; then
- if [[ -f "$ENV_EXAMPLE" ]]; then
- cp "$ENV_EXAMPLE" "$ENV_FILE"
- echo "[prod] created $ENV_FILE from .env.example — 請先修改密鑰與管理員密碼"
- else
- echo "[prod] missing $ENV_FILE" >&2
- exit 1
- fi
- fi
-
- set -a
- # shellcheck disable=SC1090
- source "$ENV_FILE"
- set +a
-
- GO_REPLICAS="${GO_WORKER_REPLICAS:-1}"
- NODE_REPLICAS="${NODE_STYLE8D_WORKER_REPLICAS:-1}"
- WEB_PORT="${HAIXUN_WEB_PORT:-8080}"
- MONGO_DB="${HAIXUN_MONGO_DATABASE:-haixun}"
-}
-
-prod_require_docker() {
- if ! command -v docker >/dev/null 2>&1; then
- echo "[prod] docker is required" >&2
- exit 1
- fi
-}
-
-prod_compose() {
- prod_common_init
- docker compose -f "$COMPOSE_FILE" "$@"
-}
-
-prod_service_health() {
- local service="$1"
- prod_compose ps --format json "$service" 2>/dev/null \
- | grep -o '"Health":"[^"]*"' \
- | head -1 \
- | cut -d'"' -f4 \
- || true
-}
-
-prod_named_container_health() {
- local name="$1"
- docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{if .State.Running}}running{{else}}stopped{{end}}{{end}}' \
- "$name" 2>/dev/null || true
-}
-
-prod_named_container_running() {
- local name="$1"
- [[ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || echo false)" == "true" ]]
-}
-
-prod_deps_healthy() {
- local mongo_ok redis_ok
- mongo_ok="$(prod_service_health mongo)"
- redis_ok="$(prod_service_health redis)"
- if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then
- return 0
- fi
-
- # compose ps 偶發失敗時,改查實際 container(避免重複 create 撞名)
- mongo_ok="$(prod_named_container_health haixun-prod-mongo-1)"
- redis_ok="$(prod_named_container_health haixun-prod-redis-1)"
- [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]
-}
-
-prod_wait_deps_healthy() {
- echo "[prod] waiting for mongo/redis..."
- for _ in $(seq 1 90); do
- if prod_deps_healthy; then
- return 0
- fi
- sleep 1
- done
- echo "[prod] mongo/redis did not become healthy in time" >&2
- exit 1
-}
-
-prod_ensure_deps() {
- if prod_deps_healthy; then
- echo "[prod] mongo + redis already healthy — 略過重啟(資料在 named volume)"
- return 0
- fi
-
- echo "[prod] starting mongo + redis..."
- if prod_named_container_running haixun-prod-mongo-1 && prod_named_container_running haixun-prod-redis-1; then
- prod_compose start mongo redis 2>/dev/null \
- || prod_compose up -d --no-recreate mongo redis
- else
- prod_compose up -d mongo redis
- fi
- prod_wait_deps_healthy
-}
-
-prod_mongo_has_members() {
- prod_compose exec -T mongo mongosh --quiet "$MONGO_DB" --eval \
- 'db.members.countDocuments({})' 2>/dev/null \
- | tr -d '\r' \
- | grep -Eq '^[1-9][0-9]*$'
-}
-
-prod_should_skip_init() {
- if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then
- return 0
- fi
- if [[ "${PROD_FORCE_INIT:-0}" == "1" ]]; then
- return 1
- fi
- if prod_mongo_has_members; then
- return 0
- fi
- return 1
-}
-
-prod_run_init_if_needed() {
- if prod_should_skip_init; then
- if [[ "${HAIXUN_SKIP_INIT:-0}" == "1" ]]; then
- echo "[prod] skip init (HAIXUN_SKIP_INIT=1)"
- else
- echo "[prod] skip init (Mongo 已有資料;若要強制重跑請設 PROD_FORCE_INIT=1)"
- fi
- return 0
- fi
-
- echo "[prod] running bootstrap init..."
- prod_compose --profile init run --rm init
-}
-
-prod_build_web_if_static() {
- prod_common_init
- if [[ "${HAIXUN_WEB_BUILD_MODE:-static}" == "static" ]]; then
- echo "[prod] building frontend static files (vite → web/dist)..."
- (cd "$BACKEND_DIR" && make web-build)
- else
- echo "[prod] HAIXUN_WEB_BUILD_MODE=docker — web image will compile inside Docker"
- fi
-}
-
-prod_start_app_services() {
- local build_flag=()
- if [[ "${PROD_SKIP_BUILD:-0}" != "1" ]]; then
- build_flag=(--build)
- fi
-
- echo "[prod] starting api, web, workers (go=${GO_REPLICAS}, node-style-8d=${NODE_REPLICAS})..."
- prod_compose up -d "${build_flag[@]}" \
- --no-deps \
- --scale "go-worker=${GO_REPLICAS}" \
- --scale "node-worker-style-8d=${NODE_REPLICAS}" \
- api web go-worker node-worker-style-8d
-}
-
-prod_wait_api_health() {
- echo "[prod] waiting for API health..."
- for _ in $(seq 1 60); do
- if curl -fsS "http://127.0.0.1:${WEB_PORT}/api/v1/health" >/dev/null 2>&1; then
- return 0
- fi
- sleep 1
- done
- echo "[prod] API health check timed out" >&2
- exit 1
-}
-
-prod_print_volume_hint() {
- echo " Data: Mongo/Redis 使用 named volume(重啟 container 不會清資料)"
- echo " Update app: make -C haixun-backend prod-update"
- echo " Wipe data: make -C haixun-backend prod-wipe-data # 會刪除 volume"
-}
-
-prod_print_stack_summary() {
- echo ""
- echo "[prod] stack is up"
- echo " Web: http://127.0.0.1:${WEB_PORT}"
- echo " API: http://127.0.0.1:${WEB_PORT}/api/v1/health (via nginx)"
- echo " Go worker: ${GO_REPLICAS} replica(s)"
- echo " Node 8D: ${NODE_REPLICAS} replica(s)"
- echo " Env: ${ENV_FILE}"
- echo " Stop: make -C haixun-backend prod-down"
- echo " Logs: make -C haixun-backend prod-logs"
- prod_print_volume_hint
-}
\ No newline at end of file
diff --git a/scripts/prod-deps.sh b/scripts/prod-deps.sh
deleted file mode 100755
index 3ac8b63..0000000
--- a/scripts/prod-deps.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# shellcheck source=scripts/prod-common.sh
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
-
-prod_load_env
-prod_require_docker
-
-cd "$DEPLOY_DIR"
-
-prod_ensure_deps
-
-echo ""
-echo "[prod] mongo + redis ready"
-prod_print_volume_hint
\ No newline at end of file
diff --git a/scripts/prod-down.sh b/scripts/prod-down.sh
deleted file mode 100755
index 94e9cf1..0000000
--- a/scripts/prod-down.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# shellcheck source=scripts/prod-common.sh
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
-
-prod_common_init
-cd "$DEPLOY_DIR"
-
-prod_compose down --remove-orphans
-echo "[prod] stopped(Mongo/Redis 資料仍在 named volume,下次 prod / prod-deps 會沿用)"
\ No newline at end of file
diff --git a/scripts/prod-logs.sh b/scripts/prod-logs.sh
deleted file mode 100755
index 43fa9e5..0000000
--- a/scripts/prod-logs.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
-
-cd "$BACKEND_DIR/deploy"
-docker compose -f "$COMPOSE_FILE" logs -f --tail=200 "${@:-}"
\ No newline at end of file
diff --git a/scripts/prod-up.sh b/scripts/prod-up.sh
deleted file mode 100755
index e3dce92..0000000
--- a/scripts/prod-up.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# shellcheck source=scripts/prod-common.sh
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
-
-prod_load_env
-prod_require_docker
-
-cd "$BACKEND_DIR"
-prod_build_web_if_static
-
-cd "$DEPLOY_DIR"
-
-echo "[prod] building images..."
-prod_compose build
-
-prod_ensure_deps
-prod_run_init_if_needed
-
-prod_start_app_services
-prod_wait_api_health
-prod_print_stack_summary
\ No newline at end of file
diff --git a/scripts/prod-update.sh b/scripts/prod-update.sh
deleted file mode 100755
index 5398784..0000000
--- a/scripts/prod-update.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# 只重建/重啟 API、Web、Workers;不碰 mongo/redis(資料留在 volume)。
-
-# shellcheck source=scripts/prod-common.sh
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
-
-prod_load_env
-prod_require_docker
-
-cd "$BACKEND_DIR"
-prod_build_web_if_static
-
-cd "$DEPLOY_DIR"
-
-if ! prod_deps_healthy; then
- echo "[prod] mongo/redis 未在運行,先啟動依賴(不會清 volume)..."
- prod_ensure_deps
-else
- echo "[prod] mongo + redis 維持運行 — 只更新應用層"
-fi
-
-echo "[prod] building app images (api, web, workers)..."
-prod_compose build api web go-worker node-worker-style-8d
-
-prod_start_app_services
-prod_wait_api_health
-prod_print_stack_summary
\ No newline at end of file
diff --git a/scripts/prod-wipe-data.sh b/scripts/prod-wipe-data.sh
deleted file mode 100755
index 1bbb805..0000000
--- a/scripts/prod-wipe-data.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# 危險:停止 stack 並刪除 Mongo/Redis named volume。
-
-# shellcheck source=scripts/prod-common.sh
-source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/prod-common.sh"
-
-prod_load_env
-prod_require_docker
-
-cd "$DEPLOY_DIR"
-
-echo "[prod] 這會刪除 haixun-prod_mongo_data 與 haixun-prod_redis_data 內所有資料。"
-read -r -p "輸入 yes 才會繼續: " confirm
-if [[ "$confirm" != "yes" ]]; then
- echo "[prod] cancelled"
- exit 1
-fi
-
-prod_compose down -v --remove-orphans
-echo "[prod] volumes removed — 下次 make prod 會是全新資料庫"
\ No newline at end of file
diff --git a/scripts/restart-all.sh b/scripts/restart-all.sh
deleted file mode 100755
index f8c1d04..0000000
--- a/scripts/restart-all.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-
-bash "$BACKEND_DIR/scripts/stop-all.sh"
-bash "$BACKEND_DIR/scripts/start-all.sh"
\ No newline at end of file
diff --git a/scripts/start-all.sh b/scripts/start-all.sh
deleted file mode 100755
index 98b9dba..0000000
--- a/scripts/start-all.sh
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-BACKEND_DIR="$ROOT_DIR/haixun-backend"
-RUN_DIR="$BACKEND_DIR/.run"
-LOG_DIR="$RUN_DIR/logs"
-COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
-CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
-BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
-WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
-
-mkdir -p "$RUN_DIR" "$LOG_DIR"
-
-bash "$BACKEND_DIR/scripts/stop-all.sh"
-
-if ! command -v docker >/dev/null 2>&1; then
- echo "[start-all] docker not found; skip mongo/redis" >&2
-else
- echo "[start-all] starting mongo + redis..."
- docker compose -f "$COMPOSE_FILE" up -d
-fi
-
-echo "[start-all] starting Go API ($CONFIG_FILE)..."
-(
- cd "$BACKEND_DIR"
- go run ./gateway.go -f "$CONFIG_FILE"
-) >"$LOG_DIR/api.log" 2>&1 &
-echo $! >"$RUN_DIR/api.pid"
-
-echo "[start-all] waiting for API health ($BACKEND_URL)..."
-for _ in $(seq 1 40); do
- if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- break
- fi
- if ! kill -0 "$(cat "$RUN_DIR/api.pid")" 2>/dev/null; then
- echo "[start-all] API exited early; see $LOG_DIR/api.log" >&2
- tail -n 20 "$LOG_DIR/api.log" >&2 || true
- exit 1
- fi
- sleep 1
-done
-if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- echo "[start-all] API health check timed out; see $LOG_DIR/api.log" >&2
- exit 1
-fi
-
-if [[ ! -d "$BACKEND_DIR/web/node_modules" ]]; then
- echo "[start-all] installing web dependencies..."
- (cd "$BACKEND_DIR/web" && npm install)
-fi
-
-echo "[start-all] starting web dev server..."
-(
- cd "$BACKEND_DIR/web"
- npm run dev
-) >"$LOG_DIR/web.log" 2>&1 &
-echo $! >"$RUN_DIR/web.pid"
-
-echo "[start-all] starting Node 8D worker..."
-(
- cd "$ROOT_DIR"
- npm run worker:style-8d
-) >"$LOG_DIR/worker.log" 2>&1 &
-echo $! >"$RUN_DIR/worker.pid"
-
-sleep 2
-
-echo ""
-echo "[start-all] all services started"
-echo " API: $BACKEND_URL"
-echo " Web: $WEB_URL"
-echo " Logs: $LOG_DIR/{api,web,worker}.log"
-echo " Stop: make -C haixun-backend stop-all"
-echo " Status: make -C haixun-backend status-all"
\ No newline at end of file
diff --git a/scripts/status-all.sh b/scripts/status-all.sh
deleted file mode 100755
index 7d740be..0000000
--- a/scripts/status-all.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-RUN_DIR="$BACKEND_DIR/.run"
-COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
-BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
-WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
-
-check_pid() {
- local name="$1"
- local file="$RUN_DIR/${name}.pid"
- if [[ -f "$file" ]]; then
- local pid
- pid="$(cat "$file" 2>/dev/null || true)"
- if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
- echo " $name: running (pid=$pid)"
- return 0
- fi
- fi
- echo " $name: stopped"
- return 1
-}
-
-echo "Haixun dev services"
-echo ""
-
-if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
- echo "Docker:"
- docker compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (docker compose not running)"
- echo ""
-fi
-
-echo "Processes:"
-check_pid api || true
-check_pid web || true
-check_pid worker || true
-echo ""
-
-echo "Health:"
-if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
- echo " API health: OK ($BACKEND_URL)"
-else
- echo " API health: down ($BACKEND_URL)"
-fi
-if curl -fsS "$WEB_URL" >/dev/null 2>&1; then
- echo " Web: OK ($WEB_URL)"
-else
- echo " Web: down ($WEB_URL)"
-fi
\ No newline at end of file
diff --git a/scripts/stop-all.sh b/scripts/stop-all.sh
deleted file mode 100755
index e336405..0000000
--- a/scripts/stop-all.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-BACKEND_DIR="$ROOT_DIR/haixun-backend"
-RUN_DIR="$BACKEND_DIR/.run"
-COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
-
-stop_pid_file() {
- local name="$1"
- local file="$RUN_DIR/${name}.pid"
- if [[ ! -f "$file" ]]; then
- return 0
- fi
- local pid
- pid="$(cat "$file" 2>/dev/null || true)"
- if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
- echo "[stop-all] stopping $name (pid=$pid)"
- kill "$pid" 2>/dev/null || true
- for _ in $(seq 1 10); do
- kill -0 "$pid" 2>/dev/null || break
- sleep 0.2
- done
- kill -9 "$pid" 2>/dev/null || true
- fi
- rm -f "$file"
-}
-
-echo "[stop-all] stopping tracked processes..."
-for name in worker web api; do
- stop_pid_file "$name"
-done
-
-echo "[stop-all] stopping stray processes..."
-pkill -f "haixun-backend/worker/style-8d-worker" 2>/dev/null || true
-pkill -f "worker:style-8d" 2>/dev/null || true
-pkill -f "haixun-backend/web/node_modules/.bin/vite" 2>/dev/null || true
-pkill -f "go run ./gateway.go -f etc/gateway.yaml" 2>/dev/null || true
-# `go run` spawns a compiled binary child under the go-build cache (e.g.
-# ~/Library/Caches/go-build/.../gateway) that is NOT killed when the parent
-# wrapper dies; kill the orphan too so it stops serving stale routes on the API
-# port and frees the port for the freshly built binary.
-pkill -f "/gateway -f etc/gateway.yaml" 2>/dev/null || true
-pkill -f "dev-with-style-8d.sh" 2>/dev/null || true
-
-if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
- echo "[stop-all] stopping docker compose (mongo + redis)..."
- docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
-fi
-
-echo "[stop-all] done"
\ No newline at end of file
diff --git a/scripts/test-job-cancel.sh b/scripts/test-job-cancel.sh
deleted file mode 100755
index abdad27..0000000
--- a/scripts/test-job-cancel.sh
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env bash
-# 示範 job 建立與取消:running 時會透過 Redis jobs:cancel: 戳 worker
-#
-# 用法:
-# ./scripts/test-job-cancel.sh
-
-set -u
-
-BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
-SCOPE="${SCOPE:-user}"
-SCOPE_ID="${SCOPE_ID:-demo_user_1}"
-
-require_cmd() {
- command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
-}
-
-require_cmd curl
-require_cmd jq
-
-echo "== create demo_long_task =="
-CREATE_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs" \
- -H "Content-Type: application/json" \
- -d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" '{
- template_type: "demo_long_task",
- scope: $scope,
- scope_id: $scope_id,
- payload: {target: "demo"}
- }')")"
-
-echo "$CREATE_BODY" | jq .
-JOB_ID="$(echo "$CREATE_BODY" | jq -r '.data.id // empty')"
-if [[ -z "$JOB_ID" ]]; then
- echo "failed to create job"
- exit 1
-fi
-
-echo ""
-echo "== wait until running (max 10s) =="
-for _ in $(seq 1 20); do
- STATUS="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}" | jq -r '.data.status')"
- echo "status: $STATUS"
- if [[ "$STATUS" == "running" || "$STATUS" == "cancel_requested" ]]; then
- break
- fi
- if [[ "$STATUS" == "succeeded" || "$STATUS" == "cancelled" ]]; then
- echo "job finished before cancel test: $STATUS"
- exit 0
- fi
- sleep 0.5
-done
-
-echo ""
-echo "== cancel while worker is active =="
-CANCEL_BODY="$(curl -sS -X POST "${BASE_URL}/api/v1/jobs/${JOB_ID}/cancel" \
- -H "Content-Type: application/json" \
- -d '{"reason":"demo cancel from script"}')"
-echo "$CANCEL_BODY" | jq .
-
-echo ""
-echo "== poll until cancelled (max 20s) =="
-for _ in $(seq 1 40); do
- BODY="$(curl -sS "${BASE_URL}/api/v1/jobs/${JOB_ID}")"
- STATUS="$(echo "$BODY" | jq -r '.data.status')"
- PHASE="$(echo "$BODY" | jq -r '.data.phase')"
- SUMMARY="$(echo "$BODY" | jq -r '.data.progress.summary')"
- echo "status=$STATUS phase=$PHASE summary=$SUMMARY"
- if [[ "$STATUS" == "cancelled" ]]; then
- echo ""
- echo "ok: worker acknowledged cancel"
- exit 0
- fi
- sleep 0.5
-done
-
-echo "timeout waiting for cancelled status"
-exit 1
\ No newline at end of file
diff --git a/scripts/test-job-concurrency.sh b/scripts/test-job-concurrency.sh
deleted file mode 100755
index 02dd3db..0000000
--- a/scripts/test-job-concurrency.sh
+++ /dev/null
@@ -1,87 +0,0 @@
-#!/usr/bin/env bash
-# Verify scheme B: same scope_id+target blocks concurrent runs; different targets allow parallel.
-#
-# Usage:
-# ./scripts/test-job-concurrency.sh
-
-set -u
-
-BASE_URL="${BASE_URL:-http://127.0.0.1:8890}"
-SCOPE="${SCOPE:-user}"
-SCOPE_ID="${SCOPE_ID:-concurrency_demo_user}"
-TEMPLATE_TYPE="${TEMPLATE_TYPE:-demo_long_task}"
-TARGET_A="${TARGET_A:-building_A}"
-TARGET_B="${TARGET_B:-building_B}"
-
-require_cmd() {
- command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1"; exit 1; }
-}
-
-require_cmd curl
-require_cmd jq
-
-create_job() {
- local target="$1"
- curl -sS -X POST "${BASE_URL}/api/v1/jobs" \
- -H "Content-Type: application/json" \
- -d "$(jq -n --arg scope "$SCOPE" --arg scope_id "$SCOPE_ID" --arg template "$TEMPLATE_TYPE" --arg target "$target" '{
- template_type: $template,
- scope: $scope,
- scope_id: $scope_id,
- payload: {target: $target}
- }')"
-}
-
-echo "== configure template for scheme B =="
-PUT_BODY="$(curl -sS -X PUT "${BASE_URL}/api/v1/job/templates/${TEMPLATE_TYPE}" \
- -H "Content-Type: application/json" \
- -d "$(jq -n '{
- name: "Demo Long Task",
- enabled: true,
- repeatable: true,
- concurrency_policy: "allow_parallel",
- dedupe_keys: ["scope_id", "target"],
- timeout_seconds: 600,
- cancel_policy: {supported: true, mode: "cooperative", grace_seconds: 30},
- retry_policy: {max_attempts: 2, backoff_seconds: [30, 120]},
- steps: [
- {id: "prepare", name: "Prepare", worker_type: "go", timeout_seconds: 60, cancelable: true},
- {id: "execute", name: "Execute", worker_type: "go", timeout_seconds: 300, cancelable: true},
- {id: "finalize", name: "Finalize", worker_type: "go", timeout_seconds: 30, cancelable: false}
- ]
- }')")"
-echo "$PUT_BODY" | jq .
-if [[ "$(echo "$PUT_BODY" | jq -r '.code')" != "102000" ]]; then
- echo "failed to upsert template"
- exit 1
-fi
-
-echo ""
-echo "== same target: first create should succeed =="
-FIRST="$(create_job "$TARGET_A")"
-echo "$FIRST" | jq .
-if [[ "$(echo "$FIRST" | jq -r '.code')" != "102000" ]]; then
- echo "first create failed"
- exit 1
-fi
-
-echo ""
-echo "== same target: second create should fail while first is active =="
-SECOND="$(create_job "$TARGET_A")"
-echo "$SECOND" | jq .
-if [[ "$(echo "$SECOND" | jq -r '.code')" == "102000" ]]; then
- echo "expected duplicate create to fail"
- exit 1
-fi
-
-echo ""
-echo "== different target: should succeed in parallel =="
-THIRD="$(create_job "$TARGET_B")"
-echo "$THIRD" | jq .
-if [[ "$(echo "$THIRD" | jq -r '.code')" != "102000" ]]; then
- echo "different target create failed"
- exit 1
-fi
-
-echo ""
-echo "ok: scheme B concurrency behaves as expected"
\ No newline at end of file
diff --git a/start-prod.sh b/start-prod.sh
new file mode 100755
index 0000000..a25f15a
--- /dev/null
+++ b/start-prod.sh
@@ -0,0 +1,59 @@
+#!/bin/bash
+set -e
+
+echo "=== 巡樓 Production 啟動 ==="
+
+# 1. 確認 nginx 不會被 default site 搶走
+echo "[1/7] 確保 nginx config..."
+if [ -f /etc/nginx/sites-enabled/default ]; then
+ sudo rm /etc/nginx/sites-enabled/default
+fi
+
+# 2. 起 infra (Mongo + Redis)
+echo "[2/7] 啟動 Mongo + Redis..."
+docker compose -f infra/docker-compose.yml --env-file infra/.env up -d
+
+# 3. 確保 Playwright 瀏覽器已安裝
+echo "[3/7] 確認 Playwright chromium..."
+if [ ! -d /opt/haixun/.cache/ms-playwright/chromium-1228 ]; then
+ sudo -u haixun bash -c 'cd /opt/haixun/node-worker && npx playwright install chromium'
+fi
+
+# 4. 清除 systemd 失敗計數(之前 crash loop 的話需要 reset)
+echo "[4/7] 重置 systemd 狀態..."
+for svc in haixun-gateway haixun-worker haixun-node-worker; do
+ sudo systemctl reset-failed "$svc" 2>/dev/null || true
+done
+
+# 5. 啟動 systemd 服務
+echo "[5/7] 啟動 backend gateway..."
+sudo systemctl start haixun-gateway
+echo "[5/7] 啟動 worker..."
+sudo systemctl start haixun-worker
+echo "[5/7] 啟動 node worker..."
+sudo systemctl start haixun-node-worker
+
+# 6. 初始化 DB(indexes + admin,冪等)
+echo "[6/7] 初始化 DB(admin: admin@30cm.net)..."
+set -a; source /opt/haixun/etc/haixun.env; set +a
+sudo -E /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml
+
+# 7. 確認 nginx 執行 + reload config
+echo "[7/7] 確認 nginx..."
+sudo nginx -t && sudo systemctl start nginx && sudo systemctl reload nginx
+
+echo ""
+echo "=== 啟動完成 ==="
+echo " nginx: $(systemctl is-active nginx)"
+echo " gateway: $(systemctl is-active haixun-gateway)"
+echo " worker: $(systemctl is-active haixun-worker)"
+echo " node-wkr: $(systemctl is-active haixun-node-worker)"
+echo " docker: $(docker compose -f infra/docker-compose.yml ps --status running 2>/dev/null | wc -l) containers running"
+echo ""
+echo "健康檢查:"
+echo " curl http://localhost/api/v1/health"
+echo ""
+echo "查看日誌:"
+echo " journalctl -u haixun-gateway -n 50 --no-pager -f"
+echo " journalctl -u haixun-worker -n 50 --no-pager -f"
+echo " docker compose -f infra/docker-compose.yml logs -f"
diff --git a/status-prod.sh b/status-prod.sh
new file mode 100755
index 0000000..f0c0cc6
--- /dev/null
+++ b/status-prod.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+echo "=== 巡樓 Production 狀態 ==="
+echo ""
+echo "--- Nginx ---"
+systemctl status nginx --no-pager -n 5 2>&1 | head -5
+echo ""
+echo "--- Gateway ---"
+systemctl status haixun-gateway --no-pager -n 5 2>&1 | head -5
+echo ""
+echo "--- Worker ---"
+systemctl status haixun-worker --no-pager -n 5 2>&1 | head -5
+echo ""
+echo "--- Node Worker ---"
+systemctl status haixun-node-worker --no-pager -n 5 2>&1 | head -5
+echo ""
+echo "--- Docker Infra ---"
+docker compose -f /home/daniel/thread-master/infra/docker-compose.yml ps 2>&1
+echo ""
+echo "--- Port 8890 ---"
+ss -tlnp | grep 8890 || echo "gateway not listening"
diff --git a/stop-prod.sh b/stop-prod.sh
new file mode 100755
index 0000000..daca685
--- /dev/null
+++ b/stop-prod.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+echo "=== 巡樓 Production 停止 ==="
+sudo systemctl stop haixun-worker
+sudo systemctl stop haixun-gateway
+docker compose -f /home/daniel/thread-master/infra/docker-compose.yml down
+echo "done"