diff --git a/apps/backend/internal/logic/radar/list_watches_logic.go b/apps/backend/internal/logic/radar/list_watches_logic.go index bf5eb21..de942b6 100644 --- a/apps/backend/internal/logic/radar/list_watches_logic.go +++ b/apps/backend/internal/logic/radar/list_watches_logic.go @@ -43,7 +43,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W if err != nil { return nil, err } - // profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒。 + // profile_exists 讓雷達頁顯示「服務檔案可之後再補」,不再當硬門檻。 hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/radar/m1_integration_test.go b/apps/backend/internal/logic/radar/m1_integration_test.go index 9ca48c9..7aeaeca 100644 --- a/apps/backend/internal/logic/radar/m1_integration_test.go +++ b/apps/backend/internal/logic/radar/m1_integration_test.go @@ -129,30 +129,21 @@ func (e *m1Env) sweepCandidates(t *testing.T) []string { return ids } -// SP-01:新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。 -func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) { +func TestM1_SP01_ActiveWatchWithoutServiceProfileIsAllowed(t *testing.T) { env := newM1Env(t, 42, 5) - _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ + w, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"婚攝 推薦"}, Enabled: true, }) - if err == nil { - t.Fatal("SP-01: active watch was created without a service profile") + if err != nil { + t.Fatalf("SP-01: active watch without profile: %v", err) } - envelope := assertStatus(t, err, http.StatusBadRequest, 400100) - if !strings.Contains(envelope.Message, "service-profile") { - t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message) + if w.Status != "active" { + t.Fatalf("SP-01: status = %q, want active", w.Status) } - // 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。 - if got := env.list(t, ""); got.Pagination.Total != 0 { - t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total) - } - - // 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。 - env.createWatch(t, "婚攝 推薦", false) - if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 { - t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got) + if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 1 { + t.Fatalf("SP-01: list = %+v, want one active watch", got) } } diff --git a/apps/backend/internal/logic/radar/watch_logic_test.go b/apps/backend/internal/logic/radar/watch_logic_test.go index 0a7b0d2..39de922 100644 --- a/apps/backend/internal/logic/radar/watch_logic_test.go +++ b/apps/backend/internal/logic/radar/watch_logic_test.go @@ -39,20 +39,18 @@ func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, return w } -// SP-01:沒建服務檔案就建 active 訂閱 → 400100,訊息要指向服務檔案而不是只說「失敗」。 -func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) { +func TestCreateActiveWatchWithoutProfileSucceeds(t *testing.T) { ctx, svcCtx := watchCtx(t, 42, 5, false) - _, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{ + w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"婚攝 推薦"}, Enabled: true, }) - if err == nil { - t.Fatal("active watch created without a service profile") + if err != nil { + t.Fatalf("active watch without profile: %v", err) } - env := assertStatus(t, err, http.StatusBadRequest, 400100) - if !strings.Contains(env.Message, "service-profile") { - t.Fatalf("message must point at the service profile, got %q", env.Message) + if w.Status != "active" { + t.Fatalf("status = %q, want active", w.Status) } } diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go index d47b310..a593de2 100644 --- a/apps/backend/internal/logic/radarmap/map.go +++ b/apps/backend/internal/logic/radarmap/map.go @@ -40,7 +40,7 @@ func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic { ServiceProfile 把 domain 檔案轉成 API 形狀。 p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。 -但 exists 這個欄位必須誠實,訂閱閘門(SP-01)與前端引導都看它。 +但 exists 這個欄位必須誠實,前端用它決定要不要顯示「之後再補」提示。 */ func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic { if p == nil { diff --git a/apps/backend/internal/module/radar/domain/service_profile.go b/apps/backend/internal/module/radar/domain/service_profile.go index acd4bf9..65749c4 100644 --- a/apps/backend/internal/module/radar/domain/service_profile.go +++ b/apps/backend/internal/module/radar/domain/service_profile.go @@ -84,8 +84,7 @@ type FaqItem struct { /* ServiceProfile 每會員一份,_id 就是 owner_uid。 -這份檔案是判定與回覆生成的共同輸入:沒有它,五問判定沒有比對基準,回覆也沒有 -價格與案例可講,所以未建檔時不允許建立 active 訂閱(SP-01)。 +這份檔案是判定與回覆生成的加分輸入:沒有它仍可用關鍵字巡邏,只是比對與回覆較通用。 */ type ServiceProfile struct { OwnerUID int64 `bson:"_id" json:"owner_uid"` diff --git a/apps/backend/internal/module/radar/usecase/billing.go b/apps/backend/internal/module/radar/usecase/billing.go index 56e81e4..c59abff 100644 --- a/apps/backend/internal/module/radar/usecase/billing.go +++ b/apps/backend/internal/module/radar/usecase/billing.go @@ -2,6 +2,7 @@ package usecase import ( "context" + "time" "github.com/zeromicro/go-zero/core/logx" ) @@ -57,7 +58,10 @@ func (c *charge) Release(ctx context.Context) { return } c.settled = true - if err := c.svc.Usage.ReleaseCall(ctx, c.uid, c.meter, c.mode); err != nil { + // 要補償的失敗常常就是「請求被取消」,退點必須用活得比它久的 context,否則使用者被扣點。 + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := c.svc.Usage.ReleaseCall(rctx, c.uid, c.meter, c.mode); err != nil { logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err) } } diff --git a/apps/backend/internal/module/radar/usecase/product_watch_test.go b/apps/backend/internal/module/radar/usecase/product_watch_test.go index 224490f..f7d0e07 100644 --- a/apps/backend/internal/module/radar/usecase/product_watch_test.go +++ b/apps/backend/internal/module/radar/usecase/product_watch_test.go @@ -35,8 +35,8 @@ func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { - t.Fatalf("generic watch without profile err=%v", err) + if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("generic watch without profile should resume: %v", err) } assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1") if err != nil { diff --git a/apps/backend/internal/module/radar/usecase/service_profile.go b/apps/backend/internal/module/radar/usecase/service_profile.go index c4a6618..cb8a5f9 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile.go +++ b/apps/backend/internal/module/radar/usecase/service_profile.go @@ -67,8 +67,7 @@ func New(repo domain.Repository) *Service { GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。 HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能 -自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來,而 SP-01 的 -訂閱閘門正是靠這個差別。 +自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來。 */ func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) { if ownerUID <= 0 { diff --git a/apps/backend/internal/module/radar/usecase/service_profile_test.go b/apps/backend/internal/module/radar/usecase/service_profile_test.go index fd9c675..b0bd3d3 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile_test.go +++ b/apps/backend/internal/module/radar/usecase/service_profile_test.go @@ -128,7 +128,7 @@ func TestUpsertRequiresAtLeastOneService(t *testing.T) { } } -// 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。 +// 「沒建檔」與「建了一份空的」必須分得出來:列表的 profile_exists 靠這個差別。 func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) { svc := newTestService() ctx := context.Background() diff --git a/apps/backend/internal/module/radar/usecase/suggest.go b/apps/backend/internal/module/radar/usecase/suggest.go index 3f39cab..c0fde7f 100644 --- a/apps/backend/internal/module/radar/usecase/suggest.go +++ b/apps/backend/internal/module/radar/usecase/suggest.go @@ -40,60 +40,61 @@ func productSuggestPrompt(p *ProductContextSnapshot, limit int) string { } /* -suggestPrompt 用服務檔案組建議關鍵字的提示。 +suggestPrompt 組建議關鍵字的提示。 -素材全部來自使用者自己填的服務檔案:服務項目、價格區間、案例、地區、禁語。 -沒有服務檔案就不呼叫 AI(見 SuggestWatchTerms)—— 沒有依據的建議只是猜測, -而使用者會把它當成系統的判斷。 +有服務檔案就帶服務項目、價格、案例、地區、禁語;沒有也能給通用求助短詞。 */ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string { var b strings.Builder - b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,") + b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,") b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n") - - b.WriteString("服務項目:\n") - for _, s := range p.Services { - b.WriteString("- " + s.Name) - if s.PriceMin > 0 || s.PriceMax > 0 { - b.WriteString(fmt.Sprintf("(價格區間 %.0f–%.0f %s)", s.PriceMin, s.PriceMax, s.Currency)) - } - b.WriteString("\n") - } - - if len(p.ServiceAreas) > 0 { - labels := make([]string, 0, len(p.ServiceAreas)) - for _, code := range p.ServiceAreas { - if label := domain.ServiceAreaLabel(code); label != "" { - labels = append(labels, label) - } - } - b.WriteString("服務地區:" + strings.Join(labels, "、") + "\n") - } - if p.RemoteOk { - b.WriteString("可遠端服務。\n") - } - if len(p.Cases) > 0 { - b.WriteString("代表案例:\n") - for _, c := range p.Cases { - b.WriteString("- " + c.Title) - if c.Summary != "" { - b.WriteString(":" + c.Summary) + if p == nil { + b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n") + } else { + b.WriteString("服務項目:\n") + for _, s := range p.Services { + b.WriteString("- " + s.Name) + if s.PriceMin > 0 || s.PriceMax > 0 { + b.WriteString(fmt.Sprintf("(價格區間 %.0f–%.0f %s)", s.PriceMin, s.PriceMax, s.Currency)) } b.WriteString("\n") } - } - if len(p.Faq) > 0 { - b.WriteString("客戶常問:\n") - for _, f := range p.Faq { - b.WriteString("- " + f.Question + "\n") + + if len(p.ServiceAreas) > 0 { + labels := make([]string, 0, len(p.ServiceAreas)) + for _, code := range p.ServiceAreas { + if label := domain.ServiceAreaLabel(code); label != "" { + labels = append(labels, label) + } + } + b.WriteString("服務地區:" + strings.Join(labels, "、") + "\n") + } + if p.RemoteOk { + b.WriteString("可遠端服務。\n") + } + if len(p.Cases) > 0 { + b.WriteString("代表案例:\n") + for _, c := range p.Cases { + b.WriteString("- " + c.Title) + if c.Summary != "" { + b.WriteString(":" + c.Summary) + } + b.WriteString("\n") + } + } + if len(p.Faq) > 0 { + b.WriteString("客戶常問:\n") + for _, f := range p.Faq { + b.WriteString("- " + f.Question + "\n") + } + } + if len(p.Forbidden) > 0 { + // 禁語是回覆生成的硬性過濾詞,順手也不該出現在關鍵字裡。 + b.WriteString("不可使用的字詞:" + strings.Join(p.Forbidden, "、") + "\n") + } + if p.ToneNote != "" { + b.WriteString("語氣備註:" + p.ToneNote + "\n") } - } - if len(p.Forbidden) > 0 { - // 禁語是回覆生成的硬性過濾詞,順手也不該出現在關鍵字裡。 - b.WriteString("不可使用的字詞:" + strings.Join(p.Forbidden, "、") + "\n") - } - if p.ToneNote != "" { - b.WriteString("語氣備註:" + p.ToneNote + "\n") } if len(extra) > 0 { // 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。 @@ -123,10 +124,11 @@ type PainTermSource interface { } /* -SuggestWatchTerms 依服務檔案回關鍵字建議(RW-03)。 +SuggestWatchTerms 回關鍵字建議(RW-03)。 -不自動寫入任何 watch:使用者逐條採用才有意義,也才看得懂每個詞是為什麼在那裡。 -計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5),不新增第五個 meter。 +服務檔案是加分項,不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。 +不自動寫入任何 watch:使用者逐條採用才有意義。 +計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5);fallback 不扣點。 */ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) { if ownerUID <= 0 { @@ -140,15 +142,12 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i } profile, err := s.Repo.GetServiceProfile(ctx, ownerUID) - if err != nil { - if errors.Is(err, domain.ErrNotFound) { - return nil, fmt.Errorf( - "%w: service profile required before suggesting keywords; fill in /api/v1/radar/service-profile first", - domain.ErrValidation, - ) - } + if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, err } + if errors.Is(err, domain.ErrNotFound) { + profile = nil + } var extra []string if s.PainTerms != nil { @@ -166,18 +165,69 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i } defer charge.Settle(ctx, &err) - raw, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra)) - if err != nil { - return nil, err + raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra)) + var out []domain.WatchTermSuggestion + if aiErr == nil { + out = domain.CleanSuggestions(parseSuggestions(raw), limit) + } else { + logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr) } - out := domain.CleanSuggestions(parseSuggestions(raw), limit) if len(out) == 0 { - // 空清單會被讀成「你的服務沒有關鍵字可監控」,那是錯的訊息。 + out = genericSuggestFallback(profile, extra, limit) + } + if len(out) == 0 { + if aiErr != nil { + return nil, aiErr + } return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation) } + if aiErr != nil { + // 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。 + charge.Release(ctx) + } return out, nil } +func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion { + raw := make([]domain.WatchTermSuggestion, 0, limit+8) + add := func(term, reason, usage string) { + term = strings.TrimSpace(term) + reason = strings.TrimSpace(reason) + if term == "" || reason == "" { + return + } + raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage}) + } + if p != nil { + for _, item := range p.Services { + name := strings.TrimSpace(item.Name) + if name == "" { + continue + } + for _, term := range domain.SearchableTermVariants(name, true) { + add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude) + } + } + } + for _, term := range extra { + basis := strings.TrimSpace(term) + if basis == "" { + continue + } + for _, v := range domain.SearchableTermVariants(basis, true) { + add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude) + } + } + add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude) + add("有人知道", "口語求助句,能找到正在發問的人", domain.SuggestUsageInclude) + add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude) + add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude) + add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude) + add("徵才", "招募文不是客人", domain.SuggestUsageExclude) + add("接案", "同業供給文,不是需求", domain.SuggestUsageExclude) + return domain.CleanSuggestions(raw, limit) +} + // SuggestProductWatchTerms suggests demand terms from a paired Brand/Product // snapshot. If the AI provider is unavailable, the catalog's own structured // fields are used as a deterministic, traceable fallback. diff --git a/apps/backend/internal/module/radar/usecase/suggest_test.go b/apps/backend/internal/module/radar/usecase/suggest_test.go index 182ce7e..46b974c 100644 --- a/apps/backend/internal/module/radar/usecase/suggest_test.go +++ b/apps/backend/internal/module/radar/usecase/suggest_test.go @@ -119,21 +119,23 @@ func TestSuggestDoesNotCreateWatches(t *testing.T) { } } -// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。 -func TestSuggestRequiresServiceProfile(t *testing.T) { +func TestSuggestWorksWithoutServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) ai := &stubAI{reply: suggestReply} svc.AI = ai - _, err := svc.SuggestWatchTerms(context.Background(), 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + list, err := svc.SuggestWatchTerms(context.Background(), 42, 0) + if err != nil { + t.Fatalf("suggest without profile: %v", err) } - if !strings.Contains(err.Error(), "service-profile") { - t.Fatalf("error must point at the service profile, got %q", err) + if len(list) == 0 { + t.Fatal("want generic suggestions when profile is missing") } - if ai.calls != 0 { - t.Fatal("AI was called without a service profile") + if ai.calls != 1 { + t.Fatalf("AI calls = %d, want 1", ai.calls) + } + if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") { + t.Fatal("prompt should say the profile is missing instead of inventing one") } } @@ -196,22 +198,29 @@ func TestSuggestToleratesProseAroundJSON(t *testing.T) { } } -// 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」。 -func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) { +// 模型回空話時改走通用短詞,不要讓「建議關鍵字」整顆按鈕壞掉。 +func TestSuggestFallsBackOnUnusableReply(t *testing.T) { svc, _, ctx := suggestService(t, "我不知道要建議什麼") - _, err := svc.SuggestWatchTerms(ctx, 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("unusable AI reply should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } } -func TestSuggestSurfacesAIFailure(t *testing.T) { +func TestSuggestFallsBackWhenAIFails(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") - if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { - t.Fatal("AI failure was swallowed") + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("AI failure should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } } @@ -262,34 +271,48 @@ func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) { } } -// AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳。 +// AI 失敗改走 fallback:使用者仍拿到詞,但沒真正呼叫模型,所以要退點。 func TestSuggestReleasesCreditWhenAIFails(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") usage := platformUsage(42) svc.Usage = usage - if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { - t.Fatal("AI failure was swallowed") + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("AI failure should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") } events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0) if err != nil { t.Fatalf("list events: %v", err) } if len(events) != 0 { - t.Fatalf("charged %d events for a failed call", len(events)) + t.Fatalf("charged %d events for a fallback call", len(events)) } } -func TestSuggestWithoutAIClientAsksForKey(t *testing.T) { +func TestSuggestWithoutAIClientUsesFallback(t *testing.T) { svc := New(repository.NewMemory()) ctx := context.Background() - if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { - t.Fatalf("seed profile: %v", err) - } - _, err := svc.SuggestWatchTerms(ctx, 42, 0) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err) + list, err := svc.SuggestWatchTerms(ctx, 42, 0) + if err != nil { + t.Fatalf("missing AI client should fall back: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned nothing") + } + found := false + for _, item := range list { + if item.Term == "求推薦" { + found = true + break + } + } + if !found { + t.Fatalf("fallback missing 求推薦: %+v", list) } } diff --git a/apps/backend/internal/module/radar/usecase/sweep_run.go b/apps/backend/internal/module/radar/usecase/sweep_run.go index c5a8e4d..4a98640 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_run.go +++ b/apps/backend/internal/module/radar/usecase/sweep_run.go @@ -61,10 +61,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s // for regional/freshness context when present. profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID) } else { - profile, err = s.Repo.GetServiceProfile(ctx, ownerUID) - if err != nil { - return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation) - } + profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID) } // Resume: if a sweep already exists for this job, reuse it. diff --git a/apps/backend/internal/module/radar/usecase/today.go b/apps/backend/internal/module/radar/usecase/today.go index d719d09..e4159bd 100644 --- a/apps/backend/internal/module/radar/usecase/today.go +++ b/apps/backend/internal/module/radar/usecase/today.go @@ -2,7 +2,6 @@ package usecase import ( "context" - "errors" "fmt" "apps/backend/internal/module/radar/domain" @@ -47,12 +46,6 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF } start, end := domain.UTCDayBounds(domain.NowNano()) - // Empty-state diagnostics:只有「真的沒建檔」才算 no_profile,其他 DB 錯誤要往上丟。 - _, profileErr := s.Repo.GetServiceProfile(ctx, ownerUID) - noProfile := errors.Is(profileErr, domain.ErrNotFound) - if profileErr != nil && !noProfile { - return nil, profileErr - } watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50}) if err != nil { return nil, err @@ -148,7 +141,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF if productFiltered { out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。" } else { - out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start) + out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start) } } return out, nil @@ -173,10 +166,7 @@ func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilt return false } -func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { - if noProfile { - return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。" - } +func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { if watchCount == 0 { return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。" } diff --git a/apps/backend/internal/module/radar/usecase/today_test.go b/apps/backend/internal/module/radar/usecase/today_test.go index 347dc3c..ae0c8ee 100644 --- a/apps/backend/internal/module/radar/usecase/today_test.go +++ b/apps/backend/internal/module/radar/usecase/today_test.go @@ -82,7 +82,7 @@ func TestGetTodayEmptyNoProfile(t *testing.T) { if err != nil { t.Fatal(err) } - if got.EmptyReason != "no_profile" { - t.Fatalf("empty_reason = %q, want no_profile", got.EmptyReason) + if got.EmptyReason != "no_watch" { + t.Fatalf("empty_reason = %q, want no_watch", got.EmptyReason) } } diff --git a/apps/backend/internal/module/radar/usecase/watch.go b/apps/backend/internal/module/radar/usecase/watch.go index dfcedc6..158e7b7 100644 --- a/apps/backend/internal/module/radar/usecase/watch.go +++ b/apps/backend/internal/module/radar/usecase/watch.go @@ -152,7 +152,7 @@ func (s *Service) PauseWatch(ctx context.Context, ownerUID int64, id string) (*d } /* -ResumeWatch 回到 active,因此要再過一次配額與服務檔案閘:暫停期間方案可能已降級, +ResumeWatch 回到 active,因此要再過一次配額閘:暫停期間方案可能已降級, 不重驗就會讓人靠「暫停再恢復」繞過上限。 */ func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) { diff --git a/apps/backend/internal/module/radar/usecase/watch_quota.go b/apps/backend/internal/module/radar/usecase/watch_quota.go index fa6f22b..4e1d711 100644 --- a/apps/backend/internal/module/radar/usecase/watch_quota.go +++ b/apps/backend/internal/module/radar/usecase/watch_quota.go @@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in } /* -assertCanActivateForWatch 是「變成 active」的兩道閘(SP-01、RW-01)。 +assertCanActivateForWatch 是「變成 active」的配額閘(RW-01)。 exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive, 帶進來只為了在訊息與計算上表達清楚。 @@ -78,42 +78,8 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不 既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。 */ func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error { - if productWatch { - return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) - } - hasProfile, err := s.HasServiceProfile(ctx, ownerUID) - if err != nil { - return err - } - if !hasProfile { - return fmt.Errorf( - "%w: service profile required before activating a radar watch; fill in /api/v1/radar/service-profile first", - domain.ErrValidation, - ) - } - - maxActive, err := s.MaxActiveWatches(ctx, ownerUID) - if err != nil { - return err - } - active, err := s.Repo.CountActiveWatches(ctx, ownerUID) - if err != nil { - return err - } - if exceptWatchID != "" { - if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive { - active-- - } else if err != nil && !errors.Is(err, domain.ErrNotFound) { - return err - } - } - if active >= int64(maxActive) { - return fmt.Errorf( - "%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan", - domain.ErrValidation, active, maxActive, - ) - } - return nil + _ = productWatch + return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) } func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error { diff --git a/apps/backend/internal/module/radar/usecase/watch_test.go b/apps/backend/internal/module/radar/usecase/watch_test.go index 5f49452..22cedfb 100644 --- a/apps/backend/internal/module/radar/usecase/watch_test.go +++ b/apps/backend/internal/module/radar/usecase/watch_test.go @@ -237,21 +237,20 @@ func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) { } } -// SP-01:沒有服務檔案,判定沒有比對基準,所以不准有 active 訂閱。 -func TestActiveWatchRequiresServiceProfile(t *testing.T) { +// 服務檔案是選項:沒建檔也能用關鍵字開巡邏。 +func TestActiveWatchDoesNotRequireServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} ctx := context.Background() - _, err := svc.CreateWatch(ctx, 42, watchInput()) - if !errors.Is(err, domain.ErrValidation) { - t.Fatalf("err = %v, want ErrValidation", err) + w, err := svc.CreateWatch(ctx, 42, watchInput()) + if err != nil { + t.Fatalf("create active without profile: %v", err) } - if !strings.Contains(err.Error(), "service-profile") { - t.Fatalf("error must point at the service profile, got %q", err) + if w.Status != domain.WatchActive { + t.Fatalf("status = %q, want active", w.Status) } - // 但可以先建成 paused 把關鍵字備好。 paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}}) if err != nil { t.Fatalf("create paused without profile: %v", err) @@ -259,15 +258,8 @@ func TestActiveWatchRequiresServiceProfile(t *testing.T) { if paused.Status != domain.WatchPaused { t.Fatalf("status = %q, want paused", paused.Status) } - // 建檔後才能開起來。 - if _, err := svc.ResumeWatch(ctx, 42, paused.ID); !errors.Is(err, domain.ErrValidation) { - t.Fatalf("resume without profile: err = %v, want ErrValidation", err) - } - if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { - t.Fatalf("upsert profile: %v", err) - } if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil { - t.Fatalf("resume after profile exists: %v", err) + t.Fatalf("resume without profile: %v", err) } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9f78675..c4ec383 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -87,9 +87,9 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx index f2d6580..d0704bb 100644 --- a/apps/web/src/components/layout/SidebarNav.tsx +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -1,42 +1,61 @@ +import { useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { useFirstRun } from "../../firstRun/FirstRunContext"; import { useI18n } from "../../i18n/I18nContext"; import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav"; +import type { NavGroupKey, NavItem } from "../../lib/nav"; import { AppIcon } from "../ui/AppIcons"; export function SidebarNav() { const { pathname } = useLocation(); const { t } = useI18n(); const { active } = useFirstRun(); + const [openAdvanced, setOpenAdvanced] = useState(null); const groups = active ? navGroupedItemsByKeys(firstRunNavKeys) : navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) })); + function renderItem(item: NavItem) { + const current = isNavActive(pathname, item); + return ( + + + + + {t(item.labelKey)} + + ); + } + return ( ); } diff --git a/apps/web/src/components/radar/OpportunityDetailDrawer.tsx b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx index 5300ad2..5cf1bd4 100644 --- a/apps/web/src/components/radar/OpportunityDetailDrawer.tsx +++ b/apps/web/src/components/radar/OpportunityDetailDrawer.tsx @@ -1,20 +1,39 @@ -import type { Opportunity } from "../../domain/types"; +import { useState } from "react"; +import type { IntentBand, Opportunity } from "../../domain/types"; import { useI18n } from "../../i18n/I18nContext"; import { Badge, Button } from "../ui"; +import { PrimaryProductPicker } from "./PrimaryProductPicker"; import { ProductMatchDetails } from "./ProductMatchDetails"; +import { ReplyComposer } from "./ReplyComposer"; + +const BANDS: IntentBand[] = ["high", "mid", "low"]; type Props = { opportunity: Opportunity; onClose: () => void; onAccept?: (opportunity: Opportunity) => void; onComplete?: (opportunity: Opportunity) => void; + /** 改主推產品(多產品匹配時);沒給就不顯示。 */ + onSetPrimary?: (opportunity: Opportunity, productId: string, reason: string) => void; + /** 覆寫意向分級;沒給就不顯示。 */ + onOverrideBand?: (opportunity: Opportunity, band: IntentBand) => void; busy?: boolean; }; -export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComplete, busy = false }: Props) { +export function OpportunityDetailDrawer({ + opportunity, + onClose, + onAccept, + onComplete, + onSetPrimary, + onOverrideBand, + busy = false, +}: Props) { const { t } = useI18n(); + const [overrideOpen, setOverrideOpen] = useState(false); const pending = (opportunity.review_state || "pending") === "pending"; const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id); + const matches = opportunity.product_matches ?? []; return ( diff --git a/apps/web/src/components/radar/OpportunityInboxCard.test.tsx b/apps/web/src/components/radar/OpportunityInboxCard.test.tsx new file mode 100644 index 0000000..000b4a9 --- /dev/null +++ b/apps/web/src/components/radar/OpportunityInboxCard.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { KEYS } from "../../data/mock/keys"; +import type { Opportunity } from "../../domain/types"; +import { I18nProvider } from "../../i18n/I18nContext"; +import { OpportunityInboxCard } from "./OpportunityInboxCard"; + +function opp(): Opportunity { + return { + id: "o1", + source: "threads", + external_id: "x", + permalink: "https://www.threads.net/@a/post/x", + author_handle: "buyer", + text: "台北有人推薦美甲嗎", + posted_at: Date.now() * 1e6, + status: "qualified", + intent_score: 80, + intent_band: "high", + reasons: [], + region_match: "unknown", + freshness_hours: 1, + matched_terms: [], + created_at: Date.now() * 1e6, + review_state: "pending", + }; +} + +describe("OpportunityInboxCard simple", () => { + it("only offers reply or skip", () => { + localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" })); + const onComplete = vi.fn(); + const onRemove = vi.fn(); + render( + + + , + ); + expect(screen.getByRole("button", { name: "回他" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "先跳過" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "留下" })).toBeNull(); + expect(screen.queryByRole("button", { name: "丟掉" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "先跳過" })); + expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" }), { reason: "pain_mismatch" }); + }); + + it("回他同時把人加進名單,否則名單永遠是空的", () => { + localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" })); + const onAccept = vi.fn(); + const onComplete = vi.fn(); + vi.spyOn(window, "open").mockReturnValue(null); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "回他" })); + expect(window.open).toHaveBeenCalledWith("https://www.threads.net/@a/post/x", "_blank", "noopener,noreferrer"); + expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" })); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/radar/OpportunityInboxCard.tsx b/apps/web/src/components/radar/OpportunityInboxCard.tsx index 636678e..5f717b1 100644 --- a/apps/web/src/components/radar/OpportunityInboxCard.tsx +++ b/apps/web/src/components/radar/OpportunityInboxCard.tsx @@ -22,9 +22,11 @@ type Props = { onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void; onRestore: (opportunity: Opportunity) => void; busy?: boolean; + /** 今日:只留回他/先跳過 */ + simple?: boolean; }; -export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false }: Props) { +export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false, simple = false }: Props) { const { t } = useI18n(); const [removeOpen, setRemoveOpen] = useState(false); const [reason, setReason] = useState("pain_mismatch"); @@ -53,6 +55,55 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete setNote(""); } + if (simple) { + return ( +
+
+ @{opportunity.author_handle || t("radar.card.unknownAuthor")} + {formatTimeAgo(opportunity.posted_at)} + {/* 「回他」會自己開分頁,但瀏覽器可能攔彈窗,留一條手動入口。 */} + {opportunity.permalink ? ( + {t("radar.card.openOriginal")} + ) : null} +
+

