diff --git a/apps/backend/generate/api/radar.api b/apps/backend/generate/api/radar.api
index d3d2ce8..e35d454 100644
--- a/apps/backend/generate/api/radar.api
+++ b/apps/backend/generate/api/radar.api
@@ -58,6 +58,8 @@ type (
LastSweptAt int64 `json:"last_swept_at,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
+ // 只在建立當下這是使用者第一組訂閱、且已排入首巡時為 true;不是持久狀態,僅供前端顯示一次性提示。
+ FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
}
ListWatchesReq {
diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go
index 01213ad..db6543a 100644
--- a/apps/backend/internal/handler/routes.go
+++ b/apps/backend/internal/handler/routes.go
@@ -1101,12 +1101,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches",
Handler: radar.CreateWatchHandler(serverCtx),
},
- // 靜態路徑必須在 /watches/:id 之前,否則 suggest 會被當成 id。
- {
- Method: http.MethodPost,
- Path: "/watches/suggest",
- Handler: radar.SuggestWatchTermsHandler(serverCtx),
- },
{
Method: http.MethodGet,
Path: "/watches/:id",
@@ -1137,6 +1131,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches/:id/sweep",
Handler: radar.TriggerWatchSweepHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/watches/suggest",
+ Handler: radar.SuggestWatchTermsHandler(serverCtx),
+ },
}...,
),
rest.WithPrefix("/api/v1/radar"),
diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go
index 380252e..ca43d38 100644
--- a/apps/backend/internal/logic/radarmap/map.go
+++ b/apps/backend/internal/logic/radarmap/map.go
@@ -76,14 +76,15 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
regions = []string{}
}
return &types.RadarWatchPublic{
- Id: w.ID,
- Terms: terms,
- ExcludeTerms: excludes,
- Regions: regions,
- Status: w.Status,
- LastSweptAt: w.LastSweptAt,
- CreatedAt: w.CreatedAt,
- UpdatedAt: w.UpdatedAt,
+ Id: w.ID,
+ Terms: terms,
+ ExcludeTerms: excludes,
+ Regions: regions,
+ Status: w.Status,
+ LastSweptAt: w.LastSweptAt,
+ CreatedAt: w.CreatedAt,
+ UpdatedAt: w.UpdatedAt,
+ FirstSweepTriggered: w.FirstSweepTriggered,
}
}
diff --git a/apps/backend/internal/module/radar/domain/watch.go b/apps/backend/internal/module/radar/domain/watch.go
index 6acfd32..d077046 100644
--- a/apps/backend/internal/module/radar/domain/watch.go
+++ b/apps/backend/internal/module/radar/domain/watch.go
@@ -32,6 +32,10 @@ type RadarWatch struct {
LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+
+ // FirstSweepTriggered 是建立當下的一次性狀態,不落庫:只用來讓前端在
+ // 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
+ FirstSweepTriggered bool `bson:"-" json:"-"`
}
type WatchListFilter struct {
diff --git a/apps/backend/internal/module/radar/usecase/watch.go b/apps/backend/internal/module/radar/usecase/watch.go
index 71b8c2c..62f9c08 100644
--- a/apps/backend/internal/module/radar/usecase/watch.go
+++ b/apps/backend/internal/module/radar/usecase/watch.go
@@ -43,14 +43,26 @@ func (s *Service) CreateWatch(ctx context.Context, ownerUID int64, in WatchInput
if err := w.Normalize(); err != nil {
return nil, err
}
+ firstEverWatch := false
if status == domain.WatchActive {
if err := s.assertCanActivate(ctx, ownerUID, ""); err != nil {
return nil, err
}
+ // 只在使用者從未建過任何訂閱時才判定「首巡」,避免每次新增都額外燒一次巡檢成本。
+ if _, total, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 1}); err == nil {
+ firstEverWatch = total == 0
+ }
}
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
+ if firstEverWatch && s.SweepJobs != nil {
+ // 不等每日排程:讓第一組訂閱的使用者最快隔一小段時間就看到結果,而不是等到隔天。
+ // 失敗不阻斷建立,每日排程仍會補上。
+ if _, sweepErr := s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, w.ID, domain.NowNano()); sweepErr == nil {
+ w.FirstSweepTriggered = true
+ }
+ }
return w, nil
}
diff --git a/apps/backend/internal/module/radar/usecase/watch_test.go b/apps/backend/internal/module/radar/usecase/watch_test.go
index aeb9db2..02f02da 100644
--- a/apps/backend/internal/module/radar/usecase/watch_test.go
+++ b/apps/backend/internal/module/radar/usecase/watch_test.go
@@ -339,6 +339,73 @@ func TestMarkWatchSweptAt(t *testing.T) {
}
}
+// 新用戶建第一組 active 訂閱應立即排入首巡,不用等每日排程;
+// 第二組訂閱不該再重複觸發,避免每次新增都多燒一次巡檢成本。
+func TestCreateWatch_FirstActiveWatchTriggersImmediateSweep(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ var scheduled []string
+ svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, ownerUID int64, watchID string, _ int64) (string, error) {
+ scheduled = append(scheduled, watchID)
+ return "job-" + watchID, nil
+ })
+
+ first, err := svc.CreateWatch(ctx, 42, watchInput())
+ if err != nil {
+ t.Fatalf("create first: %v", err)
+ }
+ if !first.FirstSweepTriggered {
+ t.Fatalf("first watch should report FirstSweepTriggered=true")
+ }
+ if len(scheduled) != 1 || scheduled[0] != first.ID {
+ t.Fatalf("scheduled = %v, want exactly [%s]", scheduled, first.ID)
+ }
+
+ second, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true})
+ if err != nil {
+ t.Fatalf("create second: %v", err)
+ }
+ if second.FirstSweepTriggered {
+ t.Fatalf("second watch should not trigger another immediate sweep")
+ }
+ if len(scheduled) != 1 {
+ t.Fatalf("scheduled = %v, want no additional sweep for the second watch", scheduled)
+ }
+}
+
+// SweepJobs 未配置(例如測試環境)時不該讓建立失敗,也不該回報已觸發首巡。
+func TestCreateWatch_FirstWatchWithoutSweepJobsDoesNotFail(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ w, err := svc.CreateWatch(ctx, 42, watchInput())
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if w.FirstSweepTriggered {
+ t.Fatalf("FirstSweepTriggered should be false when SweepJobs is nil")
+ }
+}
+
+// 第一組訂閱先以 paused 建立時不算「第一組 active」,之後才建的 active 訂閱
+// 不會觸發首巡:規則只看「使用者是否曾建過任何訂閱」,不特別追第一個 active。
+func TestCreateWatch_FirstWatchPausedThenActiveSecondSkipsImmediateSweep(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ var scheduled []string
+ svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, _ int64, watchID string, _ int64) (string, error) {
+ scheduled = append(scheduled, watchID)
+ return "job-" + watchID, nil
+ })
+
+ if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}, Enabled: false}); err != nil {
+ t.Fatalf("create paused: %v", err)
+ }
+ active, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true})
+ if err != nil {
+ t.Fatalf("create active: %v", err)
+ }
+ if active.FirstSweepTriggered || len(scheduled) != 0 {
+ t.Fatalf("expected no immediate sweep after an earlier paused watch already existed, scheduled=%v", scheduled)
+ }
+}
+
func TestWatchTransitionMatrix(t *testing.T) {
allowed := map[string][]string{
domain.WatchActive: {domain.WatchPaused, domain.WatchArchived},
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index ac929ec..01dd828 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -1666,14 +1666,15 @@ type RadarTodayStats struct {
}
type RadarWatchPublic struct {
- Id string `json:"id"`
- Terms []string `json:"terms"`
- ExcludeTerms []string `json:"exclude_terms"`
- Regions []string `json:"regions"`
- Status string `json:"status"` // active | paused | archived
- LastSweptAt int64 `json:"last_swept_at,optional"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
+ Id string `json:"id"`
+ Terms []string `json:"terms"`
+ ExcludeTerms []string `json:"exclude_terms"`
+ Regions []string `json:"regions"`
+ Status string `json:"status"` // active | paused | archived
+ LastSweptAt int64 `json:"last_swept_at,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
}
type RemoveWorkspaceMemberReq struct {
diff --git a/apps/web/src/data/live/radarRepos.ts b/apps/web/src/data/live/radarRepos.ts
index 75646f2..f494ed2 100644
--- a/apps/web/src/data/live/radarRepos.ts
+++ b/apps/web/src/data/live/radarRepos.ts
@@ -99,6 +99,7 @@ function mapWatch(raw: Raw): RadarWatch {
last_swept_at: optNum(raw.last_swept_at),
created_at: num(raw.created_at),
updated_at: num(raw.updated_at),
+ first_sweep_triggered: Boolean(raw.first_sweep_triggered),
};
}
diff --git a/apps/web/src/data/mock/keys.ts b/apps/web/src/data/mock/keys.ts
index eb65769..680098f 100644
--- a/apps/web/src/data/mock/keys.ts
+++ b/apps/web/src/data/mock/keys.ts
@@ -63,4 +63,6 @@ export const KEYS = {
ownPostsSyncMeta: "harbor.own_posts.sync_meta",
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */
accountInsightHistory: "harbor.account.insight_history",
+ /** 使用者手動關掉 Today 頁「上手三步」引導後不再顯示 */
+ radarOnboardingDismissed: "harbor.radar.onboarding_dismissed",
} as const;
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
index a3e542b..5a3ad97 100644
--- a/apps/web/src/domain/types.ts
+++ b/apps/web/src/domain/types.ts
@@ -845,6 +845,8 @@ export type RadarWatch = {
last_swept_at?: number;
created_at: number;
updated_at: number;
+ /** 只在建立當下是使用者第一組訂閱且已排入首巡時為 true;非持久狀態。 */
+ first_sweep_triggered?: boolean;
};
export type WatchTermSuggestion = {
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
index 2133d12..504c347 100644
--- a/apps/web/src/lib/i18n/messages.ts
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -2168,6 +2168,7 @@ export const zhTW: MessageDict = {
"radar.watches.archive": "封存",
"radar.watches.confirmArchive": "封存後不會再巡,也不能恢復。要繼續嗎?",
"radar.watches.created": "訂閱已建立",
+ "radar.watches.createdFirstSweep": "訂閱已建立,首巡已排入,約幾分鐘後回今日商機頁看結果(之後每天自動巡,不用再等)",
"radar.watches.updated": "訂閱已更新",
"radar.watches.paused": "訂閱已暫停",
"radar.watches.resumed": "訂閱已恢復",
@@ -2191,6 +2192,17 @@ export const zhTW: MessageDict = {
"today.radar.goProfile": "先填服務檔案",
"today.radar.goWatches": "去訂閱關鍵字",
+ "today.onboarding.title": "上手三步",
+ "today.onboarding.dismiss": "不再顯示",
+ "today.onboarding.step.profile": "填服務檔案",
+ "today.onboarding.step.profileHint": "服務項目、地區、不能說的內容",
+ "today.onboarding.step.watch": "建立雷達訂閱",
+ "today.onboarding.step.watchHint": "設定關鍵字,之後每天自動巡",
+ "today.onboarding.step.opportunity": "看今日商機",
+ "today.onboarding.step.opportunityHint": "訂閱建立後,最快次日就有結果",
+ "today.onboarding.step.done": "完成",
+ "today.onboarding.step.go": "去完成",
+
"radar.suggest.title": "關鍵字建議",
"radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
"radar.suggest.ask": "取得建議",
@@ -4512,6 +4524,7 @@ export const en: MessageDict = {
"radar.watches.archive": "Archive",
"radar.watches.confirmArchive": "Archived watches stop sweeping and cannot be restored. Continue?",
"radar.watches.created": "Watch created",
+ "radar.watches.createdFirstSweep": "Watch created — first sweep queued. Check today's demand in a few minutes (it runs automatically every day after this).",
"radar.watches.updated": "Watch updated",
"radar.watches.paused": "Watch paused",
"radar.watches.resumed": "Watch resumed",
@@ -4536,6 +4549,17 @@ export const en: MessageDict = {
"today.radar.goProfile": "Fill service profile",
"today.radar.goWatches": "Add keyword watches",
+ "today.onboarding.title": "3 steps to get started",
+ "today.onboarding.dismiss": "Don't show again",
+ "today.onboarding.step.profile": "Fill service profile",
+ "today.onboarding.step.profileHint": "Services, region, what not to say",
+ "today.onboarding.step.watch": "Add a radar watch",
+ "today.onboarding.step.watchHint": "Set keywords; runs automatically every day after",
+ "today.onboarding.step.opportunity": "See today's demand",
+ "today.onboarding.step.opportunityHint": "Results can show as early as the next day",
+ "today.onboarding.step.done": "Done",
+ "today.onboarding.step.go": "Go",
+
"radar.suggest.title": "Term suggestions",
"radar.suggest.hint":
"Terms drawn from your service profile. Adopt them one by one or all at once; you still need to save to create the watch.",
diff --git a/apps/web/src/lib/radarOnboarding.ts b/apps/web/src/lib/radarOnboarding.ts
new file mode 100644
index 0000000..fdd3e10
--- /dev/null
+++ b/apps/web/src/lib/radarOnboarding.ts
@@ -0,0 +1,14 @@
+import { readJson, writeJson } from "./storage";
+import { KEYS } from "../data/mock/keys";
+
+/**
+ * Today 頁「上手三步」引導的關閉狀態。純前端記憶(per browser),
+ * 三步全部完成或使用者手動關閉後就不再出現。
+ */
+export function isRadarOnboardingDismissed(): boolean {
+ return readJson
{message}
+ {justTriggeredFirstSweep ? (
+ <>
+ {" "}
+ {t("today.radar.open")}
+ >
+ ) : null}
{radarToday?.empty_hint || t("today.radar.empty")}{" "} - + {radarToday?.empty_reason === "no_profile" ? t("today.radar.goProfile") : t("today.radar.goWatches")}