{opportunity.text}

+ {evidence ?

{evidence}

: null} +
+ {pending ? ( + + ) : null} + {pending ? ( + + ) : null} + {pending ? ( + + ) : null} +
+
+ ); + } + return (
diff --git a/apps/web/src/components/radar/ProductWatchForm.tsx b/apps/web/src/components/radar/ProductWatchForm.tsx index ff1b41f..12a235f 100644 --- a/apps/web/src/components/radar/ProductWatchForm.tsx +++ b/apps/web/src/components/radar/ProductWatchForm.tsx @@ -19,12 +19,12 @@ export function ProductWatchForm({ brands, products, brandId, productId, disable const product = products.find((p) => p.id === productId); return (
- onBrandChange(e.target.value)}> + {brands.map((b) => )} - onProductChange(e.target.value)}> + {products.map((p) => )} diff --git a/apps/web/src/components/radar/QuickWatchStart.tsx b/apps/web/src/components/radar/QuickWatchStart.tsx new file mode 100644 index 0000000..a4924eb --- /dev/null +++ b/apps/web/src/components/radar/QuickWatchStart.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { SERVICE_AREAS } from "./serviceAreas"; +import { Button, Textarea } from "../ui"; +import { useRepos } from "../../data/DataContext"; +import type { Brand, BrandProduct } from "../../domain/types"; +import { useI18n } from "../../i18n/I18nContext"; +import { useFormatApiError } from "../../lib/apiErrors"; +import { expandIncludeTerms } from "../../lib/threadsTerm"; +import { ProductWatchForm } from "./ProductWatchForm"; + +function splitTerms(raw: string): string[] { + return raw + .split(/[\n,、]/) + .map((s) => s.trim()) + .filter(Boolean); +} + +type Props = { + /** firstSweepTriggered:後端已排第一輪,前端不該再叫使用者按「立即巡邏」。 */ + onCreated: (result: { firstSweepTriggered: boolean }) => void; +}; + +/** 今日空狀態:關鍵字就能開工,品牌/產品是選項。 */ +export function QuickWatchStart({ onCreated }: Props) { + const { t } = useI18n(); + const repos = useRepos(); + const formatError = useFormatApiError(); + const [terms, setTerms] = useState(""); + const [regions, setRegions] = useState([]); + const [brandId, setBrandId] = useState(""); + const [productId, setProductId] = useState(""); + const [brands, setBrands] = useState([]); + const [products, setProducts] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const scout = (repos as { scout?: { listBrands: () => Promise; listProducts: (id: string) => Promise } }).scout; + + useEffect(() => { + if (!scout) return; + void scout.listBrands().then(setBrands).catch(() => setBrands([])); + }, [scout]); + + useEffect(() => { + if (!scout || !brandId) { + setProducts([]); + return; + } + void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([])); + }, [scout, brandId]); + + async function submit() { + const nextTerms = expandIncludeTerms(splitTerms(terms)); + if (!nextTerms.length) { + setError(t("radar.watches.threadsRequired")); + return; + } + if ((brandId && !productId) || (!brandId && productId)) { + setError(t("radar.watches.needBrandProductShort")); + return; + } + setBusy(true); + setError(""); + try { + const created = await repos.radar.createWatch({ + terms: nextTerms, + regions, + enabled: true, + brand_id: brandId || undefined, + product_id: productId || undefined, + }); + onCreated({ firstSweepTriggered: Boolean(created.first_sweep_triggered) }); + } catch (e) { + setError(formatError(e)); + } finally { + setBusy(false); + } + } + + return ( +
{ + e.preventDefault(); + void submit(); + }} + > +

{t("radar.start.hint")}

+