fix all bug

This commit is contained in:
王性驊 2026-08-13 02:22:24 +00:00
parent 85d2ad37f6
commit 806cd51333
378 changed files with 25877 additions and 1239 deletions

View File

@ -7,6 +7,7 @@ import (
"time"
"apps/backend/internal/config"
scoutRepo "apps/backend/internal/module/scout/repository"
"github.com/zeromicro/go-zero/core/conf"
"go.mongodb.org/mongo-driver/bson"
@ -86,6 +87,8 @@ func indexModels() map[string][]mongo.IndexModel {
// The jobs list is polled continuously by the UI and runs a count plus a find per poll.
ownerIndex("updated_at", "owner_jobs_updated"),
},
"scout_runs": scoutRepo.RunIndexModels(),
"scout_seen_identities": scoutRepo.SeenIdentityIndexModels(),
// One notification is written per job state change and nothing purges them, so this
// collection grows without bound and its scan cost grows with it.
"notifications": {
@ -112,10 +115,10 @@ func indexModels() map[string][]mongo.IndexModel {
},
"scout_brands": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_brands")}},
"scout_products": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_products")}},
"scout_posts": {
"scout_posts": append([]mongo.IndexModel{
ownerIndex("created_at", "owner_posts_created"),
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_brand_posts")},
},
}, scoutRepo.RunPostIndexModels()...),
"scout_homework": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_homework")}},
"inspire_elements": {ownerIndex("updated_at", "owner_elements_updated")},
"inspire_sessions": {ownerIndex("updated_at", "owner_sessions_updated")},
@ -180,6 +183,7 @@ func indexModels() map[string][]mongo.IndexModel {
"crm_contacts": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "stage", Value: 1}, {Key: "last_touch_at", Value: -1}}, Options: options.Index().SetName("owner_contacts_stage_touch")},
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "needs_follow_up", Value: 1}}, Options: options.Index().SetName("owner_contacts_follow_up")},
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "removed_at", Value: 1}, {Key: "stage", Value: 1}, {Key: "last_touch_at", Value: -1}}, Options: options.Index().SetName("owner_contacts_active_stage_touch")},
},
"crm_touches": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "contact_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_touches_created")},

View File

@ -159,6 +159,11 @@ func main() {
}
radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
// Product watches are validated again inside the worker at execution time.
// Keep the worker on the same read-only Scout catalog bridge as the gateway;
// without it queued product sweeps fail with "product catalog unavailable".
radarSvc.ProductSource = &workerRadarProductContextBridge{Scout: scoutSvc}
radarSvc.Usage = usageSvc
radarSvc.HitFetch = radarUC.HitFetcherFunc(func(ctx context.Context, ownerUID int64, terms []string, limit int) ([]radarUC.ThreadHit, string, error) {
hits, path, err := scoutSvc.SearchHitsOnly(ctx, ownerUID, terms, limit)
if err != nil {
@ -354,6 +359,35 @@ func (b *workerCrmFollowUpNotif) NotifyFollowUp(ctx context.Context, ownerUID in
"待追蹤到期:請回訪聯絡人", 100)
}
type workerRadarProductContextBridge struct {
Scout *scoutUC.Service
}
func (b *workerRadarProductContextBridge) GetBrand(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) {
brand, err := b.Scout.GetBrand(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return &radarUC.ProductBrand{
ID: brand.ID, OwnerUID: brand.OwnerUID, DisplayName: brand.DisplayName,
TargetAudience: brand.TargetAudience, UpdatedAt: brand.UpdatedAt,
}, nil
}
func (b *workerRadarProductContextBridge) GetProduct(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) {
product, err := b.Scout.GetProduct(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return &radarUC.ProductCatalogProduct{
ID: product.ID, OwnerUID: product.OwnerUID, BrandID: product.BrandID,
Label: product.Label, ProductContext: product.ProductContext,
PainPoints: product.PainPoints, MatchTags: product.MatchTags,
ProviderCapabilityTerms: product.ProviderCapabilityTerms,
ProviderExcludeTerms: product.ProviderExcludeTerms, UpdatedAt: product.UpdatedAt,
}, nil
}
// runRadarSweep executes fetch → judge → persist for one watch.
func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Service, j *jobDomain.Job) error {
var payload jobUC.RadarSweepPayload
@ -448,18 +482,50 @@ func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redisloc
}
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
var brief scoutDomain.RunBrief
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
var payload struct {
RunID string `json:"run_id"`
scoutDomain.RunBrief
}
if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil {
return fmt.Errorf("invalid scout scan payload: %w", err)
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
return err
if payload.RunID == "" || payload.RunID != j.RefID {
return fmt.Errorf("scout run/job reference mismatch")
}
posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief)
run, err := scout.GetRun(ctx, j.OwnerUID, payload.RunID)
if err != nil {
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil {
if run.JobID != j.ID {
return fmt.Errorf("scout run job binding mismatch")
}
// The run owns the normalized target (20 by default). Older payloads may
// omit target_count, so make the worker honor the run contract rather than
// accidentally stopping after the small legacy fan-out default.
if payload.TargetCount <= 0 {
payload.TargetCount = run.TargetCount
}
if _, err := scout.StartRun(ctx, j.OwnerUID, payload.RunID, j.ID); err != nil {
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed")
return err
}
posts, err := scout.RunScanForRun(ctx, j.OwnerUID, payload.RunID, &payload.RunBrief)
if err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error())
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 75, fmt.Sprintf("海巡 · 篩選完成 · 合格 %d / 目標 %d", len(posts), run.TargetCount)); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed")
return err
}
if err := scout.PublishRun(ctx, j.OwnerUID, payload.RunID, posts); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error())
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已發佈 %d 筆候選", len(posts))); err != nil {
return err
}
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))

View File

@ -0,0 +1,166 @@
package main
import (
"context"
"encoding/json"
"errors"
"testing"
jobDomain "apps/backend/internal/module/job/domain"
jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase"
scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
)
type workerScoutProvider struct{ err error }
func (p workerScoutProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]scoutUC.ThreadSearchResult, error) {
if p.err != nil {
return nil, p.err
}
term := "市集"
if len(terms) > 0 && terms[0] != "" {
term = terms[0]
}
if limit < 1 {
limit = 1
}
return []scoutUC.ThreadSearchResult{{
URL: "https://www.threads.net/@worker/post/ok", Snippet: term + "討論內容",
}}, nil
}
type failingPublishScoutRepo struct{ scoutDomain.Repository }
func (failingPublishScoutRepo) PublishRunPosts(context.Context, int64, string, []*scoutDomain.Post) error {
return errors.New("publish storage unavailable")
}
func workerPayload(runID string, brief scoutDomain.RunBrief) string {
raw, _ := json.Marshal(struct {
RunID string `json:"run_id"`
scoutDomain.RunBrief
}{RunID: runID, RunBrief: brief})
return string(raw)
}
func workerRunAndJob(t *testing.T, scoutStore scoutDomain.Repository, jobStore jobDomain.Repository, status string) (*scoutUC.Service, *jobUC.Service, *jobDomain.Job, *scoutDomain.Run) {
t.Helper()
brief := scoutDomain.RunBrief{Intent: "市集", Mode: scoutDomain.ModeActivity, ScanTerms: []string{"市集"}}
run := scoutDomain.NewRun("worker-run", "worker-job", 7, brief, 1)
if err := scoutStore.CreateRun(context.Background(), run); err != nil {
t.Fatal(err)
}
job := &jobDomain.Job{ID: run.JobID, OwnerUID: 7, TemplateType: jobDomain.TemplateScoutScan,
Status: jobDomain.StatusRunning, RefID: run.ID, Payload: workerPayload(run.ID, brief), CreatedAt: 1, UpdatedAt: 1}
if err := jobStore.Insert(context.Background(), job); err != nil {
t.Fatal(err)
}
if status != scoutDomain.RunQueued {
if status == scoutDomain.RunCancelled {
run.Status = scoutDomain.RunCancelled
} else {
run.Status = status
}
if err := scoutStore.ReplaceRunGuarded(context.Background(), 7, run.ID, []string{scoutDomain.RunQueued}, run); err != nil {
t.Fatal(err)
}
}
scout := scoutUC.New(scoutStore)
scout.Provider = workerScoutProvider{}
return scout, jobUC.New(jobStore), job, run
}
func TestWorkerScoutScanSuccessPublishesExactlyOnce(t *testing.T) {
ctx := context.Background()
scoutStore := scoutRepo.NewMemory()
jobStore := jobRepo.NewMemory()
scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued)
if err := runScoutScan(ctx, jobs, scout, job); err != nil {
t.Fatal(err)
}
gotRun, err := scoutStore.GetRun(ctx, 7, run.ID)
if err != nil || gotRun.Status != scoutDomain.RunSucceeded || gotRun.EligibleCount != 1 {
t.Fatalf("run=%+v err=%v", gotRun, err)
}
posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10)
if err != nil || len(posts.Items) != 1 {
t.Fatalf("published posts=%+v err=%v", posts, err)
}
jobAfter, err := jobStore.FindByID(ctx, job.ID)
if err != nil || jobAfter.Status != jobDomain.StatusSucceeded {
t.Fatalf("job=%+v err=%v", jobAfter, err)
}
if err := runScoutScan(ctx, jobs, scout, job); err == nil {
t.Fatal("terminal run retry should not search/publish again")
}
postsAgain, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10)
if err != nil || len(postsAgain.Items) != 1 {
t.Fatalf("retry changed result membership: %+v %v", postsAgain, err)
}
}
func TestWorkerScoutScanMismatchFailsBeforeSearch(t *testing.T) {
ctx := context.Background()
scoutStore := scoutRepo.NewMemory()
jobStore := jobRepo.NewMemory()
scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued)
job.RefID = "wrong-run"
if err := runScoutScan(ctx, jobs, scout, job); err == nil {
t.Fatal("reference mismatch should fail")
}
got, err := scoutStore.GetRun(ctx, 7, run.ID)
if err != nil || got.Status != scoutDomain.RunQueued {
t.Fatalf("mismatch changed run=%+v err=%v", got, err)
}
}
func TestWorkerScoutScanProviderFailureFailsRunAndHidesResults(t *testing.T) {
ctx := context.Background()
scoutStore := scoutRepo.NewMemory()
jobStore := jobRepo.NewMemory()
scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued)
scout.Provider = workerScoutProvider{err: errors.New("provider timeout: token redacted")}
if err := runScoutScan(ctx, jobs, scout, job); err == nil {
t.Fatal("provider failure should fail")
}
got, err := scoutStore.GetRun(ctx, 7, run.ID)
if err != nil || got.Status != scoutDomain.RunFailed || got.Error == "" {
t.Fatalf("failed run=%+v err=%v", got, err)
}
posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10)
if err != nil || len(posts.Items) != 0 {
t.Fatalf("failed run exposed posts=%+v err=%v", posts, err)
}
}
func TestWorkerScoutScanPublishFailureFailsRun(t *testing.T) {
ctx := context.Background()
base := scoutRepo.NewMemory()
scoutStore := failingPublishScoutRepo{Repository: base}
jobStore := jobRepo.NewMemory()
scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued)
if err := runScoutScan(ctx, jobs, scout, job); err == nil {
t.Fatal("publish failure should fail")
}
got, err := base.GetRun(ctx, 7, run.ID)
if err != nil || got.Status != scoutDomain.RunFailed {
t.Fatalf("publish failed run=%+v err=%v", got, err)
}
}
func TestWorkerScoutScanCancelledRunDoesNotRestart(t *testing.T) {
ctx := context.Background()
scoutStore := scoutRepo.NewMemory()
jobStore := jobRepo.NewMemory()
scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunCancelled)
if err := runScoutScan(ctx, jobs, scout, job); err == nil {
t.Fatal("cancelled run should not restart")
}
got, err := scoutStore.GetRun(ctx, 7, run.ID)
if err != nil || got.Status != scoutDomain.RunCancelled {
t.Fatalf("cancelled run changed=%+v err=%v", got, err)
}
}

View File

@ -39,7 +39,7 @@ Optional: `SCOUT_CRAWLER_PORT=8891` changes the local port.
- `dev_mode=false` does not use this service; it uses the configured API provider.
- `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error.
- The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer <CrawlerToken>`.
- **Dual-track search** (熱門 + 最新): each query hits Top and Recent in parallel; merge is **Recent-primary order + Top fill** (both is a badge only — does not reshuffle relevance). Hard-drops only when age &gt; `SCOUT_MAX_AGE_DAYS` (default **180**). Response: `track`, `serp_rank`, `published_at`.
- **Dual-track search** (熱門 + 最新): each query hits Top and Recent in parallel; merge is **Recent-primary order + Top fill** (both is a badge only — does not reshuffle relevance). Published age is returned as metadata and is not hard-dropped; backend score may down-rank older candidates. Response: `track`, `serp_rank`, `published_at`.
## Operations

View File

@ -17,13 +17,6 @@ type Post = {
const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
const token = process.env.SCOUT_CRAWLER_TOKEN || "";
/** 硬擋:可解析且超過此天數才丟。預設 180。 */
const hardMaxAgeDays = (() => {
const n = Number(process.env.SCOUT_MAX_AGE_DAYS);
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
return 180;
})();
function isThreadsURL(value: string): boolean {
try {
const host = new URL(value).hostname.toLowerCase();
@ -101,13 +94,6 @@ function parsePublishedFromCardText(text: string): { iso?: string; label?: strin
return {};
}
function isHardTooOld(iso?: string): boolean {
if (!iso || hardMaxAgeDays <= 0) return false;
const t = Date.parse(iso);
if (!Number.isFinite(t)) return false;
return Date.now() - t > hardMaxAgeDays * 86_400_000;
}
/**
* page.evaluate SERP locator query
* document /post/ nav 100% query
@ -162,7 +148,6 @@ async function readPosts(page: Page, query: string, limit: number): Promise<Post
if (!isThreadsURL(permalink)) continue;
if (!textMatchesQuery(r.text, query)) continue;
const { iso, label } = parsePublishedFromCardText(r.text);
if (isHardTooOld(iso)) continue;
rank += 1;
posts.push({
permalink,

View File

@ -1,57 +0,0 @@
# Call Home 會議重點與 Action Items
**日期:** 2026/07/28
## 會議重點
### 1. 整合方向
Call Home 可拆成三個部分:
1. **TriggerState**:事件收集、分類、去重與 Recovery。
2. **Delivery**Email、SMS、Phone、ServiceNow、Salesforce。
3. **Service Process**自動開單、Ticket 狀態與結案責任。
### 2. 可以直接重用的能力
- **SSM 團隊提供:** Event Mapping、事件白名單、資料收集、去重與 Recovery 邏輯。
- **SCC-FLEX 提供:** Policy Engine、統一 Webhook、多通路通知與 Salesforce API。
- **Local Administrator** 沿用 SCC-FLEX 現有 RecipientReceiver 設定,不另外開發獨立模組。
### 3. 目前主要缺口
- 尚未定義統一 Event Payload 與 Correlation Key。
- SCC-FLEX 尚缺完整 Deduplication、SoftHard State 與 Event Lifecycle。
- Service Team 可接受的 Event 白名單尚未確認。
- Device Recovered 與 Ticket Closed 的關係尚未定義。
- Salesforce Ticket 的 Owner、Close 權限與 Ask-to-Close 流程尚未確認。
- Call Home License 模式尚未確認。
### 4. 初步共識
- Supermicro Service 情境傾向**自動建立 Ticket**。
- 自動開單前必須先完成 Event 白名單、去重及 Idempotency。
- 初期先支援 ComputeSupermicro Hardware。
- CDU、PDU、Liquid Cooling 等產品後續再評估。
## Action Items
| Owner | Action Item | Status |
|---|---|---|
| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
| DanielPM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
| PMService Team | 提供可自動開單的 Event 白名單、必要 Payload附件及維護 Owner。 | 待確認 |
| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、SoftHard State、Deduplication 與 Idempotency。 | 待排程 |
| DanielITService Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
| PM產品團隊 | 決定 Event 選擇方式及 Ticket ListAsk-to-Close 是否列入產品範圍。 | 待討論 |
## 下一步
下一次會議只需要確認四件事:
1. Service Team Event 白名單。
2. SSM 可提供的資料與介面。
3. Salesforce Ticket Lifecycle 與責任人。
4. License 與產品範圍。

View File

@ -42,6 +42,7 @@ type (
ListContactsReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Query string `form:"query,optional"`
Stage string `form:"stage,optional"`
// FollowUp: true | false留空不篩選
FollowUp string `form:"follow_up,optional"`
@ -237,6 +238,9 @@ service gateway {
@handler GetContact
get /contacts/:id (GetContactReq) returns (ContactDetailData)
@handler DeleteContact
delete /contacts/:id (ContactIdReq) returns (OkData)
@handler UpdateContactStage
post /contacts/:id/stage (UpdateContactStageReq) returns (ContactPublic)

View File

@ -247,7 +247,7 @@ type (
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"`
// TargetCount話題今日目標。主路徑不足時會加碼再搜次路徑補抓上限 40。
// TargetCount話題今日目標,未指定時預設 20。主路徑不足時會加碼再搜/次路徑補抓,上限 40。
TargetCount int `json:"target_count,optional"`
}
ScoutScanReq {
@ -255,9 +255,53 @@ type (
}
ScoutScanJobData {
Job JobPublic `json:"job"`
Run ScoutRunPublic `json:"run"`
}
ScoutRunPublic {
Id string `json:"id"`
JobId string `json:"job_id"`
ThemeKey string `json:"theme_key"`
ThemeLabel string `json:"theme_label"`
Intent string `json:"intent"`
Mode string `json:"mode"`
BrandId string `json:"brand_id,optional"`
TargetCount int `json:"target_count"`
Status string `json:"status"`
SearchedCount int `json:"searched_count"`
DuplicateCount int `json:"duplicate_count"`
IrrelevantCount int `json:"irrelevant_count"`
EligibleCount int `json:"eligible_count"`
PendingCount int `json:"pending_count"`
ShortfallCount int `json:"shortfall_count"`
ShortfallReasons []string `json:"shortfall_reasons"`
CreatedAt int64 `json:"created_at"`
StartedAt int64 `json:"started_at,optional"`
CompletedAt int64 `json:"completed_at,optional"`
Error string `json:"error,optional"`
}
ScoutRunListReq {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
BrandId string `form:"brand_id,optional"`
Mode string `form:"mode,optional"`
}
ScoutRunListData {
List []ScoutRunPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
ScoutRunPostsReq {
RunId string `path:"runId"`
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ScoutRunPostsData {
Run ScoutRunPublic `json:"run"`
List []ScoutPostPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
ScoutPostPublic {
Id string `json:"id"`
RunId string `json:"run_id,optional"`
BrandId string `json:"brand_id,optional"`
Author string `json:"author"`
Text string `json:"text"`
@ -451,6 +495,15 @@ service gateway {
@handler RunScan
post /scan (ScoutScanReq) returns (ScoutScanJobData)
@handler ListScoutRuns
get /runs (ScoutRunListReq) returns (ScoutRunListData)
@handler ListScoutRunPosts
get /runs/:runId/posts (ScoutRunPostsReq) returns (ScoutRunPostsData)
@handler RemoveScoutRun
delete /runs/:runId (ScoutRunPostsReq) returns (OkData)
@handler ListScoutPosts
get /posts (ScoutPostListReq) returns (ScoutPostListData)

View File

@ -50,22 +50,32 @@ type (
// ---------- RadarWatch ----------
RadarWatchPublic {
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
ContextMode string `json:"context_mode"` // generic | product
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
BrandNameSnapshot string `json:"brand_name_snapshot,optional"`
ProductLabelSnapshot string `json:"product_label_snapshot,optional"`
ContextBoundAt int64 `json:"context_bound_at,optional"`
PauseReason string `json:"pause_reason,optional"`
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 {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Status string `form:"status,optional"`
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Status string `form:"status,optional"`
ContextMode string `form:"context_mode,optional"`
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,optional"`
}
WatchListData {
@ -81,6 +91,8 @@ type (
ExcludeTerms []string `json:"exclude_terms,optional"`
Regions []string `json:"regions,optional"`
Enabled bool `json:"enabled,optional"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
}
UpdateWatchReq {
@ -94,14 +106,24 @@ type (
Id string `path:"id"`
}
AssignProductReq {
Id string `path:"id"`
BrandId string `json:"brand_id"`
ProductId string `json:"product_id"`
}
SuggestWatchTermsReq {
Limit int `json:"limit,optional"`
Limit int `json:"limit,optional"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
}
WatchTermSuggestion {
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"` // include | exclude
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"` // include | exclude
BasisKind string `json:"basis_kind,optional"`
BasisText string `json:"basis_text,optional"`
}
WatchSuggestData {
@ -129,6 +151,33 @@ type (
At int64 `json:"at"`
}
ProductFitReasonPublic {
Dimension string `json:"dimension"` // pain | scenario | audience | capability
Score int `json:"score"`
Reason string `json:"reason"`
CandidateExcerpt string `json:"candidate_excerpt,optional"`
ProductBasis string `json:"product_basis,optional"`
}
ProductMatchPublic {
BrandId string `json:"brand_id"`
ProductId string `json:"product_id"`
BrandNameSnapshot string `json:"brand_name_snapshot"`
ProductLabelSnapshot string `json:"product_label_snapshot"`
BrandUpdatedAt int64 `json:"brand_updated_at"`
ProductUpdatedAt int64 `json:"product_updated_at"`
ProductFitScore int `json:"product_fit_score"`
ProductFitBand string `json:"product_fit_band"` // strong | possible | weak
Eligible bool `json:"eligible"`
Excluded bool `json:"excluded"`
ExcludeReason string `json:"exclude_reason,optional"`
Reasons []ProductFitReasonPublic `json:"reasons"`
Risks []string `json:"risks"`
WatchIds []string `json:"watch_ids"`
MatchedTerms []string `json:"matched_terms"`
MatchedAt int64 `json:"matched_at"`
}
OpportunityPublic {
Id string `json:"id"`
WatchId string `json:"watch_id,optional"`
@ -152,6 +201,30 @@ type (
Override *OpportunityOverride `json:"override,optional"`
ContactId string `json:"contact_id,optional"`
DefaultReply *ReplyVariantPublic `json:"default_reply,optional"`
PrimaryBrandId string `json:"primary_brand_id,optional"`
PrimaryProductId string `json:"primary_product_id,optional"`
PrimaryBrandName string `json:"primary_brand_name,optional"`
PrimaryProductLabel string `json:"primary_product_label,optional"`
PrimaryProductFitScore int `json:"primary_product_fit_score,optional"`
PrimaryProductFitBand string `json:"primary_product_fit_band,optional"`
PrimaryProductOverridden bool `json:"primary_product_overridden,optional"`
ProductMatches []ProductMatchPublic `json:"product_matches"`
ReviewState string `json:"review_state"` // pending | completed | removed
PreviousReviewState string `json:"previous_review_state,optional"`
RemovalReason string `json:"removal_reason,optional"`
RemovalNote string `json:"removal_note,optional"`
RemovedAt int64 `json:"removed_at,optional"`
RemovedBy int64 `json:"removed_by,optional"`
LastMatchedAt int64 `json:"last_matched_at,optional"`
PriorityScore int `json:"priority_score"`
PriorityBand string `json:"priority_band"` // high | review | low
PainFitScore int `json:"pain_fit_score,optional"`
DemandIntentScore int `json:"demand_intent_score,optional"`
EvidenceQualityScore int `json:"evidence_quality_score,optional"`
FreshnessScore int `json:"freshness_score,optional"`
DemandEvidence []string `json:"demand_evidence,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
CreatedAt int64 `json:"created_at"`
}
@ -169,19 +242,33 @@ type (
Low []OpportunityPublic `json:"low"`
TruncatedCount int `json:"truncated_count"`
LastSweptAt int64 `json:"last_swept_at,optional"`
// EmptyReason: not_swept_yet | all_watches_paused | no_watch | no_profile | sweep_failed | no_hit
// EmptyReason: not_swept_yet | all_watches_paused | no_watch | no_profile | sweep_failed | no_hit | no_eligible_product_match
EmptyReason string `json:"empty_reason,optional"`
EmptyHint string `json:"empty_hint,optional"`
}
RadarTodayReq {
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,optional"`
FitBand string `form:"fit_band,optional"`
}
ListOpportunitiesReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Band string `form:"band,optional"`
Status string `form:"status,optional"`
WatchId string `form:"watch_id,optional"`
From int64 `form:"from,optional"`
To int64 `form:"to,optional"`
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Band string `form:"band,optional"`
Status string `form:"status,optional"`
WatchId string `form:"watch_id,optional"`
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,optional"`
FitBand string `form:"fit_band,optional"`
MatchState string `form:"match_state,optional"`
ReviewState string `form:"review_state,optional"`
TimeScope string `form:"time_scope,optional"` // today | 7d | all
PriorityBand string `form:"priority_band,optional"` // high | review | low
Sort string `form:"sort,optional"`
From int64 `form:"from,optional"`
To int64 `form:"to,optional"`
}
OpportunityListData {
@ -208,6 +295,82 @@ type (
Reason string `json:"reason,optional"`
}
ReviewStateReq {
Id string `path:"id"`
State string `json:"state"` // pending | completed | removed
RemovalReason string `json:"removal_reason,optional"`
RemovalNote string `json:"removal_note,optional"`
DuplicateOpportunityId string `json:"duplicate_of_opportunity_id,optional"`
}
DemandMapProductReq {
ProductId string `path:"productId"`
}
DemandMapPhrase {
Text string `json:"text"`
Kind string `json:"kind"`
BasisKind string `json:"basis_kind,optional"`
BasisText string `json:"basis_text,optional"`
Origin string `json:"origin"` // product | user | ai
Enabled bool `json:"enabled"`
}
DemandMapPublic {
ProductId string `json:"product_id"`
DemandInputVersion string `json:"demand_input_version"`
MapVersion int64 `json:"map_version"`
State string `json:"state"` // ready | incomplete | stale
PainPhrases []DemandMapPhrase `json:"pain_phrases"`
ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases"`
DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes"`
SolutionSignals []DemandMapPhrase `json:"solution_signals"`
ExclusionSignals []DemandMapPhrase `json:"exclusion_signals"`
SourceBasis []string `json:"source_basis"`
CustomPhrases []DemandMapPhrase `json:"custom_phrases"`
AiEnrichedAt int64 `json:"ai_enriched_at,optional"`
UpdatedAt int64 `json:"updated_at"`
}
UpdateDemandMapReq {
ProductId string `path:"productId"`
ExpectedMapVersion int64 `json:"expected_map_version"`
PainPhrases []DemandMapPhrase `json:"pain_phrases,optional"`
ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases,optional"`
DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes,optional"`
SolutionSignals []DemandMapPhrase `json:"solution_signals,optional"`
ExclusionSignals []DemandMapPhrase `json:"exclusion_signals,optional"`
CustomPhrases []DemandMapPhrase `json:"custom_phrases,optional"`
}
CostPreviewReq {
Action string `form:"action"` // sweep | explore | demand_map_enrich | reply
WatchId string `form:"watch_id,optional"`
ProductId string `form:"product_id,optional"`
CandidateLimit int `form:"candidate_limit,optional"`
}
CostPreviewPublic {
PreviewId string `json:"preview_id"`
Action string `json:"action"`
KeyMode string `json:"key_mode"` // platform | byok
FixedCredits int `json:"fixed_credits"`
MinCredits int `json:"min_credits"`
MaxCredits int `json:"max_credits"`
SearchCalls int `json:"search_calls"`
MaxAiCandidates int `json:"max_ai_candidates"`
RemainingCredits int `json:"remaining_credits"`
EstimateBasis string `json:"estimate_basis"`
ExpiresAt int64 `json:"expires_at"`
}
EnrichDemandMapReq {
ProductId string `path:"productId"`
PreviewId string `json:"preview_id"`
CreditCeiling int `json:"credit_ceiling"`
ExpectedMapVersion int64 `json:"expected_map_version"`
}
OverrideOpportunityReq {
Id string `path:"id"`
Band string `json:"band,optional"`
@ -215,6 +378,12 @@ type (
Note string `json:"note,optional"`
}
SetPrimaryProductReq {
Id string `path:"id"`
ProductId string `json:"product_id"`
Reason string `json:"reason"`
}
// ---------- ReplyVariant ----------
ReplyVariantPublic {
Id string `json:"id"`
@ -257,18 +426,35 @@ type (
// ---------- RadarSweep ----------
RadarSweepPublic {
Id string `json:"id"`
WatchId string `json:"watch_id"`
JobId string `json:"job_id,optional"`
Path string `json:"path"` // api | crawler
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
TruncatedCount int `json:"truncated_count"`
FailedReason string `json:"failed_reason,optional"`
CreditsUsed int `json:"credits_used"`
StartedAt int64 `json:"started_at"`
EndedAt int64 `json:"ended_at,optional"`
Id string `json:"id"`
WatchId string `json:"watch_id"`
JobId string `json:"job_id,optional"`
Path string `json:"path"` // api | crawler
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
TruncatedCount int `json:"truncated_count"`
MatchEvaluatedCount int `json:"match_evaluated_count"`
MatchMergedCount int `json:"match_merged_count"`
FitRejectedCount int `json:"fit_rejected_count"`
FailedReason string `json:"failed_reason,optional"`
CreditsUsed int `json:"credits_used"`
StartedAt int64 `json:"started_at"`
EndedAt int64 `json:"ended_at,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
DedupedCount int `json:"deduped_count"`
PrefilterPassCount int `json:"prefilter_pass_count"`
PrefilterReviewCount int `json:"prefilter_review_count"`
PrefilterRejectedCount int `json:"prefilter_rejected_count"`
CachedJudgmentCount int `json:"cached_judgment_count"`
TombstoneMatchedCount int `json:"tombstone_matched_count"`
BudgetDeferredCount int `json:"budget_deferred_count"`
SweepStatus string `json:"status"` // complete | partial_budget | blocked_budget | failed
CreditSearch int `json:"credit_search"`
CreditDemandMap int `json:"credit_demand_map"`
CreditJudge int `json:"credit_judge"`
CreditReply int `json:"credit_reply"`
}
ListSweepsReq {
@ -296,7 +482,9 @@ type (
}
ImportOpportunitiesReq {
Items []ImportOpportunityItem `json:"items"`
Items []ImportOpportunityItem `json:"items"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
}
ImportedOpportunityResult {
@ -316,7 +504,13 @@ type (
// ---------- Explore商機頁立即探索短詞 fan-out → 五問判定) ----------
ExploreOpportunitiesReq {
// 16 組 Threads 短詞(契約 A不合規整組擋下不默默修正。
Terms []string `json:"terms"`
Terms []string `json:"terms"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
PreviewId string `json:"preview_id,optional"`
CreditCeiling int `json:"credit_ceiling,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
}
ExploreOpportunitiesData {
@ -324,8 +518,18 @@ type (
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
MatchedCount int `json:"matched_count"`
MergedCount int `json:"merged_count"`
TruncatedCount int `json:"truncated_count"`
CreditsUsed int `json:"credits_used"`
DedupedCount int `json:"deduped_count"`
PrefilterPassCount int `json:"prefilter_pass_count"`
PrefilterReviewCount int `json:"prefilter_review_count"`
PrefilterRejectedCount int `json:"prefilter_rejected_count"`
CachedJudgmentCount int `json:"cached_judgment_count"`
TombstoneMatchedCount int `json:"tombstone_matched_count"`
BudgetDeferredCount int `json:"budget_deferred_count"`
SweepStatus string `json:"status"`
}
)
@ -350,6 +554,9 @@ service gateway {
@handler SuggestWatchTerms
post /watches/suggest (SuggestWatchTermsReq) returns (WatchSuggestData)
@handler AssignWatchProduct
post /watches/:id/assign-product (AssignProductReq) returns (RadarWatchPublic)
@handler GetWatch
get /watches/:id (WatchIdReq) returns (RadarWatchPublic)
@ -359,6 +566,9 @@ service gateway {
@handler ArchiveWatch
delete /watches/:id (WatchIdReq) returns (OkData)
@handler DeleteArchivedWatch
delete /watches/:id/purge (WatchIdReq) returns (OkData)
@handler PauseWatch
post /watches/:id/pause (WatchIdReq) returns (RadarWatchPublic)
@ -369,7 +579,7 @@ service gateway {
post /watches/:id/sweep (WatchIdReq) returns (TriggerSweepData)
@handler GetRadarToday
get /today returns (RadarTodayData)
get /today (RadarTodayReq) returns (RadarTodayData)
@handler ListOpportunities
get /opportunities (ListOpportunitiesReq) returns (OpportunityListData)
@ -383,9 +593,27 @@ service gateway {
@handler DismissOpportunity
post /opportunities/:id/dismiss (DismissOpportunityReq) returns (OpportunityPublic)
@handler UpdateOpportunityReviewState
put /opportunities/:id/review-state (ReviewStateReq) returns (OpportunityPublic)
@handler OverrideOpportunity
post /opportunities/:id/override (OverrideOpportunityReq) returns (OpportunityPublic)
@handler SetPrimaryProduct
put /opportunities/:id/primary-product (SetPrimaryProductReq) returns (OpportunityPublic)
@handler GetDemandMap
get /products/:productId/demand-map (DemandMapProductReq) returns (DemandMapPublic)
@handler UpdateDemandMap
put /products/:productId/demand-map (UpdateDemandMapReq) returns (DemandMapPublic)
@handler EnrichDemandMap
post /products/:productId/demand-map/enrich (EnrichDemandMapReq) returns (DemandMapPublic)
@handler GetRadarCostPreview
get /cost-preview (CostPreviewReq) returns (CostPreviewPublic)
@handler ListOpportunityReplies
get /opportunities/:id/replies (ListRepliesReq) returns (ReplyListData)

View File

@ -0,0 +1,6 @@
[
{ "dropIndexes": "radar_watches", "index": "owner_watches_context_status" },
{ "dropIndexes": "radar_watches", "index": "owner_watches_product_created" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunities_primary_product" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunities_product_match" }
]

View File

@ -0,0 +1,16 @@
[
{
"createIndexes": "radar_watches",
"indexes": [
{ "key": { "owner_uid": 1, "context_mode": 1, "status": 1 }, "name": "owner_watches_context_status" },
{ "key": { "owner_uid": 1, "product_id": 1, "created_at": -1 }, "name": "owner_watches_product_created" }
]
},
{
"createIndexes": "radar_opportunities",
"indexes": [
{ "key": { "owner_uid": 1, "primary_product_id": 1, "created_at": -1 }, "name": "owner_opportunities_primary_product" },
{ "key": { "owner_uid": 1, "product_matches.product_id": 1, "created_at": -1 }, "name": "owner_opportunities_product_match" }
]
}
]

View File

@ -0,0 +1,5 @@
[
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunities_review_posted" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunities_tombstone" },
{ "dropIndexes": "radar_opportunity_review_events", "index": "owner_opportunity_review_events" }
]

View File

@ -0,0 +1,15 @@
[
{
"createIndexes": "radar_opportunities",
"indexes": [
{ "key": { "owner_uid": 1, "review_state": 1, "posted_at": -1 }, "name": "owner_opportunities_review_posted" },
{ "key": { "owner_uid": 1, "removal_reason": 1, "removed_at": -1 }, "name": "owner_opportunities_tombstone" }
]
},
{
"createIndexes": "radar_opportunity_review_events",
"indexes": [
{ "key": { "owner_uid": 1, "opportunity_id": 1, "at": -1 }, "name": "owner_opportunity_review_events" }
]
}
]

View File

@ -0,0 +1,6 @@
[
{
"dropIndexes": "radar_demand_maps",
"indexNames": ["owner_product_demand_map", "owner_demand_input_version"]
}
]

View File

@ -0,0 +1,9 @@
[
{
"createIndexes": "radar_demand_maps",
"indexes": [
{ "key": { "owner_uid": 1, "product_id": 1 }, "name": "owner_product_demand_map", "unique": true },
{ "key": { "owner_uid": 1, "demand_input_version": 1 }, "name": "owner_demand_input_version" }
]
}
]

View File

@ -0,0 +1,3 @@
[
{ "dropIndexes": "crm_contacts", "index": "owner_contacts_active_stage_touch" }
]

View File

@ -0,0 +1,8 @@
[
{
"createIndexes": "crm_contacts",
"indexes": [
{ "key": { "owner_uid": 1, "removed_at": 1, "stage": 1, "last_touch_at": -1 }, "name": "owner_contacts_active_stage_touch" }
]
}
]

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package crm
import (
"net/http"
"apps/backend/internal/logic/crm"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ContactIdReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewDeleteContactLogic(r.Context(), svcCtx)
data, err := l.DeleteContact(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func AssignWatchProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AssignProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewAssignWatchProductLogic(r.Context(), svcCtx)
data, err := l.AssignWatchProduct(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteArchivedWatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.WatchIdReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewDeleteArchivedWatchLogic(r.Context(), svcCtx)
data, err := l.DeleteArchivedWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func EnrichDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.EnrichDemandMapReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewEnrichDemandMapLogic(r.Context(), svcCtx)
data, err := l.EnrichDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DemandMapProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetDemandMapLogic(r.Context(), svcCtx)
data, err := l.GetDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetRadarCostPreviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CostPreviewReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetRadarCostPreviewLogic(r.Context(), svcCtx)
data, err := l.GetRadarCostPreview(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -9,12 +9,20 @@ import (
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetRadarTodayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RadarTodayReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetRadarTodayLogic(r.Context(), svcCtx)
data, err := l.GetRadarToday()
data, err := l.GetRadarToday(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SetPrimaryProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SetPrimaryProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewSetPrimaryProductLogic(r.Context(), svcCtx)
data, err := l.SetPrimaryProduct(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateDemandMapReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewUpdateDemandMapLogic(r.Context(), svcCtx)
data, err := l.UpdateDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateOpportunityReviewStateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ReviewStateReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewUpdateOpportunityReviewStateLogic(r.Context(), svcCtx)
data, err := l.UpdateOpportunityReviewState(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -331,6 +331,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/contacts/:id",
Handler: crm.GetContactHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/contacts/:id",
Handler: crm.DeleteContactHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/conversion",
@ -1031,6 +1036,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/cost-preview",
Handler: radar.GetRadarCostPreviewHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/explore",
@ -1066,6 +1076,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/opportunities/:id/override",
Handler: radar.OverrideOpportunityHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/opportunities/:id/primary-product",
Handler: radar.SetPrimaryProductHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/opportunities/:id/replies",
@ -1081,6 +1096,26 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/opportunities/:id/replies/:replyId/mark-used",
Handler: radar.MarkOpportunityReplyUsedHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/opportunities/:id/review-state",
Handler: radar.UpdateOpportunityReviewStateHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/products/:productId/demand-map",
Handler: radar.GetDemandMapHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/products/:productId/demand-map",
Handler: radar.UpdateDemandMapHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/products/:productId/demand-map/enrich",
Handler: radar.EnrichDemandMapHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/service-profile",
@ -1126,11 +1161,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches/:id",
Handler: radar.ArchiveWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/assign-product",
Handler: radar.AssignWatchProductHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/pause",
Handler: radar.PauseWatchHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/watches/:id/purge",
Handler: radar.DeleteArchivedWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/resume",
@ -1300,6 +1345,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/products/import",
Handler: scout.ImportProductHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/runs",
Handler: scout.ListScoutRunsHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/runs/:runId",
Handler: scout.RemoveScoutRunHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/runs/:runId/posts",
Handler: scout.ListScoutRunPostsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/scan",

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package scout
import (
"net/http"
"apps/backend/internal/logic/scout"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListScoutRunPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunPostsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewListScoutRunPostsLogic(r.Context(), svcCtx)
data, err := l.ListScoutRunPosts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package scout
import (
"net/http"
"apps/backend/internal/logic/scout"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListScoutRunsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunListReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewListScoutRunsLogic(r.Context(), svcCtx)
data, err := l.ListScoutRuns(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package scout
import (
"net/http"
"apps/backend/internal/logic/scout"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func RemoveScoutRunHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunPostsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewRemoveScoutRunLogic(r.Context(), svcCtx)
data, err := l.RemoveScoutRun(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteContactLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContactLogic {
return &DeleteContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
// DeleteContact removes the contact from the working list while retaining
// opportunity, touch, and conversion history for audit and attribution.
func (l *DeleteContactLogic) DeleteContact(req *types.ContactIdReq) (*types.OkData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if err := l.svcCtx.Crm.RemoveContact(l.ctx, uid, req.Id); err != nil {
return nil, err
}
return &types.OkData{Ok: true, Message: "contact removed from list"}, nil
}

View File

@ -27,7 +27,7 @@ func (l *ListContactsLogic) ListContacts(req *types.ListContactsReq) (*types.Con
if err != nil {
return nil, err
}
f := domain.ContactListFilter{Stage: req.Stage, Band: req.Band, Sort: req.Sort, Page: req.Page, PageSize: req.PageSize}
f := domain.ContactListFilter{Query: req.Query, Stage: req.Stage, Band: req.Band, Sort: req.Sort, Page: req.Page, PageSize: req.PageSize}
switch strings.ToLower(req.FollowUp) {
case "true", "1":
v := true

View File

@ -0,0 +1,37 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type AssignWatchProductLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAssignWatchProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AssignWatchProductLogic {
return &AssignWatchProductLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AssignWatchProductLogic) AssignWatchProduct(req *types.AssignProductReq) (resp *types.RadarWatchPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
w, err := l.svcCtx.Radar.AssignWatchProduct(l.ctx, uid, req.Id, req.BrandId, req.ProductId)
if err != nil {
return nil, err
}
return radarmap.Watch(w), nil
}

View File

@ -0,0 +1,157 @@
package radar
import (
"context"
"testing"
"time"
radarDomain "apps/backend/internal/module/radar/domain"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
)
// p0ProductSource is the same read-only boundary used by the real Scout→Radar
// bridge. Keeping it local makes this gate deterministic and network-free.
type p0ProductSource struct {
brand *radarUC.ProductBrand
products map[string]*radarUC.ProductCatalogProduct
}
func (s *p0ProductSource) GetBrand(_ context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) {
if s.brand == nil || s.brand.ID != id {
return nil, radarDomain.ErrNotFound
}
if s.brand.OwnerUID != ownerUID {
return nil, radarDomain.ErrForbidden
}
return s.brand, nil
}
func (s *p0ProductSource) GetProduct(_ context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) {
p, ok := s.products[id]
if !ok {
return nil, radarDomain.ErrNotFound
}
if p.OwnerUID != ownerUID {
return nil, radarDomain.ErrForbidden
}
return p, nil
}
type p0ContactBinder struct{ calls int }
func (b *p0ContactBinder) BindOpportunity(_ context.Context, _ int64, o *radarDomain.Opportunity) (string, error) {
b.calls++
return "contact:" + o.AuthorHandle, nil
}
func p0Reasons() []radarDomain.OpportunityReason {
return []radarDomain.OpportunityReason{
{Dimension: radarDomain.DimAuthenticity, Score: 30, Reason: "公開貼文是使用者求助"},
{Dimension: radarDomain.DimIntent, Score: 30, Reason: "明確詢問推薦與價格"},
{Dimension: radarDomain.DimRegion, Score: 10, Reason: "未猜測地區"},
{Dimension: radarDomain.DimFreshness, Score: 15, Reason: "剛發布"},
{Dimension: radarDomain.DimFit, Score: 5, Reason: "產品上下文仍保留"},
}
}
func TestBrandProductP0FlowCreateSweepMergePrimaryQueryAndPause(t *testing.T) {
const owner = int64(42)
ctx := context.Background()
now := time.Now().UnixNano()
source := &p0ProductSource{
brand: &radarUC.ProductBrand{ID: "b1", OwnerUID: owner, DisplayName: "澄光品牌", TargetAudience: "敏感肌使用者", UpdatedAt: 10},
products: map[string]*radarUC.ProductCatalogProduct{
"p1": {ID: "p1", BrandID: "b1", OwnerUID: owner, Label: "舒緩精華", ProductContext: "日常修護", PainPoints: []string{"泛紅不適"}, MatchTags: []string{"舒緩保濕"}, ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 11},
"p2": {ID: "p2", BrandID: "b1", OwnerUID: owner, Label: "修護乳霜", ProductContext: "日常修護", PainPoints: []string{"泛紅不適"}, MatchTags: []string{"舒緩保濕"}, ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 12},
},
}
svc := radarUC.New(radarRepo.NewMemory())
svc.ProductSource = source
svc.Quota = radarUC.FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 20}
svc.CRM = &p0ContactBinder{}
svc.HitFetch = radarUC.HitFetcherFunc(func(context.Context, int64, []string, int) ([]radarUC.ThreadHit, string, error) {
return []radarUC.ThreadHit{{
URL: "https://www.threads.net/@seeker/post/shared-1",
Snippet: "敏感肌使用者求推薦:泛紅不適,想了解舒緩保濕日常修護與修護,請問價格?",
PublishedAt: now,
}}, "crawler", nil
})
w1, err := svc.CreateWatch(ctx, owner, radarUC.WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"})
if err != nil {
t.Fatal(err)
}
if _, err := svc.RunSweep(ctx, owner, w1.ID, "p0-job-p1"); err != nil {
t.Fatal(err)
}
w2, err := svc.CreateWatch(ctx, owner, radarUC.WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p2"})
if err != nil {
t.Fatal(err)
}
second, err := svc.RunSweep(ctx, owner, w2.ID, "p0-job-p2")
if err != nil {
t.Fatal(err)
}
if second.Created != 0 || second.Sweep.MatchMergedCount != 1 {
t.Fatalf("same post was not merged: created=%d sweep=%+v", second.Created, second.Sweep)
}
list, total, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ProductID: "p2", MatchState: "eligible", Sort: "score"})
if err != nil || total != 1 || len(list) != 1 || len(list[0].ProductMatches) != 2 {
t.Fatalf("expected one opportunity with two product matches: total=%d list=%+v err=%v", total, list, err)
}
opportunityID := list[0].ID
if list[0].PrimaryProductID != "p1" {
t.Fatalf("automatic primary arbitration selected %q, want p1", list[0].PrimaryProductID)
}
selected, err := svc.SetPrimaryProduct(ctx, owner, opportunityID, "p1", "人工確認目前主推舒緩精華")
if err != nil {
t.Fatal(err)
}
if selected.PrimaryProductID != "p1" || !selected.PrimaryProductOverridden {
t.Fatalf("primary override not persisted: %+v", selected)
}
today, err := svc.GetTodayFiltered(ctx, owner, radarDomain.OpportunityListFilter{ProductID: "p2"})
if err != nil || today.Stats.Total != 1 || len(today.High)+len(today.Mid)+len(today.Low) != 1 {
t.Fatalf("today product filter lost eligible match: today=%+v err=%v", today, err)
}
accepted, contactID, err := svc.AcceptOpportunity(ctx, owner, opportunityID)
if err != nil || contactID != "contact:seeker" || accepted.ContactID != contactID {
t.Fatalf("contact identity was not reused: opp=%+v contact=%q err=%v", accepted, contactID, err)
}
if accepted.ReviewState != radarDomain.ReviewCompleted {
t.Fatalf("accepted opportunity should leave pending inbox: opp=%+v", accepted)
}
pending, pendingTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ReviewState: radarDomain.ReviewPending})
if err != nil || pendingTotal != 0 || len(pending) != 0 {
t.Fatalf("accepted opportunity remained in pending inbox: total=%d list=%+v err=%v", pendingTotal, pending, err)
}
completed, completedTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ReviewState: radarDomain.ReviewCompleted})
if err != nil || completedTotal != 1 || len(completed) != 1 || completed[0].ID != opportunityID {
t.Fatalf("accepted opportunity missing from completed inbox: total=%d list=%+v err=%v", completedTotal, completed, err)
}
if binder := svc.CRM.(*p0ContactBinder); binder.calls != 1 {
t.Fatalf("expected one CRM contact bind, calls=%d", binder.calls)
}
// A legacy generic row remains queryable without inventing a product match.
legacy := &radarDomain.Opportunity{OwnerUID: owner, ExternalID: "legacy-1", Permalink: "https://threads.net/legacy-1", Text: "舊資料", PostedAt: now, Status: radarDomain.OppQualified, IntentScore: 60, IntentBand: radarDomain.BandMid, Reasons: p0Reasons()}
if _, err := svc.Repo.UpsertByExternalID(ctx, legacy); err != nil {
t.Fatal(err)
}
legacyList, legacyTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{MatchState: "generic"})
if err != nil || legacyTotal != 1 || len(legacyList) != 1 || len(legacyList[0].ProductMatches) != 0 {
t.Fatalf("legacy generic query changed: total=%d list=%+v err=%v", legacyTotal, legacyList, err)
}
paused, err := svc.PauseProductWatches(ctx, owner, "p1")
if err != nil || paused != 1 {
t.Fatalf("product delete lifecycle did not pause p1 watch: count=%d err=%v", paused, err)
}
pausedWatch, err := svc.GetWatch(ctx, owner, w1.ID)
if err != nil || pausedWatch.Status != radarDomain.WatchPaused || pausedWatch.PauseReason != radarDomain.PauseReasonProductUnavailable {
t.Fatalf("paused watch lost explicit reason: %+v err=%v", pausedWatch, err)
}
}

View File

@ -34,6 +34,8 @@ func (l *CreateWatchLogic) CreateWatch(req *types.CreateWatchReq) (resp *types.R
Terms: req.Terms,
ExcludeTerms: req.ExcludeTerms,
Regions: req.Regions,
BrandID: req.BrandId,
ProductID: req.ProductId,
Enabled: req.Enabled,
})
if err != nil {

View File

@ -0,0 +1,37 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteArchivedWatchLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteArchivedWatchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteArchivedWatchLogic {
return &DeleteArchivedWatchLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// DeleteArchivedWatch permanently removes only an already archived watch.
// Its sweeps and opportunities remain available as historical records.
func (l *DeleteArchivedWatchLogic) DeleteArchivedWatch(req *types.WatchIdReq) (*types.OkData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if err := l.svcCtx.Radar.DeleteArchivedWatch(l.ctx, uid, req.Id); err != nil {
return nil, err
}
return &types.OkData{Ok: true, Message: "archived watch deleted"}, nil
}

View File

@ -0,0 +1,42 @@
package radar
import (
"context"
"fmt"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type EnrichDemandMapLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewEnrichDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EnrichDemandMapLogic {
return &EnrichDemandMapLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *EnrichDemandMapLogic) EnrichDemandMap(req *types.EnrichDemandMapReq) (resp *types.DemandMapPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if req == nil || req.ProductId == "" {
return nil, fmt.Errorf("%w: product id required", domain.ErrValidation)
}
m, err := l.svcCtx.Radar.EnrichDemandMap(l.ctx, uid, req.ProductId, req.PreviewId, req.ExpectedMapVersion, req.CreditCeiling)
if err != nil {
return nil, err
}
return radarmap.DemandMap(m), nil
}

View File

@ -2,7 +2,10 @@ package radar
import (
"context"
"fmt"
"strings"
usecase "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@ -28,7 +31,16 @@ func (l *ExploreOpportunitiesLogic) ExploreOpportunities(req *types.ExploreOppor
if err != nil {
return nil, err
}
res, err := l.svcCtx.Radar.ExploreOpportunities(l.ctx, uid, req.Terms)
brandID, productID := strings.TrimSpace(req.BrandId), strings.TrimSpace(req.ProductId)
if (brandID == "") != (productID == "") {
return nil, fmt.Errorf("brand_id and product_id must be provided together")
}
var res *usecase.ExploreResult
if brandID != "" {
res, err = l.svcCtx.Radar.ExploreProductOpportunities(l.ctx, uid, req.Terms, brandID, productID)
} else {
res, err = l.svcCtx.Radar.ExploreOpportunities(l.ctx, uid, req.Terms)
}
if err != nil {
return nil, err
}
@ -37,6 +49,8 @@ func (l *ExploreOpportunitiesLogic) ExploreOpportunities(req *types.ExploreOppor
HitCount: res.HitCount,
JudgedCount: res.JudgedCount,
CreatedCount: res.CreatedCount,
MatchedCount: res.MatchedCount,
MergedCount: res.MergedCount,
TruncatedCount: res.TruncatedCount,
CreditsUsed: res.CreditsUsed,
}, nil

View File

@ -0,0 +1,42 @@
package radar
import (
"context"
"fmt"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetDemandMapLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetDemandMapLogic {
return &GetDemandMapLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetDemandMapLogic) GetDemandMap(req *types.DemandMapProductReq) (resp *types.DemandMapPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if req == nil {
return nil, fmt.Errorf("%w: product id required", domain.ErrValidation)
}
m, err := l.svcCtx.Radar.GetDemandMap(l.ctx, uid, req.ProductId)
if err != nil {
return nil, err
}
return radarmap.DemandMap(m), nil
}

View File

@ -0,0 +1,42 @@
package radar
import (
"context"
"fmt"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRadarCostPreviewLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetRadarCostPreviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarCostPreviewLogic {
return &GetRadarCostPreviewLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRadarCostPreviewLogic) GetRadarCostPreview(req *types.CostPreviewReq) (resp *types.CostPreviewPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if req == nil {
return nil, fmt.Errorf("%w: preview request required", domain.ErrValidation)
}
p, err := l.svcCtx.Radar.GetCostPreview(l.ctx, uid, req.Action, req.WatchId, req.ProductId, req.CandidateLimit)
if err != nil {
return nil, err
}
return radarmap.CostPreview(p), nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
radaruc "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@ -21,12 +22,16 @@ func NewGetRadarTodayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get
return &GetRadarTodayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetRadarTodayLogic) GetRadarToday() (*types.RadarTodayData, error) {
func (l *GetRadarTodayLogic) GetRadarToday(req *types.RadarTodayReq) (*types.RadarTodayData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
t, err := l.svcCtx.Radar.GetToday(l.ctx, uid)
filter := domain.OpportunityListFilter{}
if req != nil {
filter.BrandID, filter.ProductID, filter.FitBand = req.BrandId, req.ProductId, req.FitBand
}
t, err := l.svcCtx.Radar.GetTodayFiltered(l.ctx, uid, filter)
if err != nil {
return nil, err
}
@ -59,4 +64,3 @@ func mapTodayCards(cards []radaruc.TodayOpportunity) []types.OpportunityPublic {
}
return out
}

View File

@ -35,7 +35,13 @@ func (l *ImportOpportunitiesLogic) ImportOpportunities(req *types.ImportOpportun
URL: it.Url, Text: it.Text, Author: it.Author, PostedAt: it.PostedAt,
})
}
results, err := l.svcCtx.Radar.ImportManualOpportunities(l.ctx, uid, items)
brandID, productID := req.BrandId, req.ProductId
var results []usecase.ManualImportResult
if brandID != "" || productID != "" {
results, err = l.svcCtx.Radar.ImportManualProductOpportunities(l.ctx, uid, items, brandID, productID)
} else {
results, err = l.svcCtx.Radar.ImportManualOpportunities(l.ctx, uid, items)
}
if err != nil {
return nil, err
}

View File

@ -8,6 +8,8 @@ import (
"apps/backend/internal/svc"
"apps/backend/internal/types"
"time"
"github.com/zeromicro/go-zero/core/logx"
)
@ -26,9 +28,18 @@ func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesR
if err != nil {
return nil, err
}
postedFrom, postedTo := req.From, req.To
now := domain.NowNano()
if req.TimeScope == "today" {
postedFrom, postedTo = domain.UTCDayBounds(now)
} else if req.TimeScope == "7d" {
postedFrom, postedTo = now-7*int64(24*time.Hour), 0
}
list, total, err := l.svcCtx.Radar.ListOpportunities(l.ctx, uid, domain.OpportunityListFilter{
Band: req.Band, Status: req.Status, WatchID: req.WatchId,
CreatedFrom: req.From, CreatedTo: req.To, Page: req.Page, PageSize: req.PageSize,
BrandID: req.BrandId, ProductID: req.ProductId, FitBand: req.FitBand, MatchState: req.MatchState, Sort: req.Sort,
CreatedFrom: req.From, CreatedTo: req.To, PostedFrom: postedFrom, PostedTo: postedTo, Page: req.Page, PageSize: req.PageSize,
ReviewState: req.ReviewState, TimeScope: req.TimeScope, PriorityBand: req.PriorityBand,
})
if err != nil {
return nil, err

View File

@ -30,7 +30,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W
if err != nil {
return nil, err
}
filter := radarDomain.WatchListFilter{Status: req.Status, Page: req.Page, PageSize: req.PageSize}
filter := radarDomain.WatchListFilter{Status: req.Status, ContextMode: req.ContextMode, BrandID: req.BrandId, ProductID: req.ProductId, Page: req.Page, PageSize: req.PageSize}
list, total, err := l.svcCtx.Radar.ListWatches(l.ctx, uid, filter)
if err != nil {
return nil, err

View File

@ -0,0 +1,19 @@
package radar
import (
"context"
"testing"
)
func TestPrimaryRouteRequiresAuthenticatedServiceContext(t *testing.T) {
ctx := context.Background()
assign := NewAssignWatchProductLogic(ctx, nil)
primary := NewSetPrimaryProductLogic(ctx, nil)
if _, err := assign.AssignWatchProduct(nil); err == nil {
t.Fatal("assign product unexpectedly succeeded without an authenticated service context")
}
if _, err := primary.SetPrimaryProduct(nil); err == nil {
t.Fatal("set primary product unexpectedly succeeded without an authenticated service context")
}
}

View File

@ -0,0 +1,37 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type SetPrimaryProductLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSetPrimaryProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetPrimaryProductLogic {
return &SetPrimaryProductLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *SetPrimaryProductLogic) SetPrimaryProduct(req *types.SetPrimaryProductReq) (resp *types.OpportunityPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, err := l.svcCtx.Radar.SetPrimaryProduct(l.ctx, uid, req.Id, req.ProductId, req.Reason)
if err != nil {
return nil, err
}
return radarmap.Opportunity(o), nil
}

View File

@ -2,7 +2,10 @@ package radar
import (
"context"
"fmt"
"strings"
radardomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@ -29,13 +32,23 @@ func (l *SuggestWatchTermsLogic) SuggestWatchTerms(req *types.SuggestWatchTermsR
if err != nil {
return nil, err
}
list, err := l.svcCtx.Radar.SuggestWatchTerms(l.ctx, uid, req.Limit)
brandID := strings.TrimSpace(req.BrandId)
productID := strings.TrimSpace(req.ProductId)
if (brandID == "") != (productID == "") {
return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", radardomain.ErrValidation)
}
var list []radardomain.WatchTermSuggestion
if brandID != "" {
list, err = l.svcCtx.Radar.SuggestProductWatchTerms(l.ctx, uid, brandID, productID, req.Limit)
} else {
list, err = l.svcCtx.Radar.SuggestWatchTerms(l.ctx, uid, req.Limit)
}
if err != nil {
return nil, err
}
out := make([]types.WatchTermSuggestion, 0, len(list))
for _, s := range list {
out = append(out, types.WatchTermSuggestion{Term: s.Term, Reason: s.Reason, Usage: s.Usage})
out = append(out, types.WatchTermSuggestion{Term: s.Term, Reason: s.Reason, Usage: s.Usage, BasisKind: s.BasisKind, BasisText: s.BasisText})
}
return &types.WatchSuggestData{List: out}, nil
}

View File

@ -0,0 +1,70 @@
package radar
import (
"context"
"fmt"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateDemandMapLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateDemandMapLogic {
return &UpdateDemandMapLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateDemandMapLogic) UpdateDemandMap(req *types.UpdateDemandMapReq) (resp *types.DemandMapPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if req == nil || req.ProductId == "" {
return nil, fmt.Errorf("%w: product id required", domain.ErrValidation)
}
if req.ExpectedMapVersion < 1 {
return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation)
}
current, err := l.svcCtx.Radar.GetDemandMap(l.ctx, uid, req.ProductId)
if err != nil {
return nil, err
}
phrase := func(in []types.DemandMapPhrase) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(in))
for _, p := range in {
out = append(out, domain.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled})
}
return out
}
value := &domain.DemandMap{
OwnerUID: uid, ProductID: req.ProductId, DemandInputVersion: current.DemandInputVersion,
MapVersion: current.MapVersion, State: demandMapState(req),
PainPhrases: phrase(req.PainPhrases), ScenarioPhrases: phrase(req.ScenarioPhrases), DesiredOutcomes: phrase(req.DesiredOutcomes),
SolutionSignals: phrase(req.SolutionSignals), ExclusionSignals: phrase(req.ExclusionSignals), CustomPhrases: phrase(req.CustomPhrases),
SourceBasis: append([]string(nil), current.SourceBasis...), AIEnrichedAt: current.AIEnrichedAt,
}
updated, err := l.svcCtx.Radar.UpdateDemandMap(l.ctx, uid, value, req.ExpectedMapVersion)
if err != nil {
return nil, err
}
return radarmap.DemandMap(updated), nil
}
func demandMapState(req *types.UpdateDemandMapReq) string {
if len(req.PainPhrases) > 0 && len(req.ScenarioPhrases) > 0 && len(req.SolutionSignals) > 0 {
return "ready"
}
return "incomplete"
}

View File

@ -0,0 +1,41 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateOpportunityReviewStateLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateOpportunityReviewStateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOpportunityReviewStateLogic {
return &UpdateOpportunityReviewStateLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateOpportunityReviewStateLogic) UpdateOpportunityReviewState(req *types.ReviewStateReq) (resp *types.OpportunityPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, err := l.svcCtx.Radar.UpdateOpportunityReviewState(l.ctx, uid, req.Id, domain.ReviewStatePatch{
State: req.State, RemovalReason: req.RemovalReason, RemovalNote: req.RemovalNote,
DuplicateOpportunityID: req.DuplicateOpportunityId, RemovedBy: uid,
})
if err != nil {
return nil, err
}
return radarmap.Opportunity(o), nil
}

View File

@ -5,6 +5,37 @@ import (
"apps/backend/internal/types"
)
func DemandMap(m *domain.DemandMap) *types.DemandMapPublic {
if m == nil {
return nil
}
phrases := func(in []domain.DemandMapPhrase) []types.DemandMapPhrase {
out := make([]types.DemandMapPhrase, 0, len(in))
for _, p := range in {
out = append(out, types.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled})
}
return out
}
return &types.DemandMapPublic{
ProductId: m.ProductID, DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, State: m.State,
PainPhrases: phrases(m.PainPhrases), ScenarioPhrases: phrases(m.ScenarioPhrases), DesiredOutcomes: phrases(m.DesiredOutcomes),
SolutionSignals: phrases(m.SolutionSignals), ExclusionSignals: phrases(m.ExclusionSignals), SourceBasis: append([]string(nil), m.SourceBasis...),
CustomPhrases: phrases(m.CustomPhrases), AiEnrichedAt: m.AIEnrichedAt, UpdatedAt: m.UpdatedAt,
}
}
func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic {
if p == nil {
return nil
}
return &types.CostPreviewPublic{
PreviewId: p.PreviewID, Action: p.Action, KeyMode: p.KeyMode,
FixedCredits: p.FixedCredits, MinCredits: p.MinCredits, MaxCredits: p.MaxCredits,
SearchCalls: p.SearchCalls, MaxAiCandidates: p.MaxAICandidates,
RemainingCredits: p.RemainingCredits, EstimateBasis: p.EstimateBasis, ExpiresAt: p.ExpiresAt,
}
}
/*
ServiceProfile domain 檔案轉成 API 形狀
@ -76,11 +107,20 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
regions = []string{}
}
return &types.RadarWatchPublic{
Id: w.ID,
Terms: terms,
ExcludeTerms: excludes,
Regions: regions,
Status: w.Status,
Id: w.ID,
Terms: terms,
ExcludeTerms: excludes,
Regions: regions,
Status: w.Status,
ContextMode: func() string {
if w.ContextMode == "" {
return domain.WatchContextGeneric
}
return w.ContextMode
}(),
BrandId: w.BrandID, ProductId: w.ProductID,
BrandNameSnapshot: w.BrandNameSnapshot, ProductLabelSnapshot: w.ProductLabelSnapshot,
ContextBoundAt: w.ContextBoundAt, PauseReason: w.PauseReason,
LastSweptAt: w.LastSweptAt,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
@ -165,6 +205,28 @@ func Opportunity(o *domain.Opportunity) *types.OpportunityPublic {
Reasons: reasons, RegionDetected: o.RegionDetected, RegionMatch: o.RegionMatch,
FreshnessHours: o.FreshnessHours, MatchedService: o.MatchedService, MatchedTerms: terms,
RejectReason: o.RejectReason, ContactId: o.ContactID, CreatedAt: o.CreatedAt,
PrimaryBrandId: o.PrimaryBrandID, PrimaryProductId: o.PrimaryProductID,
PrimaryBrandName: o.PrimaryBrandName, PrimaryProductLabel: o.PrimaryProductLabel,
PrimaryProductFitScore: o.PrimaryProductFitScore, PrimaryProductFitBand: o.PrimaryProductFitBand,
PrimaryProductOverridden: o.PrimaryProductOverridden,
ReviewState: reviewState(o), PreviousReviewState: o.PreviousReviewState,
RemovalReason: o.RemovalReason, RemovalNote: o.RemovalNote, RemovedAt: o.RemovedAt, RemovedBy: o.RemovedBy,
LastMatchedAt: o.LastMatchedAt,
PriorityScore: o.PriorityScore, PriorityBand: o.PriorityBand, PainFitScore: o.PainFitScore,
DemandIntentScore: o.DemandIntentScore, EvidenceQualityScore: o.EvidenceQualityScore,
FreshnessScore: o.FreshnessScore, DemandEvidence: nonNilStrings(o.DemandEvidence),
DemandInputVersion: o.DemandInputVersion, DemandMapVersion: o.DemandMapVersion,
}
out.ProductMatches = make([]types.ProductMatchPublic, 0, len(o.ProductMatches))
for _, match := range o.ProductMatches {
if match == nil {
continue
}
reasons := make([]types.ProductFitReasonPublic, 0, len(match.Reasons))
for _, reason := range match.Reasons {
reasons = append(reasons, types.ProductFitReasonPublic{Dimension: reason.Dimension, Score: reason.Score, Reason: reason.Reason, CandidateExcerpt: reason.CandidateExcerpt, ProductBasis: reason.ProductBasis})
}
out.ProductMatches = append(out.ProductMatches, types.ProductMatchPublic{BrandId: match.BrandID, ProductId: match.ProductID, BrandNameSnapshot: match.BrandNameSnapshot, ProductLabelSnapshot: match.ProductLabelSnapshot, BrandUpdatedAt: match.BrandUpdatedAt, ProductUpdatedAt: match.ProductUpdatedAt, ProductFitScore: match.ProductFitScore, ProductFitBand: match.ProductFitBand, Eligible: match.Eligible, Excluded: match.Excluded, ExcludeReason: match.ExcludeReason, Reasons: reasons, Risks: nonNilStrings(match.Risks), WatchIds: nonNilStrings(match.WatchIDs), MatchedTerms: nonNilStrings(match.MatchedTerms), MatchedAt: match.MatchedAt})
}
if o.Override != nil {
out.Override = &types.OpportunityOverride{
@ -176,6 +238,27 @@ func Opportunity(o *domain.Opportunity) *types.OpportunityPublic {
return out
}
func reviewState(o *domain.Opportunity) string {
if o.ReviewState != "" {
return o.ReviewState
}
switch o.Status {
case domain.OppAccepted:
return domain.ReviewCompleted
case domain.OppDismissed:
return domain.ReviewRemoved
default:
return domain.ReviewPending
}
}
func nonNilStrings(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func OpportunityList(list []*domain.Opportunity) []types.OpportunityPublic {
out := make([]types.OpportunityPublic, 0, len(list))
for _, o := range list {
@ -190,11 +273,24 @@ func Sweep(s *domain.RadarSweep) *types.RadarSweepPublic {
if s == nil {
return nil
}
status := "complete"
if s.FailedReason != "" {
status = "failed"
} else if s.TruncatedCount > 0 || s.BudgetDeferredCount > 0 {
status = "partial_budget"
}
return &types.RadarSweepPublic{
Id: s.ID, WatchId: s.WatchID, JobId: s.JobID, Path: s.Path,
HitCount: s.HitCount, JudgedCount: s.JudgedCount, CreatedCount: s.CreatedCount,
TruncatedCount: s.TruncatedCount, FailedReason: s.FailedReason, CreditsUsed: s.CreditsUsed,
StartedAt: s.StartedAt, EndedAt: s.EndedAt,
MatchEvaluatedCount: s.MatchEvaluatedCount, MatchMergedCount: s.MatchMergedCount, FitRejectedCount: s.FitRejectedCount,
DedupedCount: s.DedupedCount, PrefilterPassCount: s.PrefilterPassCount, PrefilterReviewCount: s.PrefilterReviewCount,
PrefilterRejectedCount: s.PrefilterRejectedCount, CachedJudgmentCount: s.CachedJudgmentCount,
TombstoneMatchedCount: s.TombstoneMatchedCount, BudgetDeferredCount: s.BudgetDeferredCount,
DemandInputVersion: s.DemandInputVersion, DemandMapVersion: s.DemandMapVersion,
CreditSearch: s.CreditSearch, CreditDemandMap: s.CreditDemandMap, CreditJudge: s.CreditJudge, CreditReply: s.CreditReply,
SweepStatus: status,
StartedAt: s.StartedAt, EndedAt: s.EndedAt,
}
}

View File

@ -0,0 +1,18 @@
package radarmap
import (
"testing"
"apps/backend/internal/module/radar/domain"
)
func TestProductMapperKeepsLegacyAndNormalizesSlices(t *testing.T) {
legacy := Watch(&domain.RadarWatch{ID: "legacy", Status: domain.WatchActive})
if legacy.ContextMode != domain.WatchContextGeneric || legacy.Terms == nil || legacy.Regions == nil {
t.Fatalf("legacy watch mapping: %#v", legacy)
}
o := Opportunity(&domain.Opportunity{ID: "o", ProductMatches: []*domain.ProductMatch{nil}})
if o.ProductMatches == nil || len(o.ProductMatches) != 0 {
t.Fatalf("nil product match must not leak: %#v", o.ProductMatches)
}
}

View File

@ -0,0 +1,57 @@
package scout
import (
"context"
"apps/backend/internal/middleware"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListScoutRunPostsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListScoutRunPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListScoutRunPostsLogic {
return &ListScoutRunPostsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListScoutRunPostsLogic) ListScoutRunPosts(req *types.ScoutRunPostsReq) (resp *types.ScoutRunPostsData, err error) {
if l.svcCtx.Scout == nil {
return nil, response.Biz(503, 503001, "scout not configured")
}
uid, ok := middleware.UIDFrom(l.ctx)
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
if req == nil {
return nil, response.Biz(400, 400020, "invalid run posts request")
}
if err := validateRunID(req.RunId); err != nil {
return nil, err
}
if err := validateRunPage(req.Page, req.PageSize); err != nil {
return nil, err
}
page, err := l.svcCtx.Scout.ListRunPosts(l.ctx, uid, req.RunId, req.Page, req.PageSize)
if err != nil {
return nil, err
}
posts := make([]types.ScoutPostPublic, 0, len(page.Items))
for _, post := range page.Items {
posts = append(posts, types.ScoutPostFromDomain(post))
}
return &types.ScoutRunPostsData{
Run: types.ScoutRunFromDomain(page.Run), List: posts,
Pagination: types.PaginationFromDomain(page.Pagination),
}, nil
}

View File

@ -0,0 +1,51 @@
package scout
import (
"context"
"apps/backend/internal/middleware"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListScoutRunsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListScoutRunsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListScoutRunsLogic {
return &ListScoutRunsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListScoutRunsLogic) ListScoutRuns(req *types.ScoutRunListReq) (resp *types.ScoutRunListData, err error) {
if l.svcCtx.Scout == nil {
return nil, response.Biz(503, 503001, "scout not configured")
}
uid, ok := middleware.UIDFrom(l.ctx)
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
if req == nil {
return nil, response.Biz(400, 400020, "invalid run list request")
}
if err := validateRunPage(req.Page, req.PageSize); err != nil {
return nil, err
}
page, err := l.svcCtx.Scout.ListRuns(l.ctx, uid, req.BrandId, req.Mode, req.Page, req.PageSize)
if err != nil {
return nil, err
}
out := make([]types.ScoutRunPublic, 0, len(page.Items))
for _, run := range page.Items {
out = append(out, types.ScoutRunFromDomain(run))
}
return &types.ScoutRunListData{List: out, Pagination: types.PaginationFromDomain(page.Pagination)}, nil
}

View File

@ -0,0 +1,46 @@
package scout
import (
"context"
"apps/backend/internal/middleware"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type RemoveScoutRunLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRemoveScoutRunLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveScoutRunLogic {
return &RemoveScoutRunLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RemoveScoutRunLogic) RemoveScoutRun(req *types.ScoutRunPostsReq) (resp *types.OkData, err error) {
if l.svcCtx.Scout == nil {
return nil, response.Biz(503, 503001, "scout not configured")
}
uid, ok := middleware.UIDFrom(l.ctx)
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
if req == nil {
return nil, response.Biz(400, 400020, "invalid run request")
}
if err := validateRunID(req.RunId); err != nil {
return nil, err
}
if err := l.svcCtx.Scout.RemoveRun(l.ctx, uid, req.RunId); err != nil {
return nil, err
}
return &types.OkData{Ok: true}, nil
}

View File

@ -0,0 +1,17 @@
package scout
import "apps/backend/internal/response"
func validateRunPage(page, pageSize int) error {
if page < 0 || pageSize < 0 || pageSize > 100 {
return response.Biz(400, 400020, "invalid page or pageSize")
}
return nil
}
func validateRunID(id string) error {
if id == "" {
return response.Biz(400, 400021, "run id is required")
}
return nil
}

View File

@ -0,0 +1,111 @@
package scout
import (
"context"
"errors"
"testing"
"apps/backend/internal/middleware"
"apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func testRun(id string, owner int64, created int64, status string) *domain.Run {
r := domain.NewRun(id, "job-"+id, owner, domain.RunBrief{
Intent: "敏感肌保養", Mode: domain.ModeTheme, ThemeKey: "skin", ThemeLabel: "敏感肌保養",
}, created)
r.Status = status
if status == domain.RunSucceeded {
r.EligibleCount = 1
}
return r
}
func testScoutContext(uid int64, repo *scoutRepo.MemoryStore) (*svc.ServiceContext, context.Context) {
service := scoutUC.New(repo)
return &svc.ServiceContext{Scout: service}, middleware.WithUID(context.Background(), uid)
}
func TestRunListLogicOwnerScopedPagination(t *testing.T) {
repo := scoutRepo.NewMemory()
for _, run := range []*domain.Run{
testRun("run-old", 7, 100, domain.RunSucceeded),
testRun("run-new", 7, 200, domain.RunSucceeded),
testRun("run-other", 8, 300, domain.RunSucceeded),
} {
if err := repo.CreateRun(context.Background(), run); err != nil {
t.Fatal(err)
}
}
svcCtx, ctx := testScoutContext(7, repo)
data, err := NewListScoutRunsLogic(ctx, svcCtx).ListScoutRuns(&types.ScoutRunListReq{Page: 1, PageSize: 1})
if err != nil {
t.Fatal(err)
}
if data.Pagination.Total != 2 || data.Pagination.TotalPages != 2 || len(data.List) != 1 || data.List[0].Id != "run-new" {
t.Fatalf("unexpected run page: %+v", data)
}
if data.List[0].JobId == "" {
t.Fatal("run response omitted job id")
}
}
func TestRunPostsLogicReturnsRunAndIndependentPage(t *testing.T) {
repo := scoutRepo.NewMemory()
run := testRun("run-posts", 7, 100, domain.RunSucceeded)
if err := repo.CreateRun(context.Background(), run); err != nil {
t.Fatal(err)
}
for _, post := range []*domain.Post{
{ID: "post-old", RunID: run.ID, OwnerUID: 7, Text: "old", PostedAt: 100, CreatedAt: 100},
{ID: "post-new", RunID: run.ID, OwnerUID: 7, Text: "new", PostedAt: 200, CreatedAt: 200},
} {
if err := repo.PublishRunPosts(context.Background(), 7, run.ID, []*domain.Post{post}); err != nil {
t.Fatal(err)
}
}
svcCtx, ctx := testScoutContext(7, repo)
data, err := NewListScoutRunPostsLogic(ctx, svcCtx).ListScoutRunPosts(&types.ScoutRunPostsReq{RunId: run.ID, Page: 2, PageSize: 1})
if err != nil {
t.Fatal(err)
}
if data.Run.Id != run.ID || data.Pagination.Total != 2 || data.Pagination.Page != 2 || len(data.List) != 1 || data.List[0].Id != "post-old" {
t.Fatalf("unexpected run posts page: %+v", data)
}
}
func TestRunRemoveLogicGuardsNonTerminalAndOwner(t *testing.T) {
repo := scoutRepo.NewMemory()
queued := testRun("run-queued", 7, 100, domain.RunQueued)
done := testRun("run-done", 7, 200, domain.RunSucceeded)
for _, run := range []*domain.Run{queued, done} {
if err := repo.CreateRun(context.Background(), run); err != nil {
t.Fatal(err)
}
}
svcCtx, ctx := testScoutContext(7, repo)
if _, err := NewRemoveScoutRunLogic(ctx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: queued.ID}); !errors.Is(err, domain.ErrIllegalRunStatus) {
t.Fatalf("queued run removal error = %v, want illegal status", err)
}
otherCtx := middleware.WithUID(context.Background(), 8)
if _, err := NewRemoveScoutRunLogic(otherCtx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: done.ID}); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("cross-owner removal error = %v, want not found", err)
}
if data, err := NewRemoveScoutRunLogic(ctx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: done.ID}); err != nil || !data.Ok {
t.Fatalf("terminal run removal = %+v, %v", data, err)
}
if _, err := repo.GetRun(context.Background(), 7, done.ID); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("removed run still exists: %v", err)
}
}
func TestRunLogicRequiresAuth(t *testing.T) {
svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutRepo.NewMemory())}
_, err := NewListScoutRunsLogic(context.Background(), svcCtx).ListScoutRuns(&types.ScoutRunListReq{})
if err == nil || err.Error() != "missing authorization" {
t.Fatalf("auth error = %v", err)
}
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"apps/backend/internal/middleware"
scoutDomain "apps/backend/internal/module/scout/domain"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@ -23,23 +24,44 @@ func NewRunScanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunScanLo
}
func (l *RunScanLogic) RunScan(req *types.ScoutScanReq) (*types.ScoutScanJobData, error) {
if l.svcCtx.Jobs == nil {
if l.svcCtx.Jobs == nil || l.svcCtx.Scout == nil {
return nil, response.Biz(503, 503001, "jobs module not configured")
}
uid, ok := middleware.UIDFrom(l.ctx)
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
if req == nil {
return nil, response.Biz(400, 400020, "invalid scout scan request")
}
brief := types.BriefToDomain(&req.Brief)
payload, err := json.Marshal(brief)
run, err := l.svcCtx.Scout.CreateQueuedRun(l.ctx, uid, brief)
if err != nil {
return nil, err
}
j, err := l.svcCtx.Jobs.ScheduleScoutScan(l.ctx, uid, brief.ThemeKey, string(payload))
// Embed run_id alongside the immutable brief fields. The current worker can
// still unmarshal the legacy flat RunBrief; the lifecycle worker will verify
// run_id against Job.RefID.
payload, err := json.Marshal(struct {
RunID string `json:"run_id"`
*scoutDomain.RunBrief
}{RunID: run.ID, RunBrief: brief})
if err != nil {
_ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "failed to encode scan payload")
return nil, err
}
return &types.ScoutScanJobData{Job: types.JobFromModel(j)}, nil
j, err := l.svcCtx.Jobs.ScheduleScoutScan(l.ctx, uid, run.ID, string(payload))
if err != nil {
_ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "scan job scheduling failed")
return nil, err
}
bound, err := l.svcCtx.Scout.BindRunJob(l.ctx, uid, run.ID, j.ID)
if err != nil {
_ = l.svcCtx.Jobs.Delete(l.ctx, uid, j.ID)
_ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "scan run/job binding failed")
return nil, err
}
return &types.ScoutScanJobData{Job: types.JobFromModel(j), Run: types.ScoutRunFromDomain(bound)}, nil
}

View File

@ -0,0 +1,104 @@
package scout
import (
"context"
"encoding/json"
"errors"
"testing"
"apps/backend/internal/middleware"
jobDomain "apps/backend/internal/module/job/domain"
jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase"
scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func TestRunScanCreatesIndependentRunAndJobLink(t *testing.T) {
ctx := middleware.WithUID(context.Background(), 7)
scoutStore := scoutRepo.NewMemory()
jobs := jobUC.New(jobRepo.NewMemory())
svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobs}
data, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{
Intent: "市集", Mode: domainModeActivity, ThemeKey: "same-theme", ThemeLabel: "週末市集",
ScanTerms: []string{"市集"}, TargetCount: 12,
}})
if err != nil {
t.Fatal(err)
}
if data.Run.Id == "" || data.Job.Id == "" || data.Run.Id == data.Job.Id {
t.Fatalf("missing independent ids: %+v", data)
}
if data.Job.RefId != data.Run.Id || data.Run.JobId != data.Job.Id || data.Run.Status != scoutDomain.RunQueued {
t.Fatalf("job/run link mismatch: job=%+v run=%+v", data.Job, data.Run)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data.Job.Payload), &raw); err != nil {
t.Fatal(err)
}
var payloadRunID string
if err := json.Unmarshal(raw["run_id"], &payloadRunID); err != nil || payloadRunID != data.Run.Id {
t.Fatalf("payload run_id=%q err=%v payload=%s", payloadRunID, err, data.Job.Payload)
}
var brief scoutDomain.RunBrief
if err := json.Unmarshal([]byte(data.Job.Payload), &brief); err != nil || brief.Intent != "市集" || len(brief.ScanTerms) != 1 {
t.Fatalf("flat worker brief=%+v err=%v", brief, err)
}
if data.Run.TargetCount != 12 {
t.Fatalf("target count=%d", data.Run.TargetCount)
}
runs, err := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10})
if err != nil || len(runs.Items) != 1 || runs.Items[0].ID != data.Run.Id {
t.Fatalf("stored runs=%+v err=%v", runs, err)
}
}
func TestRunScanCreatesFreshRunForSameTheme(t *testing.T) {
ctx := middleware.WithUID(context.Background(), 7)
scoutStore := scoutRepo.NewMemory()
svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobUC.New(jobRepo.NewMemory())}
logic := NewRunScanLogic(ctx, svcCtx)
request := &types.ScoutScanReq{Brief: types.ScoutBriefPublic{Intent: "市集", Mode: domainModeActivity, ThemeKey: "repeat", ScanTerms: []string{"市集"}}}
first, err := logic.RunScan(request)
if err != nil {
t.Fatal(err)
}
second, err := logic.RunScan(request)
if err != nil {
t.Fatal(err)
}
if first.Run.Id == second.Run.Id || first.Job.Id == second.Job.Id {
t.Fatalf("same theme reused id: first=%+v second=%+v", first, second)
}
}
type failingInsertJobRepo struct{ jobDomain.Repository }
func (failingInsertJobRepo) Insert(context.Context, *jobDomain.Job) error {
return errors.New("insert unavailable")
}
func TestRunScanSchedulingFailureFailsQueuedRun(t *testing.T) {
ctx := middleware.WithUID(context.Background(), 7)
scoutStore := scoutRepo.NewMemory()
failingJobs := jobUC.New(failingInsertJobRepo{Repository: jobRepo.NewMemory()})
svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: failingJobs}
_, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{
Intent: "市集", Mode: domainModeActivity, ScanTerms: []string{"市集"},
}})
if err == nil {
t.Fatal("expected scheduling failure")
}
runs, listErr := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10})
if listErr != nil || len(runs.Items) != 1 || runs.Items[0].Status != scoutDomain.RunFailed {
t.Fatalf("failed run convergence: runs=%+v err=%v listErr=%v", runs, err, listErr)
}
if runs.Items[0].Error == "" {
t.Fatal("failed run missing safe error")
}
}
const domainModeActivity = "activity"

View File

@ -52,8 +52,11 @@ type Contact struct {
TopIntentScore int `bson:"top_intent_score,omitempty" json:"top_intent_score,omitempty"`
// OutcomeID links the latest conversion in growth_outcomes.
OutcomeID string `bson:"outcome_id,omitempty" json:"outcome_id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
// RemovedAt hides the contact from the working list without breaking
// opportunity, touch, or conversion history that still references its ID.
RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type ContactTouch struct {
@ -80,6 +83,7 @@ type FollowUp struct {
}
type ContactListFilter struct {
Query string
Stage string
FollowUp *bool
Band string

View File

@ -7,6 +7,8 @@ type Repository interface {
UpsertContactByIdentity(ctx context.Context, c *Contact) (*Contact, error)
GetContact(ctx context.Context, id string) (*Contact, error)
SaveContact(ctx context.Context, c *Contact) error
// RemoveContact hides a contact and closes active follow-ups while retaining history.
RemoveContact(ctx context.Context, ownerUID int64, id string, at int64) error
ListContacts(ctx context.Context, ownerUID int64, f ContactListFilter) ([]*Contact, int64, error)
CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error)

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"sync"
"apps/backend/internal/module/crm/domain"
@ -39,6 +40,12 @@ func (m *Memory) UpsertContactByIdentity(_ context.Context, c *domain.Contact) (
key := idKey(c.OwnerUID, c.SourcePlatform, c.AuthorHandle)
if id, ok := m.identity[key]; ok {
ex := m.contacts[id]
if ex.RemovedAt > 0 {
ex.RemovedAt = 0
ex.Stage = domain.StageNewFound
ex.NeedsFollowUp = false
ex.LastTouchAt = c.LastTouchAt
}
// merge opportunity ids
seen := map[string]bool{}
for _, x := range ex.OpportunityIDs {
@ -98,12 +105,44 @@ func (m *Memory) SaveContact(_ context.Context, c *domain.Contact) error {
return nil
}
func (m *Memory) RemoveContact(_ context.Context, ownerUID int64, id string, at int64) error {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.contacts[id]
if !ok || c.RemovedAt > 0 {
return domain.ErrNotFound
}
if c.OwnerUID != ownerUID {
return domain.ErrForbidden
}
if at <= 0 {
at = domain.NowNano()
}
c.RemovedAt = at
c.NeedsFollowUp = false
c.UpdatedAt = at
for _, followUp := range m.followups {
if followUp.OwnerUID != ownerUID || followUp.ContactID != id {
continue
}
if followUp.Status == domain.FollowUpScheduled || followUp.Status == domain.FollowUpSnoozed || followUp.Status == domain.FollowUpNotified {
followUp.Status = domain.FollowUpDone
followUp.UpdatedAt = at
}
}
return nil
}
func (m *Memory) ListContacts(_ context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
matched := make([]*domain.Contact, 0)
for _, c := range m.contacts {
if c.OwnerUID != ownerUID {
if c.OwnerUID != ownerUID || c.RemovedAt > 0 {
continue
}
q := strings.ToLower(strings.TrimSpace(f.Query))
if q != "" && !strings.Contains(strings.ToLower(c.AuthorHandle), q) && !strings.Contains(strings.ToLower(c.DisplayName), q) {
continue
}
if f.Stage != "" && c.Stage != f.Stage {
@ -155,7 +194,7 @@ func (m *Memory) CountByStage(_ context.Context, ownerUID int64) (map[string]int
out := map[string]int{}
follow := 0
for _, c := range m.contacts {
if c.OwnerUID != ownerUID {
if c.OwnerUID != ownerUID || c.RemovedAt > 0 {
continue
}
out[c.Stage]++

View File

@ -2,6 +2,8 @@ package repository
import (
"context"
"regexp"
"strings"
libmongo "apps/backend/internal/lib/mongo"
"apps/backend/internal/module/crm/domain"
@ -40,6 +42,12 @@ func (s *MonStore) UpsertContactByIdentity(ctx context.Context, c *domain.Contac
err := s.contacts.FindOne(ctx, &existing, filter)
if err == nil {
// merge
if existing.RemovedAt > 0 {
existing.RemovedAt = 0
existing.Stage = domain.StageNewFound
existing.NeedsFollowUp = false
existing.LastTouchAt = c.LastTouchAt
}
seen := map[string]bool{}
for _, id := range existing.OpportunityIDs {
seen[id] = true
@ -92,8 +100,62 @@ func (s *MonStore) SaveContact(ctx context.Context, c *domain.Contact) error {
return err
}
func (s *MonStore) RemoveContact(ctx context.Context, ownerUID int64, id string, at int64) error {
if at <= 0 {
at = domain.NowNano()
}
_, err := s.followups.UpdateMany(ctx, bson.M{
"owner_uid": ownerUID,
"contact_id": id,
"status": bson.M{"$in": bson.A{
domain.FollowUpScheduled,
domain.FollowUpSnoozed,
domain.FollowUpNotified,
}},
}, bson.M{"$set": bson.M{"status": domain.FollowUpDone, "updated_at": at}})
if err != nil {
return err
}
res, err := s.contacts.UpdateOne(ctx, bson.M{
"_id": id,
"owner_uid": ownerUID,
"$or": bson.A{
bson.M{"removed_at": bson.M{"$exists": false}},
bson.M{"removed_at": 0},
},
}, bson.M{"$set": bson.M{
"removed_at": at,
"needs_follow_up": false,
"updated_at": at,
}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return domain.ErrNotFound
}
return nil
}
func activeContactQuery(ownerUID int64) bson.M {
return bson.M{
"owner_uid": ownerUID,
"$and": bson.A{bson.M{"$or": bson.A{
bson.M{"removed_at": bson.M{"$exists": false}},
bson.M{"removed_at": 0},
}}},
}
}
func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) {
q := bson.M{"owner_uid": ownerUID}
q := activeContactQuery(ownerUID)
if search := strings.TrimSpace(f.Query); search != "" {
pattern := regexp.QuoteMeta(search)
q["$and"] = append(q["$and"].(bson.A), bson.M{"$or": bson.A{
bson.M{"author_handle": bson.M{"$regex": pattern, "$options": "i"}},
bson.M{"display_name": bson.M{"$regex": pattern, "$options": "i"}},
}})
}
if f.Stage != "" {
q["stage"] = f.Stage
}
@ -127,8 +189,9 @@ func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.Co
}
func (s *MonStore) CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error) {
// simple: list all and count (contacts per owner are bounded)
list, _, err := s.ListContacts(ctx, ownerUID, domain.ContactListFilter{Page: 1, PageSize: 500})
// Counts describe the complete active pipeline, not just the current page.
var list []*domain.Contact
err := s.contacts.Find(ctx, &list, activeContactQuery(ownerUID), options.Find().SetProjection(bson.M{"stage": 1, "needs_follow_up": 1}))
if err != nil {
return nil, err
}

View File

@ -0,0 +1,92 @@
package usecase
import (
"context"
"errors"
"fmt"
"testing"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/module/crm/repository"
)
func seedContact(t *testing.T, repo domain.Repository, ownerUID int64, handle, displayName, stage string) *domain.Contact {
t.Helper()
c, err := repo.UpsertContactByIdentity(context.Background(), &domain.Contact{
OwnerUID: ownerUID, SourcePlatform: domain.PlatformThreads,
AuthorHandle: handle, DisplayName: displayName, Stage: stage,
OpportunityIDs: []string{"opp-" + handle}, LastTouchAt: domain.NowNano(),
})
if err != nil {
t.Fatalf("seed contact %s: %v", handle, err)
}
return c
}
func TestContactListSearchPaginationAndCompleteCounts(t *testing.T) {
repo := repository.NewMemory()
svc := New(repo)
ctx := context.Background()
for i := 0; i < 25; i++ {
stage := domain.StageNewFound
if i%2 == 0 {
stage = domain.StageEngaged
}
seedContact(t, repo, 42, fmt.Sprintf("buyer-%02d", i), fmt.Sprintf("客戶 %02d", i), stage)
}
list, total, counts, err := svc.ListContacts(ctx, 42, domain.ContactListFilter{Page: 2, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 25 || len(list) != 5 {
t.Fatalf("pagination list=%d total=%d, want 5/25", len(list), total)
}
if counts[domain.StageEngaged] != 13 || counts[domain.StageNewFound] != 12 {
t.Fatalf("stage counts = %+v", counts)
}
list, total, _, err = svc.ListContacts(ctx, 42, domain.ContactListFilter{Query: "客戶 07", Page: 1, PageSize: 20})
if err != nil || total != 1 || len(list) != 1 || list[0].AuthorHandle != "buyer-07" {
t.Fatalf("search list=%+v total=%d err=%v", list, total, err)
}
}
func TestRemoveContactHidesItClosesFollowUpsAndCanBeReadded(t *testing.T) {
repo := repository.NewMemory()
svc := New(repo)
ctx := context.Background()
c := seedContact(t, repo, 42, "buyer", "買家", domain.StageEngaged)
if _, err := svc.SetFollowUp(ctx, 42, c.ID, true, 3); err != nil {
t.Fatalf("set follow-up: %v", err)
}
if err := svc.RemoveContact(ctx, 42, c.ID); err != nil {
t.Fatalf("remove: %v", err)
}
if _, err := svc.GetContactOnly(ctx, 42, c.ID); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("get removed err=%v, want ErrNotFound", err)
}
list, total, counts, err := svc.ListContacts(ctx, 42, domain.ContactListFilter{Page: 1, PageSize: 20})
if err != nil || total != 0 || len(list) != 0 || counts[domain.StageEngaged] != 0 {
t.Fatalf("removed contact still visible: list=%+v total=%d counts=%+v err=%v", list, total, counts, err)
}
followUps, totalFollowUps, err := repo.ListFollowUps(ctx, 42, domain.FollowUpListFilter{Status: domain.FollowUpDone, Page: 1, PageSize: 20})
if err != nil || totalFollowUps != 1 || len(followUps) != 1 {
t.Fatalf("active follow-up was not closed: list=%+v total=%d err=%v", followUps, totalFollowUps, err)
}
readded := seedContact(t, repo, 42, "buyer", "買家", domain.StageNewFound)
if readded.ID != c.ID || readded.RemovedAt != 0 || readded.Stage != domain.StageNewFound {
t.Fatalf("re-added contact = %+v, want same active identity", readded)
}
}
func TestRemoveContactOwnerGuard(t *testing.T) {
repo := repository.NewMemory()
svc := New(repo)
c := seedContact(t, repo, 42, "buyer", "買家", domain.StageNewFound)
if err := svc.RemoveContact(context.Background(), 7, c.ID); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("remove other owner err=%v, want ErrForbidden", err)
}
}

View File

@ -3,7 +3,9 @@ package usecase
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"apps/backend/internal/module/crm/domain"
radarDomain "apps/backend/internal/module/radar/domain"
@ -63,6 +65,19 @@ func (s *Service) BindOpportunity(ctx context.Context, ownerUID int64, opp *rada
}
func (s *Service) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, map[string]int, error) {
if ownerUID <= 0 {
return nil, 0, nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
f.Query = strings.TrimSpace(f.Query)
if utf8.RuneCountInString(f.Query) > 80 {
return nil, 0, nil, fmt.Errorf("%w: query is too long", domain.ErrValidation)
}
if f.Stage != "" && !domain.IsStage(f.Stage) {
return nil, 0, nil, fmt.Errorf("%w: unknown stage %q", domain.ErrValidation, f.Stage)
}
if f.PageSize > 50 {
f.PageSize = 50
}
list, total, err := s.Repo.ListContacts(ctx, ownerUID, f)
if err != nil {
return nil, 0, nil, err
@ -92,9 +107,21 @@ func (s *Service) GetContactOnly(ctx context.Context, ownerUID int64, id string)
if c.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
if c.RemovedAt > 0 {
return nil, domain.ErrNotFound
}
return c, nil
}
// RemoveContact removes a contact from the working list and closes active
// follow-ups. Opportunity, touch, and conversion history keep their contact ID.
func (s *Service) RemoveContact(ctx context.Context, ownerUID int64, id string) error {
if _, err := s.GetContactOnly(ctx, ownerUID, id); err != nil {
return err
}
return s.Repo.RemoveContact(ctx, ownerUID, id, domain.NowNano())
}
// OpportunityBriefs resolves linked opportunities for contact detail.
func (s *Service) OpportunityBriefs(ctx context.Context, ownerUID int64, ids []string) []map[string]any {
out := make([]map[string]any, 0, len(ids))
@ -277,7 +304,7 @@ func (s *Service) ReportConversion(ctx context.Context, ownerUID int64, contactI
_ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{
ID: domain.NewID(), OwnerUID: ownerUID, ContactID: contactID,
Type: domain.TouchConversion, ToStage: domain.StageWon,
Body: fmt.Sprintf("成交回報 %.0f %s %s", amount, currency, note),
Body: fmt.Sprintf("成交回報 %.0f %s %s", amount, currency, note),
ActorUID: ownerUID, CreatedAt: now,
})
return c, outcomeID, nil

View File

@ -136,13 +136,13 @@ func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, e
}
// ScheduleScoutScan enqueues an immutable Scout brief for the worker.
func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, themeKey, payload string) (*domain.Job, error) {
if strings.TrimSpace(payload) == "" {
return nil, fmt.Errorf("scout scan payload required")
func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, runID, payload string) (*domain.Job, error) {
if strings.TrimSpace(runID) == "" || strings.TrimSpace(payload) == "" {
return nil, fmt.Errorf("scout scan run id and payload required")
}
now := domain.NowNano()
j := &domain.Job{ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateScoutScan,
Status: domain.StatusQueued, RefID: themeKey, Payload: payload,
Status: domain.StatusQueued, RefID: runID, Payload: payload,
ProgressSummary: "海巡已排程 · 等待 worker", CreatedAt: now, UpdatedAt: now}
if err := s.Repo.Insert(ctx, j); err != nil {
return nil, err

View File

@ -0,0 +1,15 @@
package domain
type CostPreview struct {
PreviewID string `json:"preview_id"`
Action string `json:"action"`
KeyMode string `json:"key_mode"`
FixedCredits int `json:"fixed_credits"`
MinCredits int `json:"min_credits"`
MaxCredits int `json:"max_credits"`
SearchCalls int `json:"search_calls"`
MaxAICandidates int `json:"max_ai_candidates"`
RemainingCredits int `json:"remaining_credits"`
EstimateBasis string `json:"estimate_basis"`
ExpiresAt int64 `json:"expires_at"`
}

View File

@ -0,0 +1,71 @@
package domain
import (
"strings"
)
type DemandMapPhrase struct {
Text string `bson:"text" json:"text"`
Kind string `bson:"kind" json:"kind"`
BasisKind string `bson:"basis_kind,omitempty" json:"basis_kind,omitempty"`
BasisText string `bson:"basis_text,omitempty" json:"basis_text,omitempty"`
Origin string `bson:"origin" json:"origin"`
Enabled bool `bson:"enabled" json:"enabled"`
}
type DemandMap struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
ProductID string `bson:"product_id" json:"product_id"`
DemandInputVersion string `bson:"demand_input_version" json:"demand_input_version"`
MapVersion int64 `bson:"map_version" json:"map_version"`
State string `bson:"state" json:"state"`
PainPhrases []DemandMapPhrase `bson:"pain_phrases" json:"pain_phrases"`
ScenarioPhrases []DemandMapPhrase `bson:"scenario_phrases" json:"scenario_phrases"`
DesiredOutcomes []DemandMapPhrase `bson:"desired_outcomes" json:"desired_outcomes"`
SolutionSignals []DemandMapPhrase `bson:"solution_signals" json:"solution_signals"`
ExclusionSignals []DemandMapPhrase `bson:"exclusion_signals" json:"exclusion_signals"`
SourceBasis []string `bson:"source_basis" json:"source_basis"`
CustomPhrases []DemandMapPhrase `bson:"custom_phrases" json:"custom_phrases"`
AIEnrichedAt int64 `bson:"ai_enriched_at,omitempty" json:"ai_enriched_at,omitempty"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
func (m *DemandMap) Normalize() error {
if m.OwnerUID <= 0 || strings.TrimSpace(m.ProductID) == "" || strings.TrimSpace(m.DemandInputVersion) == "" {
return ErrValidation
}
if m.MapVersion < 1 {
m.MapVersion = 1
}
if m.State != "ready" && m.State != "incomplete" && m.State != "stale" {
return ErrValidation
}
for i := range m.PainPhrases {
normalizeDemandPhrase(&m.PainPhrases[i])
}
for i := range m.ScenarioPhrases {
normalizeDemandPhrase(&m.ScenarioPhrases[i])
}
for i := range m.DesiredOutcomes {
normalizeDemandPhrase(&m.DesiredOutcomes[i])
}
for i := range m.SolutionSignals {
normalizeDemandPhrase(&m.SolutionSignals[i])
}
for i := range m.ExclusionSignals {
normalizeDemandPhrase(&m.ExclusionSignals[i])
}
for i := range m.CustomPhrases {
normalizeDemandPhrase(&m.CustomPhrases[i])
}
return nil
}
func normalizeDemandPhrase(p *DemandMapPhrase) {
p.Text = strings.TrimSpace(p.Text)
p.Kind = strings.TrimSpace(p.Kind)
p.BasisKind = strings.TrimSpace(p.BasisKind)
p.BasisText = strings.TrimSpace(p.BasisText)
p.Origin = strings.TrimSpace(p.Origin)
}

View File

@ -6,6 +6,7 @@ var (
ErrNotFound = errors.New("radar not found")
ErrForbidden = errors.New("radar access denied")
ErrValidation = errors.New("radar validation")
ErrConflict = errors.New("radar conflict")
// ErrNotReady marks a radar capability whose route exists but has no implementation yet.
// Returning it keeps the contract honest: never answer 102000 with empty data.
ErrNotReady = errors.New("radar capability not ready")

View File

@ -15,6 +15,24 @@ const (
OppDismissed = "dismissed"
)
// ReviewState is the inbox workflow and deliberately remains separate from
// Opportunity status (accepted still belongs to CRM).
const (
ReviewPending = "pending"
ReviewCompleted = "completed"
ReviewRemoved = "removed"
)
const (
RemovalPainMismatch = "pain_mismatch"
RemovalProviderAd = "provider_or_ad"
RemovalStale = "stale"
RemovalAlreadySolved = "already_solved"
RemovalDuplicate = "duplicate"
RemovalOther = "other"
RemovalLegacy = "legacy_unknown"
)
// Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here.
const (
BandHigh = "high"
@ -75,6 +93,12 @@ type OpportunityOverride struct {
At int64 `bson:"at" json:"at"`
}
type ProductPrimaryOverride struct {
ActorUID int64 `bson:"actor_uid" json:"actor_uid"`
Reason string `bson:"reason" json:"reason"`
At int64 `bson:"at" json:"at"`
}
/*
Opportunity 是經五問判定後的商機
@ -82,43 +106,99 @@ Opportunity 是經五問判定後的商機。
併入 matched_terms不重跑判定SW-06
*/
type Opportunity struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"`
Source string `bson:"source" json:"source"`
SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"`
ExternalID string `bson:"external_id" json:"external_id"`
Permalink string `bson:"permalink" json:"permalink"`
AuthorHandle string `bson:"author_handle" json:"author_handle"`
Text string `bson:"text" json:"text"`
PostedAt int64 `bson:"posted_at" json:"posted_at"`
Status string `bson:"status" json:"status"`
IntentScore int `bson:"intent_score" json:"intent_score"`
IntentBand string `bson:"intent_band" json:"intent_band"`
Reasons []OpportunityReason `bson:"reasons" json:"reasons"`
RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"`
RegionMatch string `bson:"region_match" json:"region_match"`
FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"`
MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"`
MatchedTerms []string `bson:"matched_terms" json:"matched_terms"`
RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"`
Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"`
ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"`
Source string `bson:"source" json:"source"`
SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"`
ExternalID string `bson:"external_id" json:"external_id"`
Permalink string `bson:"permalink" json:"permalink"`
AuthorHandle string `bson:"author_handle" json:"author_handle"`
Text string `bson:"text" json:"text"`
PostedAt int64 `bson:"posted_at" json:"posted_at"`
Status string `bson:"status" json:"status"`
IntentScore int `bson:"intent_score" json:"intent_score"`
IntentBand string `bson:"intent_band" json:"intent_band"`
Reasons []OpportunityReason `bson:"reasons" json:"reasons"`
RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"`
RegionMatch string `bson:"region_match" json:"region_match"`
FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"`
MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"`
MatchedTerms []string `bson:"matched_terms" json:"matched_terms"`
RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"`
Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"`
ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"`
PrimaryBrandID string `bson:"primary_brand_id,omitempty" json:"primary_brand_id,omitempty"`
PrimaryProductID string `bson:"primary_product_id,omitempty" json:"primary_product_id,omitempty"`
PrimaryBrandName string `bson:"primary_brand_name,omitempty" json:"primary_brand_name,omitempty"`
PrimaryProductLabel string `bson:"primary_product_label,omitempty" json:"primary_product_label,omitempty"`
PrimaryProductFitScore int `bson:"primary_product_fit_score,omitempty" json:"primary_product_fit_score,omitempty"`
PrimaryProductFitBand string `bson:"primary_product_fit_band,omitempty" json:"primary_product_fit_band,omitempty"`
PrimaryProductOverridden bool `bson:"primary_product_overridden,omitempty" json:"primary_product_overridden,omitempty"`
PrimaryProductOverride *ProductPrimaryOverride `bson:"primary_product_override,omitempty" json:"primary_product_override,omitempty"`
ProductMatches []*ProductMatch `bson:"product_matches,omitempty" json:"product_matches,omitempty"`
ReviewState string `bson:"review_state,omitempty" json:"review_state,omitempty"`
PreviousReviewState string `bson:"previous_review_state,omitempty" json:"previous_review_state,omitempty"`
RemovalReason string `bson:"removal_reason,omitempty" json:"removal_reason,omitempty"`
RemovalNote string `bson:"removal_note,omitempty" json:"removal_note,omitempty"`
RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"`
RemovedBy int64 `bson:"removed_by,omitempty" json:"removed_by,omitempty"`
LastMatchedAt int64 `bson:"last_matched_at,omitempty" json:"last_matched_at,omitempty"`
PriorityScore int `bson:"priority_score,omitempty" json:"priority_score,omitempty"`
PriorityBand string `bson:"priority_band,omitempty" json:"priority_band,omitempty"`
PainFitScore int `bson:"pain_fit_score,omitempty" json:"pain_fit_score,omitempty"`
DemandIntentScore int `bson:"demand_intent_score,omitempty" json:"demand_intent_score,omitempty"`
EvidenceQualityScore int `bson:"evidence_quality_score,omitempty" json:"evidence_quality_score,omitempty"`
FreshnessScore int `bson:"freshness_score,omitempty" json:"freshness_score,omitempty"`
DemandEvidence []string `bson:"demand_evidence,omitempty" json:"demand_evidence,omitempty"`
DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"`
DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
// OpportunityListFilter 支援 bandstatuswatch日期區間。
// Status 與 Statuses 擇一Statuses 非空時用 $in否則 Status 做單值比對。
type OpportunityListFilter struct {
Band string
Status string
Statuses []string // 多狀態;$in今日頁qualifiedaccepteddismissed
WatchID string
CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound
CreatedTo int64 // exclusive, unix ns; 0 = no upper bound
Page int
PageSize int
Band string
Status string
Statuses []string // 多狀態;$in今日頁qualifiedaccepteddismissed
WatchID string
CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound
CreatedTo int64 // exclusive, unix ns; 0 = no upper bound
PostedFrom int64
PostedTo int64
Page int
PageSize int
BrandID string
ProductID string
FitBand string
MatchState string
Sort string
ReviewState string
TimeScope string
PriorityBand string
}
type ReviewStatePatch struct {
State string
RemovalReason string
RemovalNote string
DuplicateOpportunityID string
RemovedBy int64
}
func IsReviewState(s string) bool {
return s == ReviewPending || s == ReviewCompleted || s == ReviewRemoved
}
func IsRemovalReason(s string) bool {
switch s {
case RemovalPainMismatch, RemovalProviderAd, RemovalStale, RemovalAlreadySolved, RemovalDuplicate, RemovalOther, RemovalLegacy:
return true
default:
return false
}
}
// BandFromScore maps intent_score → band with locked 8050 thresholds.
@ -314,6 +394,31 @@ func (o *Opportunity) ValidateForWrite() error {
return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand)
}
o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms)
seenProducts := map[string]bool{}
for _, match := range o.ProductMatches {
if err := match.ValidateForWrite(); err != nil {
return err
}
if seenProducts[match.ProductID] {
return fmt.Errorf("%w: duplicate product match %q", ErrValidation, match.ProductID)
}
seenProducts[match.ProductID] = true
}
if o.PrimaryProductID != "" {
var primary *ProductMatch
for _, match := range o.ProductMatches {
if match.ProductID == o.PrimaryProductID {
primary = match
break
}
}
if primary == nil {
return fmt.Errorf("%w: primary product must exist in product_matches", ErrValidation)
}
if (!primary.Eligible || primary.Excluded) && o.PrimaryProductOverride == nil {
return fmt.Errorf("%w: weak or excluded primary requires override", ErrValidation)
}
}
return nil
}

View File

@ -0,0 +1,43 @@
package domain
import "strings"
// ReviewEvent is append-only audit data for the inbox workflow. It is kept
// separate from Opportunity so a transition never rewrites history.
type ReviewEvent struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
OpportunityID string `bson:"opportunity_id" json:"opportunity_id"`
From string `bson:"from" json:"from"`
To string `bson:"to" json:"to"`
Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
Note string `bson:"note,omitempty" json:"note,omitempty"`
ActorUID int64 `bson:"actor_uid" json:"actor_uid"`
At int64 `bson:"at" json:"at"`
}
func (e *ReviewEvent) Normalize() error {
e.Reason = strings.TrimSpace(e.Reason)
e.Note = strings.TrimSpace(e.Note)
if e.ID == "" {
e.ID = NewID()
}
if e.At == 0 {
e.At = NowNano()
}
if e.OwnerUID <= 0 || e.OpportunityID == "" || !IsReviewState(e.To) {
return ErrValidation
}
return nil
}
func LegacyReviewState(status string) (state, reason string) {
switch status {
case OppAccepted:
return ReviewCompleted, ""
case OppDismissed:
return ReviewRemoved, RemovalLegacy
default:
return ReviewPending, ""
}
}

View File

@ -0,0 +1,141 @@
package domain
import (
"fmt"
"strings"
)
const (
ProductFitBandStrong = "strong"
ProductFitBandPossible = "possible"
ProductFitBandWeak = "weak"
ProductFitPain = "pain"
ProductFitScenario = "scenario"
ProductFitAudience = "audience"
ProductFitCapability = "capability"
)
type ProductFitReason struct {
Dimension string `bson:"dimension" json:"dimension"`
Score int `bson:"score" json:"score"`
Reason string `bson:"reason" json:"reason"`
CandidateExcerpt string `bson:"candidate_excerpt,omitempty" json:"candidate_excerpt,omitempty"`
ProductBasis string `bson:"product_basis,omitempty" json:"product_basis,omitempty"`
}
type ProductMatch struct {
BrandID string `bson:"brand_id" json:"brand_id"`
ProductID string `bson:"product_id" json:"product_id"`
BrandNameSnapshot string `bson:"brand_name_snapshot" json:"brand_name_snapshot"`
ProductLabelSnapshot string `bson:"product_label_snapshot" json:"product_label_snapshot"`
BrandUpdatedAt int64 `bson:"brand_updated_at" json:"brand_updated_at"`
ProductUpdatedAt int64 `bson:"product_updated_at" json:"product_updated_at"`
ProductFitScore int `bson:"product_fit_score" json:"product_fit_score"`
ProductFitBand string `bson:"product_fit_band" json:"product_fit_band"`
Eligible bool `bson:"eligible" json:"eligible"`
Excluded bool `bson:"excluded" json:"excluded"`
ExcludeReason string `bson:"exclude_reason,omitempty" json:"exclude_reason,omitempty"`
Reasons []ProductFitReason `bson:"reasons" json:"reasons"`
Risks []string `bson:"risks" json:"risks"`
WatchIDs []string `bson:"watch_ids" json:"watch_ids"`
MatchedTerms []string `bson:"matched_terms" json:"matched_terms"`
MatchedAt int64 `bson:"matched_at" json:"matched_at"`
}
var productFitDimensions = map[string]int{
ProductFitPain: 35, ProductFitScenario: 25, ProductFitAudience: 20, ProductFitCapability: 20,
}
func ProductFitBandFromScore(score int) string {
if score >= 75 {
return ProductFitBandStrong
}
if score >= 45 {
return ProductFitBandPossible
}
return ProductFitBandWeak
}
func IsProductFitBand(s string) bool {
return s == ProductFitBandStrong || s == ProductFitBandPossible || s == ProductFitBandWeak
}
// ValidateForWrite enforces four dimensions, bounded scores, and evidence for
// every positive claim. Product name alone is therefore never enough.
func (m *ProductMatch) ValidateForWrite() error {
if m == nil || strings.TrimSpace(m.BrandID) == "" || strings.TrimSpace(m.ProductID) == "" {
return fmt.Errorf("%w: product match requires brand_id and product_id", ErrValidation)
}
if len(m.Reasons) != len(productFitDimensions) {
return fmt.Errorf("%w: product match requires four fit reasons", ErrValidation)
}
seen := map[string]bool{}
total, painOrScenario := 0, false
for _, reason := range m.Reasons {
max, ok := productFitDimensions[reason.Dimension]
if !ok || seen[reason.Dimension] {
return fmt.Errorf("%w: invalid or duplicate product fit dimension %q", ErrValidation, reason.Dimension)
}
if reason.Score < 0 || reason.Score > max {
return fmt.Errorf("%w: product fit score for %s out of range", ErrValidation, reason.Dimension)
}
if strings.TrimSpace(reason.Reason) == "" {
return fmt.Errorf("%w: product fit reason %s is empty", ErrValidation, reason.Dimension)
}
if reason.Score > 0 && (strings.TrimSpace(reason.CandidateExcerpt) == "" || strings.TrimSpace(reason.ProductBasis) == "") {
return fmt.Errorf("%w: positive product fit reason %s requires excerpt and basis", ErrValidation, reason.Dimension)
}
if reason.Score > 0 && (reason.Dimension == ProductFitPain || reason.Dimension == ProductFitScenario) {
painOrScenario = true
}
seen[reason.Dimension], total = true, total+reason.Score
}
if total != m.ProductFitScore || total > 100 {
return fmt.Errorf("%w: product fit score does not equal reason total", ErrValidation)
}
if m.ProductFitBand == "" {
m.ProductFitBand = ProductFitBandFromScore(total)
}
if !IsProductFitBand(m.ProductFitBand) || m.ProductFitBand != ProductFitBandFromScore(total) {
return fmt.Errorf("%w: product fit band does not match score", ErrValidation)
}
if m.Excluded && strings.TrimSpace(m.ExcludeReason) == "" {
return fmt.Errorf("%w: excluded product match requires exclude_reason", ErrValidation)
}
m.Eligible = total >= 45 && painOrScenario && !m.Excluded
m.WatchIDs = uniqueStrings(m.WatchIDs)
m.MatchedTerms = NormalizeMatchedTerms(m.MatchedTerms)
if m.Risks == nil {
m.Risks = []string{}
}
if m.WatchIDs == nil {
m.WatchIDs = []string{}
}
if m.MatchedTerms == nil {
m.MatchedTerms = []string{}
}
return nil
}
func uniqueStrings(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, raw := range in {
s := strings.TrimSpace(raw)
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func CloneProductMatch(m *ProductMatch) *ProductMatch {
if m == nil {
return nil
}
cp := *m
cp.Reasons = append([]ProductFitReason(nil), m.Reasons...)
cp.Risks, cp.WatchIDs, cp.MatchedTerms = append([]string(nil), m.Risks...), append([]string(nil), m.WatchIDs...), append([]string(nil), m.MatchedTerms...)
return &cp
}

View File

@ -0,0 +1,63 @@
package domain
import "testing"
func fitReasons() []ProductFitReason {
return []ProductFitReason{
{Dimension: ProductFitPain, Score: 30, Reason: "貼文描述漏水", CandidateExcerpt: "天花板一直漏水", ProductBasis: "漏水"},
{Dimension: ProductFitScenario, Score: 20, Reason: "有居家處理情境", CandidateExcerpt: "想找人處理", ProductBasis: "居家修繕"},
{Dimension: ProductFitAudience, Score: 10, Reason: "文字支持自住家庭", CandidateExcerpt: "家裡", ProductBasis: "自住家庭"},
{Dimension: ProductFitCapability, Score: 10, Reason: "能力詞可處理", CandidateExcerpt: "抓漏", ProductBasis: "抓漏"},
}
}
func TestProductMatchFitBandsAndEligibility(t *testing.T) {
cases := []struct {
score int
band string
eligible bool
}{
{100, ProductFitBandStrong, true}, {75, ProductFitBandStrong, true}, {74, ProductFitBandPossible, true}, {45, ProductFitBandPossible, true}, {44, ProductFitBandWeak, false},
}
for _, tc := range cases {
m := &ProductMatch{BrandID: "b", ProductID: "p", ProductFitScore: tc.score, ProductFitBand: ProductFitBandFromScore(tc.score), Reasons: fitReasons()}
// Adapt reason total to table boundary while keeping evidence contract.
m.Reasons[0].Score, m.Reasons[1].Score, m.Reasons[2].Score, m.Reasons[3].Score = tc.score, 0, 0, 0
if tc.score > 35 {
m.Reasons[0].Score = 35
m.Reasons[1].Score = tc.score - 35
}
if m.Reasons[1].Score > 25 {
m.Reasons[1].Score = 25
m.Reasons[2].Score = tc.score - 60
}
if m.Reasons[2].Score > 20 {
m.Reasons[2].Score = 20
m.Reasons[3].Score = tc.score - 80
}
if tc.score > 0 {
for i := range m.Reasons {
if m.Reasons[i].Score == 0 {
m.Reasons[i].CandidateExcerpt = "證據"
m.Reasons[i].ProductBasis = "依據"
}
}
}
if err := m.ValidateForWrite(); err != nil {
t.Fatalf("score %d: %v", tc.score, err)
}
if m.ProductFitBand != tc.band || m.Eligible != tc.eligible {
t.Fatalf("score %d got band=%s eligible=%v", tc.score, m.ProductFitBand, m.Eligible)
}
}
}
func TestProductMatchPositiveReasonNeedsEvidence(t *testing.T) {
m := &ProductMatch{BrandID: "b", ProductID: "p", ProductFitScore: 10, Reasons: fitReasons()}
m.Reasons[0].Score = 10
m.Reasons[1].Score, m.Reasons[2].Score, m.Reasons[3].Score = 0, 0, 0
m.Reasons[0].CandidateExcerpt = ""
if err := m.ValidateForWrite(); err == nil {
t.Fatal("positive reason without excerpt must fail")
}
}

View File

@ -0,0 +1,111 @@
package domain
import (
"strconv"
"strings"
)
// ProductMatchPreferred is the stable arbitration order from spec §3.3.
func ProductMatchPreferred(candidate, best *ProductMatch) bool {
if candidate == nil || !candidate.Eligible || candidate.Excluded {
return false
}
if best == nil || !best.Eligible || best.Excluded {
return true
}
if candidate.ProductFitScore != best.ProductFitScore {
return candidate.ProductFitScore > best.ProductFitScore
}
if candidate.ProductFitBand != best.ProductFitBand {
return candidate.ProductFitBand == ProductFitBandStrong
}
if candidate.MatchedAt != best.MatchedAt {
return candidate.MatchedAt < best.MatchedAt
}
return candidate.ProductID < best.ProductID
}
func BestEligibleProductMatch(matches []*ProductMatch) *ProductMatch {
var best *ProductMatch
for _, match := range matches {
if ProductMatchPreferred(match, best) {
best = match
}
}
return best
}
// ProductFitToIntentFit keeps the existing five-question score scale while
// changing only its fit dimension when the primary product changes.
func ProductFitToIntentFit(productScore int) int {
if productScore <= 0 {
return 0
}
if productScore > 100 {
productScore = 100
}
return (productScore*WeightFit + 50) / 100
}
// ApplyPrimaryProduct selects an eligible product (unless a human override is
// already present), copies its snapshot fields, and recomputes only the intent
// fit component. Other four reasons are retained verbatim.
func ApplyPrimaryProduct(o *Opportunity) {
if o == nil {
return
}
var primary *ProductMatch
if o.PrimaryProductOverridden && o.PrimaryProductID != "" {
for _, match := range o.ProductMatches {
if match != nil && match.ProductID == o.PrimaryProductID {
primary = match
break
}
}
} else {
primary = BestEligibleProductMatch(o.ProductMatches)
}
if primary == nil {
return
}
o.PrimaryBrandID, o.PrimaryProductID = primary.BrandID, primary.ProductID
o.PrimaryBrandName, o.PrimaryProductLabel = primary.BrandNameSnapshot, primary.ProductLabelSnapshot
o.PrimaryProductFitScore, o.PrimaryProductFitBand = primary.ProductFitScore, primary.ProductFitBand
fitScore := ProductFitToIntentFit(primary.ProductFitScore)
found := false
for i := range o.Reasons {
if o.Reasons[i].Dimension == DimFit {
o.Reasons[i].Score = fitScore
label := strings.TrimSpace(primary.ProductLabelSnapshot)
if label == "" {
label = primary.ProductID
}
o.Reasons[i].Reason = "主推產品「" + label + "」適配 " + formatProductScore(primary.ProductFitScore) + ",換算產品 fit " + formatProductScore(fitScore) + "/10"
found = true
break
}
}
if !found {
o.Reasons = append(o.Reasons, OpportunityReason{Dimension: DimFit, Score: fitScore, Reason: "主推產品適配分數換算產品 fit"})
}
total := 0
for _, reason := range o.Reasons {
total += reason.Score
}
o.IntentScore = SumReasonScores(o.Reasons)
if total != o.IntentScore {
o.IntentScore = total
if o.IntentScore > 100 {
o.IntentScore = 100
}
}
o.IntentBand = BandFromScore(o.IntentScore)
}
func formatProductScore(score int) string {
// Keep the helper local to avoid introducing a presentation dependency.
if score < 0 {
score = 0
}
return strconv.Itoa(score)
}

View File

@ -0,0 +1,37 @@
package domain
// Product-fit scoring is deliberately a small exported contract so the
// deterministic scorer and later AI judge cannot silently drift in weights.
const (
ProductFitPainWeight = 35
ProductFitScenarioWeight = 25
ProductFitAudienceWeight = 20
ProductFitCapabilityWeight = 20
ProductFitEligibleMinScore = 45
)
var ProductFitDimensionOrder = []string{
ProductFitPain,
ProductFitScenario,
ProductFitAudience,
ProductFitCapability,
}
func ProductFitDimensionWeight(dimension string) int {
switch dimension {
case ProductFitPain:
return ProductFitPainWeight
case ProductFitScenario:
return ProductFitScenarioWeight
case ProductFitAudience:
return ProductFitAudienceWeight
case ProductFitCapability:
return ProductFitCapabilityWeight
default:
return 0
}
}
func ProductFitEligible(score, pain, scenario int, excluded bool) bool {
return score >= ProductFitEligibleMinScore && (pain > 0 || scenario > 0) && !excluded
}

View File

@ -0,0 +1,18 @@
package domain
// QueryPlan is the reviewed search intent compiled from a DemandMap. It is
// persisted with both versions so a sweep can prove which product truth it
// searched, and can safely discard a stale plan after an edit.
type QueryPlan struct {
DemandInputVersion string `json:"demand_input_version"`
MapVersion int64 `json:"map_version"`
Groups []QueryPlanGroup `json:"groups"`
}
type QueryPlanGroup struct {
Query string `json:"query"`
Include []string `json:"include"`
Exclude []string `json:"exclude"`
BasisKinds []string `json:"basis_kinds"`
BasisTexts []string `json:"basis_texts"`
}

View File

@ -11,9 +11,14 @@ type Repository interface {
GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error)
SaveServiceProfile(ctx context.Context, p *ServiceProfile) error
// DemandMap is the product-specific, user-editable search contract.
GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error)
SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error)
// RadarWatch
SaveWatch(ctx context.Context, w *RadarWatch) error
GetWatch(ctx context.Context, id string) (*RadarWatch, error)
DeleteWatch(ctx context.Context, id string) error
ListWatches(ctx context.Context, ownerUID int64, f WatchListFilter) ([]*RadarWatch, int64, error)
// ListActiveWatches 是每日排程的來源,只回 active。
ListActiveWatches(ctx context.Context, ownerUID int64) ([]*RadarWatch, error)
@ -25,9 +30,14 @@ type Repository interface {
// Opportunity
// UpsertByExternalID同 owner+external_id 只留一筆;命中則併 matched_terms不重跑判定。
UpsertByExternalID(ctx context.Context, o *Opportunity) (*Opportunity, error)
// MergeProductMatch atomically adds a new ProductMatch or merges only its
// watch/term source sets when that product was already evaluated.
MergeProductMatch(ctx context.Context, ownerUID int64, opportunityID string, match *ProductMatch) (*Opportunity, error)
SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID string, override *ProductPrimaryOverride) (*Opportunity, error)
GetOpportunity(ctx context.Context, id string) (*Opportunity, error)
GetByExternalID(ctx context.Context, ownerUID int64, externalID string) (*Opportunity, error)
ListOpportunities(ctx context.Context, ownerUID int64, f OpportunityListFilter) ([]*Opportunity, int64, error)
UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, opportunityID string, patch ReviewStatePatch) (*Opportunity, error)
// CountToday 回傳 UTC 當日建立的商機數(每日配額池)。
CountToday(ctx context.Context, ownerUID int64, at int64) (int64, error)
UpdateOpportunityStatus(ctx context.Context, id string, status string) error

View File

@ -6,11 +6,27 @@ const (
SuggestUsageInclude = "include"
SuggestUsageExclude = "exclude"
SuggestBasisAudience = "audience"
SuggestBasisPain = "pain"
SuggestBasisContext = "context"
SuggestBasisTag = "tag"
SuggestBasisCapability = "capability"
SuggestBasisExclude = "exclude"
// 建議數量上限:清單要能一眼看完並逐條決定,不是丟一大串讓人放棄。
MaxSuggestions = 20
DefaultSuggestions = 8
)
func IsSuggestionBasisKind(kind string) bool {
switch strings.ToLower(strings.TrimSpace(kind)) {
case SuggestBasisAudience, SuggestBasisPain, SuggestBasisContext, SuggestBasisTag, SuggestBasisCapability, SuggestBasisExclude:
return true
default:
return false
}
}
/*
WatchTermSuggestion 是一則關鍵字建議
@ -18,9 +34,11 @@ Reason 是必填的:使用者要逐條決定採不採用,看不到「為什
那這個功能就退化成一個猜測產生器
*/
type WatchTermSuggestion struct {
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"`
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"`
BasisKind string `json:"basis_kind,omitempty"`
BasisText string `json:"basis_text,omitempty"`
}
func NormalizeSuggestUsage(s string) string {
@ -66,7 +84,10 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
continue
}
seen[key] = true
out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
out = append(out, WatchTermSuggestion{
Term: term, Reason: reason, Usage: usage,
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: strings.TrimSpace(s.BasisText),
})
if len(out) >= limit {
break
}

View File

@ -18,32 +18,62 @@ RadarSweep 是一次每日巡(或手動觸發)的執行紀錄。
judged_external_ids 標記已判過的貼文避免重跑
*/
type RadarSweep struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
WatchID string `bson:"watch_id" json:"watch_id"`
JobID string `bson:"job_id,omitempty" json:"job_id,omitempty"`
Path string `bson:"path" json:"path"`
HitCount int `bson:"hit_count" json:"hit_count"`
JudgedCount int `bson:"judged_count" json:"judged_count"`
CreatedCount int `bson:"created_count" json:"created_count"`
TruncatedCount int `bson:"truncated_count" json:"truncated_count"`
FailedReason string `bson:"failed_reason,omitempty" json:"failed_reason,omitempty"`
CreditsUsed int `bson:"credits_used" json:"credits_used"`
JudgedExternalIDs []string `bson:"judged_external_ids,omitempty" json:"judged_external_ids,omitempty"`
StartedAt int64 `bson:"started_at" json:"started_at"`
EndedAt int64 `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
WatchID string `bson:"watch_id" json:"watch_id"`
JobID string `bson:"job_id,omitempty" json:"job_id,omitempty"`
Path string `bson:"path" json:"path"`
HitCount int `bson:"hit_count" json:"hit_count"`
JudgedCount int `bson:"judged_count" json:"judged_count"`
CreatedCount int `bson:"created_count" json:"created_count"`
TruncatedCount int `bson:"truncated_count" json:"truncated_count"`
MatchEvaluatedCount int `bson:"match_evaluated_count" json:"match_evaluated_count"`
MatchMergedCount int `bson:"match_merged_count" json:"match_merged_count"`
FitRejectedCount int `bson:"fit_rejected_count" json:"fit_rejected_count"`
FailedReason string `bson:"failed_reason,omitempty" json:"failed_reason,omitempty"`
CreditsUsed int `bson:"credits_used" json:"credits_used"`
DedupedCount int `bson:"deduped_count" json:"deduped_count"`
PrefilterPassCount int `bson:"prefilter_pass_count" json:"prefilter_pass_count"`
PrefilterReviewCount int `bson:"prefilter_review_count" json:"prefilter_review_count"`
PrefilterRejectedCount int `bson:"prefilter_rejected_count" json:"prefilter_rejected_count"`
CachedJudgmentCount int `bson:"cached_judgment_count" json:"cached_judgment_count"`
TombstoneMatchedCount int `bson:"tombstone_matched_count" json:"tombstone_matched_count"`
BudgetDeferredCount int `bson:"budget_deferred_count" json:"budget_deferred_count"`
DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"`
DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"`
CreditSearch int `bson:"credit_search" json:"credit_search"`
CreditDemandMap int `bson:"credit_demand_map" json:"credit_demand_map"`
CreditJudge int `bson:"credit_judge" json:"credit_judge"`
CreditReply int `bson:"credit_reply" json:"credit_reply"`
JudgedExternalIDs []string `bson:"judged_external_ids,omitempty" json:"judged_external_ids,omitempty"`
StartedAt int64 `bson:"started_at" json:"started_at"`
EndedAt int64 `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
}
// SweepDelta is an incremental progress patch applied with $inc / $addToSet.
type SweepDelta struct {
HitCount int
JudgedCount int
CreatedCount int
TruncatedCount int
CreditsUsed int
JudgedExternalIDs []string
FailedReason *string // nil = leave unchanged; non-nil (incl. empty) = set
EndedAt int64 // 0 = leave unchanged
HitCount int
JudgedCount int
CreatedCount int
TruncatedCount int
MatchEvaluatedCount int
MatchMergedCount int
FitRejectedCount int
CreditsUsed int
DedupedCount int
PrefilterPassCount int
PrefilterReviewCount int
PrefilterRejectedCount int
CachedJudgmentCount int
TombstoneMatchedCount int
BudgetDeferredCount int
CreditSearch int
CreditDemandMap int
CreditJudge int
CreditReply int
JudgedExternalIDs []string
FailedReason *string // nil = leave unchanged; non-nil (incl. empty) = set
EndedAt int64 // 0 = leave unchanged
}
// SweepListFilter pages sweeps for an owner, optionally scoped to one watch.

View File

@ -10,10 +10,15 @@ const (
WatchPaused = "paused"
WatchArchived = "archived"
MaxWatchTerms = 20
MaxWatchExcludeTerms = 30
MaxTermLen = 60
MinTermLen = 2
MaxWatchTerms = 20
MaxWatchExcludeTerms = 30
MaxTermLen = 60
MinTermLen = 2
WatchContextGeneric = "generic"
WatchContextProduct = "product"
PauseReasonUser = "user"
PauseReasonProductUnavailable = "product_unavailable"
PauseReasonBrandUnavailable = "brand_unavailable"
)
/*
@ -23,15 +28,22 @@ regions 留空代表沿用服務檔案的地區 —— 這裡刻意不把服務
否則之後改服務檔案舊訂閱會繼續用舊地區判定而使用者不會知道
*/
type RadarWatch struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
Terms []string `bson:"terms" json:"terms"`
ExcludeTerms []string `bson:"exclude_terms" json:"exclude_terms"`
Regions []string `bson:"regions" json:"regions"`
Status string `bson:"status" json:"status"`
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"`
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
Terms []string `bson:"terms" json:"terms"`
ExcludeTerms []string `bson:"exclude_terms" json:"exclude_terms"`
Regions []string `bson:"regions" json:"regions"`
Status string `bson:"status" json:"status"`
ContextMode string `bson:"context_mode,omitempty" json:"context_mode,omitempty"`
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
ProductID string `bson:"product_id,omitempty" json:"product_id,omitempty"`
BrandNameSnapshot string `bson:"brand_name_snapshot,omitempty" json:"brand_name_snapshot,omitempty"`
ProductLabelSnapshot string `bson:"product_label_snapshot,omitempty" json:"product_label_snapshot,omitempty"`
ContextBoundAt int64 `bson:"context_bound_at,omitempty" json:"context_bound_at,omitempty"`
PauseReason string `bson:"pause_reason,omitempty" json:"pause_reason,omitempty"`
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 是建立當下的一次性狀態,不落庫:只用來讓前端在
// 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
@ -39,9 +51,62 @@ type RadarWatch struct {
}
type WatchListFilter struct {
Status string
Page int
PageSize int
Status string
ContextMode string
BrandID string
ProductID string
Page int
PageSize int
}
func IsWatchContext(s string) bool { return s == WatchContextGeneric || s == WatchContextProduct }
func IsWatchPauseReason(s string) bool {
return s == "" || s == PauseReasonUser || s == PauseReasonProductUnavailable || s == PauseReasonBrandUnavailable
}
// NormalizeContext makes pre-product documents read as generic and validates
// the all-or-nothing product identity invariant.
func (w *RadarWatch) NormalizeContext() error {
if w.ContextMode == "" {
w.ContextMode = WatchContextGeneric
}
if !IsWatchContext(w.ContextMode) {
return fmt.Errorf("%w: unknown context_mode %q", ErrValidation, w.ContextMode)
}
if !IsWatchPauseReason(w.PauseReason) {
return fmt.Errorf("%w: unknown pause_reason %q", ErrValidation, w.PauseReason)
}
if w.ContextMode == WatchContextGeneric {
if w.BrandID != "" || w.ProductID != "" {
return fmt.Errorf("%w: generic watch cannot carry product IDs", ErrValidation)
}
return nil
}
if strings.TrimSpace(w.BrandID) == "" || strings.TrimSpace(w.ProductID) == "" {
return fmt.Errorf("%w: product watch requires brand_id and product_id", ErrValidation)
}
if w.ContextBoundAt <= 0 {
w.ContextBoundAt = w.UpdatedAt
}
return nil
}
// BindProduct is one-way: replacing a product would make historical sweeps ambiguous.
func (w *RadarWatch) BindProduct(brandID, productID, brandSnapshot, productSnapshot string, at int64) error {
if err := w.NormalizeContext(); err != nil {
return err
}
if w.ContextMode != WatchContextGeneric || w.BrandID != "" || w.ProductID != "" {
return fmt.Errorf("%w: watch product context is already bound", ErrValidation)
}
if strings.TrimSpace(brandID) == "" || strings.TrimSpace(productID) == "" {
return fmt.Errorf("%w: brand_id and product_id must be provided together", ErrValidation)
}
w.ContextMode, w.BrandID, w.ProductID = WatchContextProduct, strings.TrimSpace(brandID), strings.TrimSpace(productID)
w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = strings.TrimSpace(brandSnapshot), strings.TrimSpace(productSnapshot), at
w.UpdatedAt = at
return nil
}
func IsWatchStatus(s string) bool {
@ -84,7 +149,16 @@ func (w *RadarWatch) Transition(to string) error {
}
return fmt.Errorf("%w: cannot change watch from %s to %s", ErrValidation, w.Status, to)
}
if to == WatchActive && (w.PauseReason == PauseReasonProductUnavailable || w.PauseReason == PauseReasonBrandUnavailable) {
return fmt.Errorf("%w: unavailable product watch must be archived and recreated", ErrValidation)
}
w.Status = to
if to == WatchActive {
w.PauseReason = ""
}
if to == WatchPaused && w.PauseReason == "" {
w.PauseReason = PauseReasonUser
}
w.UpdatedAt = NowNano()
return nil
}
@ -150,6 +224,9 @@ func (w *RadarWatch) Normalize() error {
if !IsWatchStatus(w.Status) {
return fmt.Errorf("%w: unknown watch status %q", ErrValidation, w.Status)
}
if err := w.NormalizeContext(); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,41 @@
package domain
import (
"errors"
"testing"
)
func contextWatch() *RadarWatch {
return &RadarWatch{ID: "w", OwnerUID: 7, Terms: []string{"找水電"}, Status: WatchActive, UpdatedAt: 10}
}
func TestWatchContextLegacyAndPairInvariant(t *testing.T) {
w := contextWatch()
if err := w.Normalize(); err != nil {
t.Fatal(err)
}
if w.ContextMode != WatchContextGeneric {
t.Fatalf("legacy mode=%q", w.ContextMode)
}
w.BrandID = "brand-only"
if err := w.NormalizeContext(); !errors.Is(err, ErrValidation) {
t.Fatalf("expected paired ID validation, got %v", err)
}
}
func TestWatchProductBindingIsOneWay(t *testing.T) {
w := contextWatch()
if err := w.Normalize(); err != nil {
t.Fatal(err)
}
if err := w.BindProduct("b1", "p1", "Brand", "Product", 20); err != nil {
t.Fatal(err)
}
if err := w.BindProduct("b2", "p2", "Other", "Other", 30); !errors.Is(err, ErrValidation) {
t.Fatalf("expected conflict, got %v", err)
}
w.Status, w.PauseReason = WatchPaused, PauseReasonProductUnavailable
if err := w.Transition(WatchActive); !errors.Is(err, ErrValidation) {
t.Fatalf("expected unavailable resume guard, got %v", err)
}
}

View File

@ -0,0 +1,72 @@
package repository
import (
"context"
"fmt"
"apps/backend/internal/module/radar/domain"
)
func demandMapKey(ownerUID int64, productID string) string {
return fmt.Sprintf("%d\x00%s", ownerUID, productID)
}
func (m *Memory) GetDemandMap(_ context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) {
m.mu.Lock()
defer m.mu.Unlock()
value, ok := m.demandMaps[demandMapKey(ownerUID, productID)]
if !ok {
return nil, domain.ErrNotFound
}
return cloneDemandMap(value), nil
}
func (m *Memory) SaveDemandMap(_ context.Context, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) {
if value == nil {
return nil, domain.ErrValidation
}
if err := value.Normalize(); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
key := demandMapKey(value.OwnerUID, value.ProductID)
existing, exists := m.demandMaps[key]
if exists {
if expectedVersion != existing.MapVersion {
return nil, domain.ErrConflict
}
value.MapVersion = existing.MapVersion + 1
} else {
if expectedVersion != 0 {
return nil, domain.ErrConflict
}
if value.MapVersion < 1 {
value.MapVersion = 1
}
}
if value.UpdatedAt == 0 {
value.UpdatedAt = domain.NowNano()
}
if value.ID == "" {
value.ID = domain.NewID()
}
stored := cloneDemandMap(value)
m.demandMaps[key] = stored
return cloneDemandMap(stored), nil
}
func cloneDemandMap(value *domain.DemandMap) *domain.DemandMap {
if value == nil {
return nil
}
out := *value
out.PainPhrases = append([]domain.DemandMapPhrase(nil), value.PainPhrases...)
out.ScenarioPhrases = append([]domain.DemandMapPhrase(nil), value.ScenarioPhrases...)
out.DesiredOutcomes = append([]domain.DemandMapPhrase(nil), value.DesiredOutcomes...)
out.SolutionSignals = append([]domain.DemandMapPhrase(nil), value.SolutionSignals...)
out.ExclusionSignals = append([]domain.DemandMapPhrase(nil), value.ExclusionSignals...)
out.SourceBasis = append([]string(nil), value.SourceBasis...)
out.CustomPhrases = append([]domain.DemandMapPhrase(nil), value.CustomPhrases...)
return &out
}

View File

@ -0,0 +1,76 @@
package repository
import (
"context"
"apps/backend/internal/module/radar/domain"
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func (s *MonStore) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) {
var value domain.DemandMap
err := s.demandMaps.FindOne(ctx, &value, bson.M{"owner_uid": ownerUID, "product_id": productID})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &value, nil
}
// SaveDemandMap implements optimistic concurrency. expectedVersion=0 is only
// valid for the first insert; every subsequent write must name the version the
// editor read, preventing an older browser tab from overwriting newer phrases.
func (s *MonStore) SaveDemandMap(ctx context.Context, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) {
if value == nil {
return nil, domain.ErrValidation
}
if err := value.Normalize(); err != nil {
return nil, err
}
existing, err := s.GetDemandMap(ctx, value.OwnerUID, value.ProductID)
if err != nil && err != domain.ErrNotFound {
return nil, err
}
if existing == nil {
if expectedVersion != 0 {
return nil, domain.ErrConflict
}
value.MapVersion = 1
if value.ID == "" {
value.ID = domain.NewID()
}
} else {
if existing.MapVersion != expectedVersion {
return nil, domain.ErrConflict
}
value.ID = existing.ID
value.MapVersion = existing.MapVersion + 1
}
if value.UpdatedAt == 0 {
value.UpdatedAt = domain.NowNano()
}
if existing == nil {
_, err = s.demandMaps.InsertOne(ctx, value)
} else {
res, rerr := s.demandMaps.ReplaceOne(ctx,
bson.M{"owner_uid": value.OwnerUID, "product_id": value.ProductID, "map_version": expectedVersion},
value, options.Replace())
if rerr == nil && res.MatchedCount == 0 {
rerr = domain.ErrConflict
}
err = rerr
}
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, domain.ErrConflict
}
return nil, err
}
return s.GetDemandMap(ctx, value.OwnerUID, value.ProductID)
}

View File

@ -20,28 +20,39 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (*
existing := m.opportunities[id]
// Hit: merge matched_terms only — do not re-judge or overwrite status/score.
existing.MatchedTerms = domain.MergeMatchedTerms(existing.MatchedTerms, o.MatchedTerms)
existing.UpdatedAt = domain.NowNano()
cp := *existing
cp.Reasons = append([]domain.OpportunityReason(nil), existing.Reasons...)
cp.MatchedTerms = append([]string(nil), existing.MatchedTerms...)
if existing.Override != nil {
ov := *existing.Override
cp.Override = &ov
for _, incoming := range o.ProductMatches {
if err := incoming.ValidateForWrite(); err != nil {
return nil, err
}
mergeMemoryProductMatch(existing, incoming)
}
return &cp, nil
existing.LastMatchedAt = domain.NowNano()
arbitrateMemoryPrimary(existing)
existing.UpdatedAt = domain.NowNano()
return cloneOpportunity(existing), nil
}
if err := o.ValidateForWrite(); err != nil {
return nil, err
}
domain.ApplyPrimaryProduct(o)
if o.ID == "" {
o.ID = domain.NewID()
}
if o.ReviewState == "" {
o.ReviewState = reviewStateFor(o)
}
if !domain.IsReviewState(o.ReviewState) {
return nil, domain.ErrValidation
}
now := domain.NowNano()
if o.CreatedAt == 0 {
o.CreatedAt = now
}
o.UpdatedAt = now
if o.LastMatchedAt == 0 {
o.LastMatchedAt = now
}
if o.IntentBand == "" && o.Status != domain.OppJudging {
o.ApplyBandFromScore()
}
@ -49,10 +60,18 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (*
cp := *o
cp.Reasons = append([]domain.OpportunityReason(nil), o.Reasons...)
cp.MatchedTerms = append([]string(nil), o.MatchedTerms...)
cp.ProductMatches = make([]*domain.ProductMatch, 0, len(o.ProductMatches))
for _, match := range o.ProductMatches {
cp.ProductMatches = append(cp.ProductMatches, domain.CloneProductMatch(match))
}
if o.Override != nil {
ov := *o.Override
cp.Override = &ov
}
if o.PrimaryProductOverride != nil {
ov := *o.PrimaryProductOverride
cp.PrimaryProductOverride = &ov
}
m.opportunities[cp.ID] = &cp
m.ownerExternal[key] = cp.ID
@ -63,9 +82,100 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (*
ov := *cp.Override
out.Override = &ov
}
out.ProductMatches = make([]*domain.ProductMatch, 0, len(cp.ProductMatches))
for _, match := range cp.ProductMatches {
out.ProductMatches = append(out.ProductMatches, domain.CloneProductMatch(match))
}
if cp.PrimaryProductOverride != nil {
ov := *cp.PrimaryProductOverride
out.PrimaryProductOverride = &ov
}
return &out, nil
}
func (m *Memory) MergeProductMatch(_ context.Context, ownerUID int64, opportunityID string, match *domain.ProductMatch) (*domain.Opportunity, error) {
if ownerUID <= 0 || match == nil {
return nil, domain.ErrValidation
}
if err := match.ValidateForWrite(); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.opportunities[opportunityID]
if !ok {
return nil, domain.ErrNotFound
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
mergeMemoryProductMatch(o, match)
arbitrateMemoryPrimary(o)
o.UpdatedAt = domain.NowNano()
return cloneOpportunity(o), nil
}
func (m *Memory) SetPrimaryProduct(_ context.Context, ownerUID int64, opportunityID, productID string, override *domain.ProductPrimaryOverride) (*domain.Opportunity, error) {
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.opportunities[opportunityID]
if !ok {
return nil, domain.ErrNotFound
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
var match *domain.ProductMatch
for _, candidate := range o.ProductMatches {
if candidate.ProductID == productID {
match = candidate
break
}
}
if match == nil {
return nil, domain.ErrValidation
}
if (!match.Eligible || match.Excluded) && (override == nil || strings.TrimSpace(override.Reason) == "") {
return nil, domain.ErrValidation
}
if override != nil {
cp := *override
if cp.At == 0 {
cp.At = domain.NowNano()
}
o.PrimaryProductOverride = &cp
o.PrimaryProductOverridden = true
} else {
o.PrimaryProductOverride = nil
o.PrimaryProductOverridden = false
}
setMemoryPrimary(o, match)
o.UpdatedAt = domain.NowNano()
return cloneOpportunity(o), nil
}
func mergeMemoryProductMatch(o *domain.Opportunity, incoming *domain.ProductMatch) {
for _, current := range o.ProductMatches {
if current.ProductID == incoming.ProductID {
current.WatchIDs = domain.MergeMatchedTerms(current.WatchIDs, incoming.WatchIDs)
current.MatchedTerms = domain.MergeMatchedTerms(current.MatchedTerms, incoming.MatchedTerms)
return
}
}
o.ProductMatches = append(o.ProductMatches, domain.CloneProductMatch(incoming))
}
func setMemoryPrimary(o *domain.Opportunity, match *domain.ProductMatch) {
o.PrimaryBrandID, o.PrimaryProductID = match.BrandID, match.ProductID
o.PrimaryBrandName, o.PrimaryProductLabel = match.BrandNameSnapshot, match.ProductLabelSnapshot
o.PrimaryProductFitScore, o.PrimaryProductFitBand = match.ProductFitScore, match.ProductFitBand
domain.ApplyPrimaryProduct(o)
}
func arbitrateMemoryPrimary(o *domain.Opportunity) {
domain.ApplyPrimaryProduct(o)
}
func (m *Memory) GetOpportunity(_ context.Context, id string) (*domain.Opportunity, error) {
m.mu.Lock()
defer m.mu.Unlock()
@ -119,17 +229,69 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O
if f.WatchID != "" && o.WatchID != f.WatchID {
continue
}
if f.BrandID != "" && !opportunityHasBrand(o, f.BrandID) {
continue
}
if f.ProductID != "" && !opportunityHasProduct(o, f.ProductID) {
continue
}
if f.FitBand != "" && !opportunityHasFitBand(o, f.FitBand) {
continue
}
if f.MatchState != "" && !opportunityMatchState(o, f.MatchState) {
continue
}
if f.ReviewState != "" && reviewStateFor(o) != f.ReviewState {
continue
}
if f.PriorityBand != "" && o.PriorityBand != f.PriorityBand {
continue
}
if f.CreatedFrom > 0 && o.CreatedAt < f.CreatedFrom {
continue
}
if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo {
continue
}
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
continue
}
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
continue
}
matched = append(matched, cloneOpportunity(o))
}
sort.Slice(matched, func(i, j int) bool {
if matched[i].CreatedAt != matched[j].CreatedAt {
return matched[i].CreatedAt > matched[j].CreatedAt
if f.Sort == "posted" || f.Sort == "newest" {
if matched[i].PostedAt != matched[j].PostedAt {
return matched[i].PostedAt > matched[j].PostedAt
}
} else if f.Sort == "oldest" {
if matched[i].PostedAt != matched[j].PostedAt {
return matched[i].PostedAt < matched[j].PostedAt
}
} else if f.Sort == "product_fit" {
if primaryFit(matched[i]) != primaryFit(matched[j]) {
return primaryFit(matched[i]) > primaryFit(matched[j])
}
} else if f.Sort == "demand_intent" {
if matched[i].IntentScore != matched[j].IntentScore {
return matched[i].IntentScore > matched[j].IntentScore
}
} else if f.Sort == "priority" || f.Sort == "priority_score" {
if matched[i].PriorityScore != matched[j].PriorityScore {
return matched[i].PriorityScore > matched[j].PriorityScore
}
} else {
if matched[i].IntentScore != matched[j].IntentScore {
return matched[i].IntentScore > matched[j].IntentScore
}
if primaryFit(matched[i]) != primaryFit(matched[j]) {
return primaryFit(matched[i]) > primaryFit(matched[j])
}
if matched[i].PostedAt != matched[j].PostedAt {
return matched[i].PostedAt > matched[j].PostedAt
}
}
return matched[i].ID < matched[j].ID
})
@ -153,6 +315,86 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O
return matched[start:end], total, nil
}
func (m *Memory) UpdateOpportunityReviewState(_ context.Context, ownerUID int64, opportunityID string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) {
if ownerUID <= 0 || !domain.IsReviewState(patch.State) {
return nil, domain.ErrValidation
}
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.opportunities[opportunityID]
if !ok {
return nil, domain.ErrNotFound
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
current := reviewStateFor(o)
if patch.State == domain.ReviewRemoved {
if !domain.IsRemovalReason(patch.RemovalReason) {
return nil, domain.ErrValidation
}
if patch.RemovalReason == domain.RemovalOther && strings.TrimSpace(patch.RemovalNote) == "" {
return nil, domain.ErrValidation
}
if patch.RemovalReason == domain.RemovalDuplicate {
if patch.DuplicateOpportunityID == "" || patch.DuplicateOpportunityID == opportunityID {
return nil, domain.ErrValidation
}
target, exists := m.opportunities[patch.DuplicateOpportunityID]
if !exists || target.OwnerUID != ownerUID || target.AuthorHandle != o.AuthorHandle {
return nil, domain.ErrValidation
}
}
if current == domain.ReviewRemoved && o.RemovalReason == patch.RemovalReason && o.RemovalNote == strings.TrimSpace(patch.RemovalNote) {
return cloneOpportunity(o), nil
}
o.PreviousReviewState = current
o.ReviewState = domain.ReviewRemoved
o.RemovalReason = patch.RemovalReason
o.RemovalNote = strings.TrimSpace(patch.RemovalNote)
o.RemovedAt = domain.NowNano()
o.RemovedBy = ownerUID
} else {
if current == patch.State && o.RemovalReason == "" {
return cloneOpportunity(o), nil
}
o.ReviewState = patch.State
o.RemovalReason = ""
o.RemovalNote = ""
o.RemovedAt = 0
o.RemovedBy = 0
}
event := domain.ReviewEvent{OwnerUID: ownerUID, OpportunityID: opportunityID, From: current, To: patch.State, Reason: patch.RemovalReason, Note: patch.RemovalNote, ActorUID: ownerUID}
if err := event.Normalize(); err != nil {
return nil, err
}
m.reviewEvents[opportunityID] = append(m.reviewEvents[opportunityID], event)
o.UpdatedAt = domain.NowNano()
return cloneOpportunity(o), nil
}
func (m *Memory) ListOpportunityReviewEvents(_ context.Context, ownerUID int64, opportunityID string) ([]domain.ReviewEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.opportunities[opportunityID]
if !ok {
return nil, domain.ErrNotFound
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
events := append([]domain.ReviewEvent(nil), m.reviewEvents[opportunityID]...)
return events, nil
}
func reviewStateFor(o *domain.Opportunity) string {
if o.ReviewState != "" {
return o.ReviewState
}
state, _ := domain.LegacyReviewState(o.Status)
return state
}
func (m *Memory) CountToday(_ context.Context, ownerUID int64, at int64) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
@ -217,9 +459,68 @@ func cloneOpportunity(o *domain.Opportunity) *domain.Opportunity {
ov := *o.Override
cp.Override = &ov
}
cp.ProductMatches = make([]*domain.ProductMatch, 0, len(o.ProductMatches))
for _, match := range o.ProductMatches {
cp.ProductMatches = append(cp.ProductMatches, domain.CloneProductMatch(match))
}
if o.PrimaryProductOverride != nil {
ov := *o.PrimaryProductOverride
cp.PrimaryProductOverride = &ov
}
return &cp
}
func opportunityHasBrand(o *domain.Opportunity, id string) bool {
for _, m := range o.ProductMatches {
if m.BrandID == id {
return true
}
}
return false
}
func opportunityHasProduct(o *domain.Opportunity, id string) bool {
for _, m := range o.ProductMatches {
if m.ProductID == id {
return true
}
}
return false
}
func opportunityHasFitBand(o *domain.Opportunity, band string) bool {
for _, m := range o.ProductMatches {
if m.ProductFitBand == band {
return true
}
}
return false
}
func opportunityMatchState(o *domain.Opportunity, state string) bool {
if state == "generic" {
return len(o.ProductMatches) == 0
}
for _, m := range o.ProductMatches {
switch state {
case "eligible":
if m.Eligible && !m.Excluded {
return true
}
case "weak":
if m.ProductFitBand == domain.ProductFitBandWeak {
return true
}
case "excluded":
if m.Excluded {
return true
}
}
}
if state == "stale" {
return domain.IsStaleHardReject(o.PostedAt, domain.NowNano())
}
return false
}
func primaryFit(o *domain.Opportunity) int { return o.PrimaryProductFitScore }
func (m *Memory) SetOpportunityContact(_ context.Context, id, contactID string) error {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -146,6 +146,41 @@ func TestUpdateStatusAndOverride(t *testing.T) {
}
}
func TestReviewStateGuardAndTombstone(t *testing.T) {
ctx := context.Background()
m := NewMemory()
row, err := m.UpsertByExternalID(ctx, sampleOpp("review-1", []string{"痛點"}, 80))
if err != nil {
t.Fatal(err)
}
removed, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch, RemovedBy: 42})
if err != nil {
t.Fatal(err)
}
if removed.ReviewState != domain.ReviewRemoved || removed.PreviousReviewState != domain.ReviewPending || removed.RemovedBy != 42 {
t.Fatalf("unexpected tombstone: %+v", removed)
}
removedList, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{ReviewState: domain.ReviewRemoved, Sort: "newest"})
if err != nil || total != 1 || len(removedList) != 1 {
t.Fatalf("review list filter failed: total=%d list=%d err=%v", total, len(removedList), err)
}
repeated, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch})
if err != nil || repeated.RemovedAt != removed.RemovedAt {
t.Fatalf("same removal should be idempotent: got=%+v err=%v", repeated, err)
}
if _, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalOther}); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("other without note should fail, got %v", err)
}
restored, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewPending})
if err != nil || restored.ReviewState != domain.ReviewPending || restored.RemovalReason != "" {
t.Fatalf("restore failed: %+v err=%v", restored, err)
}
events, err := m.ListOpportunityReviewEvents(ctx, 42, row.ID)
if err != nil || len(events) != 2 || events[0].To != domain.ReviewRemoved || events[1].To != domain.ReviewPending {
t.Fatalf("audit should be append-only and idempotent: events=%+v err=%v", events, err)
}
}
func sampleOpp(externalID string, terms []string, score int) *domain.Opportunity {
return &domain.Opportunity{
OwnerUID: 42,

View File

@ -3,6 +3,7 @@ package repository
import (
"context"
"strings"
"time"
"apps/backend/internal/module/radar/domain"
@ -52,6 +53,14 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
return nil, rerr
}
}
for _, match := range o.ProductMatches {
if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
return nil, merr
}
}
if len(o.ProductMatches) > 0 {
return s.GetOpportunity(ctx, existing.ID)
}
return &existing, nil
}
if err != mon.ErrNotFound {
@ -61,6 +70,7 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
if err := o.ValidateForWrite(); err != nil {
return nil, err
}
domain.ApplyPrimaryProduct(o)
if o.ID == "" {
o.ID = domain.NewID()
}
@ -72,6 +82,9 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
if o.IntentBand == "" && o.Status != domain.OppJudging {
o.ApplyBandFromScore()
}
if o.ReviewState == "" {
o.ReviewState = reviewStateFor(o)
}
o.ExternalID = externalID
_, err = s.opportunities.InsertOne(ctx, o)
@ -85,6 +98,97 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
return o, nil
}
func (s *MonStore) MergeProductMatch(ctx context.Context, ownerUID int64, opportunityID string, match *domain.ProductMatch) (*domain.Opportunity, error) {
if ownerUID <= 0 || match == nil {
return nil, domain.ErrValidation
}
if err := match.ValidateForWrite(); err != nil {
return nil, err
}
var existing domain.Opportunity
if err := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": opportunityID, "owner_uid": ownerUID}); err == mon.ErrNotFound {
return nil, domain.ErrNotFound
} else if err != nil {
return nil, err
}
// A guarded insert means concurrent workers cannot both append the same product.
res, err := s.opportunities.UpdateOne(ctx,
bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$not": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}}},
bson.M{"$push": bson.M{"product_matches": match}, "$set": bson.M{"updated_at": domain.NowNano()}})
if err != nil {
return nil, err
}
if res.MatchedCount == 0 {
_, err = s.opportunities.UpdateOne(ctx,
bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}},
bson.M{"$addToSet": bson.M{"product_matches.$.watch_ids": bson.M{"$each": match.WatchIDs}, "product_matches.$.matched_terms": bson.M{"$each": match.MatchedTerms}}, "$set": bson.M{"updated_at": domain.NowNano()}})
if err != nil {
return nil, err
}
}
if err := s.autoArbitratePrimary(ctx, ownerUID, opportunityID); err != nil {
return nil, err
}
return s.GetOpportunity(ctx, opportunityID)
}
func (s *MonStore) autoArbitratePrimary(ctx context.Context, ownerUID int64, opportunityID string) error {
o, err := s.GetOpportunity(ctx, opportunityID)
if err != nil || o.PrimaryProductOverridden {
return err
}
before := o.PrimaryProductID
domain.ApplyPrimaryProduct(o)
if o.PrimaryProductID == "" || o.PrimaryProductID == before && o.IntentScore == 0 {
return nil
}
set := bson.M{
"primary_brand_id": o.PrimaryBrandID, "primary_product_id": o.PrimaryProductID,
"primary_brand_name": o.PrimaryBrandName, "primary_product_label": o.PrimaryProductLabel,
"primary_product_fit_score": o.PrimaryProductFitScore, "primary_product_fit_band": o.PrimaryProductFitBand,
"intent_score": o.IntentScore, "intent_band": o.IntentBand, "reasons": o.Reasons,
"updated_at": domain.NowNano(),
}
_, err = s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "primary_product_overridden": bson.M{"$ne": true}}, bson.M{"$set": set})
return err
}
func (s *MonStore) SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID string, override *domain.ProductPrimaryOverride) (*domain.Opportunity, error) {
o, err := s.GetOpportunity(ctx, opportunityID)
if err != nil {
return nil, err
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
var match *domain.ProductMatch
for _, candidate := range o.ProductMatches {
if candidate.ProductID == productID {
match = candidate
break
}
}
if match == nil || ((!match.Eligible || match.Excluded) && (override == nil || strings.TrimSpace(override.Reason) == "")) {
return nil, domain.ErrValidation
}
set := bson.M{"primary_brand_id": match.BrandID, "primary_product_id": match.ProductID, "primary_brand_name": match.BrandNameSnapshot, "primary_product_label": match.ProductLabelSnapshot, "primary_product_fit_score": match.ProductFitScore, "primary_product_fit_band": match.ProductFitBand, "primary_product_overridden": override != nil, "updated_at": domain.NowNano()}
if override != nil {
if override.At == 0 {
override.At = domain.NowNano()
}
set["primary_product_override"] = override
}
o.PrimaryProductOverridden = override != nil
o.PrimaryProductID = productID
o.PrimaryProductOverride = override
domain.ApplyPrimaryProduct(o)
set["intent_score"], set["intent_band"], set["reasons"] = o.IntentScore, o.IntentBand, o.Reasons
if _, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches.product_id": productID}, bson.M{"$set": set}); err != nil {
return nil, err
}
return s.GetOpportunity(ctx, opportunityID)
}
func (s *MonStore) GetOpportunity(ctx context.Context, id string) (*domain.Opportunity, error) {
var o domain.Opportunity
err := s.opportunities.FindOne(ctx, &o, bson.M{"_id": id})
@ -125,6 +229,36 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
if f.WatchID != "" {
q["watch_id"] = f.WatchID
}
if f.BrandID != "" {
q["product_matches.brand_id"] = f.BrandID
}
if f.ProductID != "" {
q["product_matches.product_id"] = f.ProductID
}
if f.FitBand != "" {
q["product_matches.product_fit_band"] = f.FitBand
}
if f.PriorityBand != "" {
q["priority_band"] = f.PriorityBand
}
if f.ReviewState != "" {
q["$or"] = bson.A{
bson.M{"review_state": f.ReviewState},
bson.M{"review_state": bson.M{"$exists": false}, "status": map[string]string{domain.ReviewCompleted: domain.OppAccepted, domain.ReviewRemoved: domain.OppDismissed}[f.ReviewState]},
}
}
switch f.MatchState {
case "eligible":
q["product_matches"] = bson.M{"$elemMatch": bson.M{"eligible": true, "excluded": false}}
case "weak":
q["product_matches.product_fit_band"] = domain.ProductFitBandWeak
case "excluded":
q["product_matches.excluded"] = true
case "generic":
q["$or"] = bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}
case "stale":
q["posted_at"] = bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}
}
if f.CreatedFrom > 0 || f.CreatedTo > 0 {
rng := bson.M{}
if f.CreatedFrom > 0 {
@ -135,6 +269,16 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
}
q["created_at"] = rng
}
if f.PostedFrom > 0 || f.PostedTo > 0 {
rng := bson.M{}
if f.PostedFrom > 0 {
rng["$gte"] = f.PostedFrom
}
if f.PostedTo > 0 {
rng["$lt"] = f.PostedTo
}
q["posted_at"] = rng
}
total, err := s.opportunities.CountDocuments(ctx, q)
if err != nil {
@ -148,13 +292,79 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
ps = 20
}
var list []*domain.Opportunity
sortSpec := bson.D{{Key: "intent_score", Value: -1}, {Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
if f.Sort == "posted" || f.Sort == "newest" {
sortSpec = bson.D{{Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
} else if f.Sort == "oldest" {
sortSpec = bson.D{{Key: "posted_at", Value: 1}, {Key: "_id", Value: 1}}
} else if f.Sort == "product_fit" {
sortSpec = bson.D{{Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
} else if f.Sort == "demand_intent" {
sortSpec = bson.D{{Key: "intent_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
} else if f.Sort == "priority" || f.Sort == "priority_score" {
sortSpec = bson.D{{Key: "priority_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}}
}
err = s.opportunities.Find(ctx, &list, q, options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetSort(sortSpec).
SetSkip(int64((page-1)*ps)).
SetLimit(int64(ps)))
return list, total, err
}
func (s *MonStore) UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, opportunityID string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) {
if ownerUID <= 0 || !domain.IsReviewState(patch.State) {
return nil, domain.ErrValidation
}
o, err := s.GetOpportunity(ctx, opportunityID)
if err != nil {
return nil, err
}
if o.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
current := reviewStateFor(o)
set := bson.M{"review_state": patch.State, "updated_at": domain.NowNano()}
if patch.State == domain.ReviewRemoved {
if !domain.IsRemovalReason(patch.RemovalReason) || patch.RemovalReason == domain.RemovalOther && strings.TrimSpace(patch.RemovalNote) == "" {
return nil, domain.ErrValidation
}
if patch.RemovalReason == domain.RemovalDuplicate {
if patch.DuplicateOpportunityID == "" || patch.DuplicateOpportunityID == opportunityID {
return nil, domain.ErrValidation
}
var target domain.Opportunity
if err := s.opportunities.FindOne(ctx, &target, bson.M{"_id": patch.DuplicateOpportunityID, "owner_uid": ownerUID, "author_handle": o.AuthorHandle}); err != nil {
return nil, domain.ErrValidation
}
}
set["previous_review_state"] = current
set["removal_reason"] = patch.RemovalReason
set["removal_note"] = strings.TrimSpace(patch.RemovalNote)
set["removed_at"] = domain.NowNano()
set["removed_by"] = ownerUID
} else {
set["removal_reason"] = ""
set["removal_note"] = ""
set["removed_at"] = int64(0)
set["removed_by"] = int64(0)
}
res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID}, bson.M{"$set": set})
if err != nil {
return nil, err
}
if res.MatchedCount == 0 {
return nil, domain.ErrNotFound
}
event := &domain.ReviewEvent{OwnerUID: ownerUID, OpportunityID: opportunityID, From: current, To: patch.State, Reason: patch.RemovalReason, Note: patch.RemovalNote, ActorUID: ownerUID, At: domain.NowNano()}
if err := event.Normalize(); err != nil {
return nil, err
}
if _, err := s.reviewEvents.InsertOne(ctx, event); err != nil {
return nil, err
}
return s.GetOpportunity(ctx, opportunityID)
}
func (s *MonStore) CountToday(ctx context.Context, ownerUID int64, at int64) (int64, error) {
start, end := domain.UTCDayBounds(at)
return s.opportunities.CountDocuments(ctx, bson.M{

View File

@ -0,0 +1,94 @@
package repository
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// Keep the migration contract executable without connecting to a Mongo instance.
// The legacy owner+external identity must remain the only Opportunity identity;
// ProductMatch indexes are query helpers, never a replacement for that key.
func TestProductRadarIndexesPreserveOpportunityIdentity(t *testing.T) {
var groups []struct {
CreateIndexes string `json:"createIndexes"`
Indexes []struct {
Key map[string]int `json:"key"`
Name string `json:"name"`
Unique bool `json:"unique"`
} `json:"indexes"`
}
base := filepath.Join("..", "..", "..", "..", "generate", "database", "mongo")
read := func(name string) {
b, err := os.ReadFile(filepath.Join(base, name))
if err != nil {
t.Fatal(err)
}
var next []struct {
CreateIndexes string `json:"createIndexes"`
Indexes []struct {
Key map[string]int `json:"key"`
Name string `json:"name"`
Unique bool `json:"unique"`
} `json:"indexes"`
}
if err := json.Unmarshal(b, &next); err != nil {
t.Fatal(err)
}
groups = append(groups, next...)
}
read("000014_demand_radar_indexes.up.json")
read("000015_brand_product_radar_indexes.up.json")
var foundProductMatch, foundLegacyUnique bool
for _, group := range groups {
for _, index := range group.Indexes {
if group.CreateIndexes == "radar_opportunities" && index.Unique {
if len(index.Key) != 2 || index.Key["owner_uid"] != 1 || index.Key["external_id"] != 1 {
t.Fatalf("legacy Opportunity unique key changed: %#v", index.Key)
}
if _, hasProduct := index.Key["product_id"]; hasProduct {
t.Fatal("legacy Opportunity unique key must not include product_id")
}
foundLegacyUnique = true
}
if index.Name == "owner_opportunities_product_match" {
foundProductMatch = true
}
}
}
if !foundLegacyUnique {
t.Fatal("legacy Opportunity unique index not represented")
}
if !foundProductMatch {
t.Fatal("ProductMatch query index not represented")
}
}
func TestOpportunityInboxReviewIndexes(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "generate", "database", "mongo", "000016_opportunity_inbox_review_indexes.up.json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var groups []struct {
CreateIndexes string `json:"createIndexes"`
Indexes []struct {
Name string `json:"name"`
} `json:"indexes"`
}
if err := json.Unmarshal(b, &groups); err != nil {
t.Fatal(err)
}
foundReview := map[string]bool{}
for _, group := range groups {
for _, index := range group.Indexes {
foundReview[index.Name] = group.CreateIndexes == "radar_opportunities" || group.CreateIndexes == "radar_opportunity_review_events"
}
}
for _, name := range []string{"owner_opportunities_review_posted", "owner_opportunities_tombstone", "owner_opportunity_review_events"} {
if !foundReview[name] {
t.Fatalf("missing review index %s", name)
}
}
}

View File

@ -0,0 +1,55 @@
package repository
import (
"context"
"fmt"
"sync"
"testing"
"apps/backend/internal/module/radar/domain"
)
func raceProductMatch(id string, score int, n int) *domain.ProductMatch {
m := &domain.ProductMatch{BrandID: "b", ProductID: id, ProductFitScore: score, ProductFitBand: domain.ProductFitBandFromScore(score), MatchedAt: int64(n), WatchIDs: []string{fmt.Sprintf("w-%d", n)}, MatchedTerms: []string{fmt.Sprintf("term-%d", n)}, Reasons: []domain.ProductFitReason{
{Dimension: domain.ProductFitPain, Score: 35, Reason: "pain", CandidateExcerpt: "痛點", ProductBasis: "痛點"},
{Dimension: domain.ProductFitScenario, Score: 25, Reason: "scenario", CandidateExcerpt: "情境", ProductBasis: "情境"},
{Dimension: domain.ProductFitAudience, Score: 0, Reason: "unknown"},
{Dimension: domain.ProductFitCapability, Score: score - 60, Reason: "capability", CandidateExcerpt: "能力", ProductBasis: "能力"},
}}
return m
}
func TestConcurrentProductMatchMergeNoDuplicateAndOverrideWins(t *testing.T) {
ctx := context.Background()
mem := NewMemory()
op, err := mem.UpsertByExternalID(ctx, &domain.Opportunity{OwnerUID: 1, ExternalID: "e-race", Source: domain.OppSourceThreads, Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh, RegionMatch: domain.RegionUnknown, Reasons: []domain.OpportunityReason{{Dimension: domain.DimAuthenticity, Reason: "a"}, {Dimension: domain.DimIntent, Reason: "i"}, {Dimension: domain.DimRegion, Reason: "r"}, {Dimension: domain.DimFreshness, Reason: "f"}, {Dimension: domain.DimFit, Reason: "fit"}}})
if err != nil {
t.Fatal(err)
}
if _, err := mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p1", 60, 1)); err != nil {
t.Fatal(err)
}
if _, err := mem.SetPrimaryProduct(ctx, 1, op.ID, "p1", &domain.ProductPrimaryOverride{ActorUID: 1, Reason: "人工確認"}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(2)
go func(n int) {
defer wg.Done()
_, _ = mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p1", 100, n+10))
}(i)
go func(n int) {
defer wg.Done()
_, _ = mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p2", 80, n+30))
}(i)
}
wg.Wait()
got, err := mem.GetOpportunity(ctx, op.ID)
if err != nil {
t.Fatal(err)
}
if len(got.ProductMatches) != 2 || got.PrimaryProductID != "p1" || !got.PrimaryProductOverridden {
t.Fatalf("race duplicate or override loss: primary=%s overridden=%v matches=%d", got.PrimaryProductID, got.PrimaryProductOverridden, len(got.ProductMatches))
}
}

View File

@ -0,0 +1,95 @@
package repository
import (
"context"
"sync"
"testing"
"apps/backend/internal/module/radar/domain"
)
func validProductMatch(id string, score int) *domain.ProductMatch {
m := &domain.ProductMatch{BrandID: "b1", ProductID: id, ProductFitScore: score, Reasons: []domain.ProductFitReason{
{Dimension: domain.ProductFitPain, Score: min(score, 35), Reason: "pain", CandidateExcerpt: "漏水", ProductBasis: "漏水"},
{Dimension: domain.ProductFitScenario, Score: min(max(score-35, 0), 25), Reason: "scenario", CandidateExcerpt: "找人", ProductBasis: "居家"},
{Dimension: domain.ProductFitAudience, Score: min(max(score-60, 0), 20), Reason: "audience", CandidateExcerpt: "家裡", ProductBasis: "家庭"},
{Dimension: domain.ProductFitCapability, Score: min(max(score-80, 0), 20), Reason: "capability", CandidateExcerpt: "抓漏", ProductBasis: "抓漏"},
}}
return m
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func TestMemoryProductMatchMergeIsAtomicAndKeepsSnapshot(t *testing.T) {
ctx := context.Background()
m := NewMemory()
o := sampleOpp("product-ext", []string{"找抓漏"}, 80)
created, err := m.UpsertByExternalID(ctx, o)
if err != nil {
t.Fatal(err)
}
base := validProductMatch("p1", 80)
base.ProductLabelSnapshot = "舊名稱"
base.WatchIDs = []string{"w1"}
base.MatchedTerms = []string{"漏水"}
if _, err := m.MergeProductMatch(ctx, 42, created.ID, base); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
next := validProductMatch("p1", 20)
next.ProductLabelSnapshot = "新名稱"
next.WatchIDs = []string{"w2"}
_, _ = m.MergeProductMatch(ctx, 42, created.ID, next)
}(i)
}
wg.Wait()
got, err := m.GetOpportunity(ctx, created.ID)
if err != nil {
t.Fatal(err)
}
if len(got.ProductMatches) != 1 || got.ProductMatches[0].ProductLabelSnapshot != "舊名稱" {
t.Fatalf("duplicate or snapshot rewrite: %#v", got.ProductMatches)
}
if len(got.ProductMatches[0].WatchIDs) != 2 {
t.Fatalf("watch sources not merged: %#v", got.ProductMatches[0].WatchIDs)
}
}
func TestMemoryPrimaryOverrideBlocksAutoArbitration(t *testing.T) {
ctx := context.Background()
m := NewMemory()
created, err := m.UpsertByExternalID(ctx, sampleOpp("primary-ext", []string{"x"}, 80))
if err != nil {
t.Fatal(err)
}
low := validProductMatch("p1", 60)
high := validProductMatch("p2", 90)
if _, err := m.MergeProductMatch(ctx, 42, created.ID, low); err != nil {
t.Fatal(err)
}
if _, err := m.SetPrimaryProduct(ctx, 42, created.ID, "p1", &domain.ProductPrimaryOverride{ActorUID: 42, Reason: "人工指定"}); err != nil {
t.Fatal(err)
}
got, err := m.MergeProductMatch(ctx, 42, created.ID, high)
if err != nil {
t.Fatal(err)
}
if got.PrimaryProductID != "p1" || !got.PrimaryProductOverridden {
t.Fatalf("override overwritten: %#v", got)
}
}

View File

@ -12,25 +12,29 @@ import (
type Memory struct {
mu sync.Mutex
profiles map[int64]*domain.ServiceProfile
demandMaps map[string]*domain.DemandMap
watches map[string]*domain.RadarWatch
opportunities map[string]*domain.Opportunity
// ownerExternal indexes "ownerUID\0externalID" → opportunity id for O(1) upsert.
ownerExternal map[string]string
sweeps map[string]*domain.RadarSweep
// jobToSweep indexes job_id → sweep id.
jobToSweep map[string]string
replies map[string]*domain.ReplyVariant
jobToSweep map[string]string
replies map[string]*domain.ReplyVariant
reviewEvents map[string][]domain.ReviewEvent
}
func NewMemory() *Memory {
return &Memory{
profiles: map[int64]*domain.ServiceProfile{},
demandMaps: map[string]*domain.DemandMap{},
watches: map[string]*domain.RadarWatch{},
opportunities: map[string]*domain.Opportunity{},
ownerExternal: map[string]string{},
sweeps: map[string]*domain.RadarSweep{},
jobToSweep: map[string]string{},
replies: map[string]*domain.ReplyVariant{},
reviewEvents: map[string][]domain.ReviewEvent{},
}
}

View File

@ -13,8 +13,10 @@ import (
type MonStore struct {
profiles *mon.Model
demandMaps *mon.Model
watches *mon.Model
opportunities *mon.Model
reviewEvents *mon.Model
sweeps *mon.Model
replies *mon.Model
}
@ -23,8 +25,10 @@ func NewMonStore(uri, database string) *MonStore {
uri = libmongo.MustMongoURI(uri)
return &MonStore{
profiles: mon.MustNewModel(uri, database, "radar_service_profiles"),
demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"),
watches: mon.MustNewModel(uri, database, "radar_watches"),
opportunities: mon.MustNewModel(uri, database, "radar_opportunities"),
reviewEvents: mon.MustNewModel(uri, database, "radar_opportunity_review_events"),
sweeps: mon.MustNewModel(uri, database, "radar_sweeps"),
replies: mon.MustNewModel(uri, database, "radar_replies"),
}

View File

@ -43,7 +43,21 @@ func (m *Memory) UpdateSweep(_ context.Context, id string, delta domain.SweepDel
s.JudgedCount += delta.JudgedCount
s.CreatedCount += delta.CreatedCount
s.TruncatedCount += delta.TruncatedCount
s.MatchEvaluatedCount += delta.MatchEvaluatedCount
s.MatchMergedCount += delta.MatchMergedCount
s.FitRejectedCount += delta.FitRejectedCount
s.CreditsUsed += delta.CreditsUsed
s.DedupedCount += delta.DedupedCount
s.PrefilterPassCount += delta.PrefilterPassCount
s.PrefilterReviewCount += delta.PrefilterReviewCount
s.PrefilterRejectedCount += delta.PrefilterRejectedCount
s.CachedJudgmentCount += delta.CachedJudgmentCount
s.TombstoneMatchedCount += delta.TombstoneMatchedCount
s.BudgetDeferredCount += delta.BudgetDeferredCount
s.CreditSearch += delta.CreditSearch
s.CreditDemandMap += delta.CreditDemandMap
s.CreditJudge += delta.CreditJudge
s.CreditReply += delta.CreditReply
if len(delta.JudgedExternalIDs) > 0 {
s.JudgedExternalIDs = domain.MergeJudgedExternalIDs(s.JudgedExternalIDs, delta.JudgedExternalIDs)
}

View File

@ -42,9 +42,51 @@ func (s *MonStore) UpdateSweep(ctx context.Context, id string, delta domain.Swee
if delta.TruncatedCount != 0 {
inc["truncated_count"] = delta.TruncatedCount
}
if delta.MatchEvaluatedCount != 0 {
inc["match_evaluated_count"] = delta.MatchEvaluatedCount
}
if delta.MatchMergedCount != 0 {
inc["match_merged_count"] = delta.MatchMergedCount
}
if delta.FitRejectedCount != 0 {
inc["fit_rejected_count"] = delta.FitRejectedCount
}
if delta.CreditsUsed != 0 {
inc["credits_used"] = delta.CreditsUsed
}
if delta.DedupedCount != 0 {
inc["deduped_count"] = delta.DedupedCount
}
if delta.PrefilterPassCount != 0 {
inc["prefilter_pass_count"] = delta.PrefilterPassCount
}
if delta.PrefilterReviewCount != 0 {
inc["prefilter_review_count"] = delta.PrefilterReviewCount
}
if delta.PrefilterRejectedCount != 0 {
inc["prefilter_rejected_count"] = delta.PrefilterRejectedCount
}
if delta.CachedJudgmentCount != 0 {
inc["cached_judgment_count"] = delta.CachedJudgmentCount
}
if delta.TombstoneMatchedCount != 0 {
inc["tombstone_matched_count"] = delta.TombstoneMatchedCount
}
if delta.BudgetDeferredCount != 0 {
inc["budget_deferred_count"] = delta.BudgetDeferredCount
}
if delta.CreditSearch != 0 {
inc["credit_search"] = delta.CreditSearch
}
if delta.CreditDemandMap != 0 {
inc["credit_demand_map"] = delta.CreditDemandMap
}
if delta.CreditJudge != 0 {
inc["credit_judge"] = delta.CreditJudge
}
if delta.CreditReply != 0 {
inc["credit_reply"] = delta.CreditReply
}
update := bson.M{}
if len(inc) > 0 {

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"testing"
"apps/backend/internal/module/radar/domain"
)
func TestMemoryWatchContextFiltersAndCopy(t *testing.T) {
m := NewMemory()
legacy := &domain.RadarWatch{ID: "legacy", OwnerUID: 1, Terms: []string{"找水電"}, Status: domain.WatchActive, CreatedAt: 1, UpdatedAt: 1}
product := &domain.RadarWatch{ID: "product", OwnerUID: 1, Terms: []string{"找抓漏"}, Status: domain.WatchActive, ContextMode: domain.WatchContextProduct, BrandID: "b1", ProductID: "p1", CreatedAt: 2, UpdatedAt: 2}
if err := m.SaveWatch(context.Background(), legacy); err != nil {
t.Fatal(err)
}
if err := m.SaveWatch(context.Background(), product); err != nil {
t.Fatal(err)
}
rows, total, err := m.ListWatches(context.Background(), 1, domain.WatchListFilter{ContextMode: domain.WatchContextGeneric})
if err != nil || total != 1 || len(rows) != 1 || rows[0].ContextMode != domain.WatchContextGeneric {
t.Fatalf("legacy filter: total=%d rows=%#v err=%v", total, rows, err)
}
rows, total, err = m.ListWatches(context.Background(), 1, domain.WatchListFilter{BrandID: "b1", ProductID: "p1"})
if err != nil || total != 1 || len(rows) != 1 {
t.Fatalf("product filter: total=%d rows=%#v err=%v", total, rows, err)
}
rows[0].Terms[0] = "mutated"
got, err := m.GetWatch(context.Background(), "product")
if err != nil || got.Terms[0] == "mutated" {
t.Fatalf("repository leaked mutable slice: %#v %v", got, err)
}
}

View File

@ -10,7 +10,16 @@ import (
func (m *Memory) SaveWatch(_ context.Context, w *domain.RadarWatch) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *w
if w == nil {
return domain.ErrValidation
}
if err := w.NormalizeContext(); err != nil {
return err
}
if old, ok := m.watches[w.ID]; ok && old.ContextMode == domain.WatchContextProduct && (old.BrandID != w.BrandID || old.ProductID != w.ProductID) {
return domain.ErrValidation
}
cp := cloneWatch(w)
m.watches[w.ID] = &cp
return nil
}
@ -22,10 +31,23 @@ func (m *Memory) GetWatch(_ context.Context, id string) (*domain.RadarWatch, err
if !ok {
return nil, domain.ErrNotFound
}
cp := *w
cp := cloneWatch(w)
if err := cp.NormalizeContext(); err != nil {
return nil, err
}
return &cp, nil
}
func (m *Memory) DeleteWatch(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.watches[id]; !ok {
return domain.ErrNotFound
}
delete(m.watches, id)
return nil
}
func (m *Memory) ListWatches(_ context.Context, ownerUID int64, f domain.WatchListFilter) ([]*domain.RadarWatch, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
@ -38,7 +60,19 @@ func (m *Memory) ListWatches(_ context.Context, ownerUID int64, f domain.WatchLi
if f.Status != "" && w.Status != f.Status {
continue
}
cp := *w
cp := cloneWatch(w)
if err := cp.NormalizeContext(); err != nil {
return nil, 0, err
}
if f.ContextMode != "" && cp.ContextMode != f.ContextMode {
continue
}
if f.BrandID != "" && cp.BrandID != f.BrandID {
continue
}
if f.ProductID != "" && cp.ProductID != f.ProductID {
continue
}
matched = append(matched, &cp)
}
sort.Slice(matched, func(i, j int) bool {
@ -73,7 +107,10 @@ func (m *Memory) ListActiveWatches(_ context.Context, ownerUID int64) ([]*domain
out := make([]*domain.RadarWatch, 0, len(m.watches))
for _, w := range m.watches {
if w.OwnerUID == ownerUID && w.Status == domain.WatchActive {
cp := *w
cp := cloneWatch(w)
if err := cp.NormalizeContext(); err != nil {
return nil, err
}
out = append(out, &cp)
}
}
@ -92,7 +129,10 @@ func (m *Memory) ListAllActiveWatches(_ context.Context) ([]*domain.RadarWatch,
out := make([]*domain.RadarWatch, 0, len(m.watches))
for _, w := range m.watches {
if w.Status == domain.WatchActive {
cp := *w
cp := cloneWatch(w)
if err := cp.NormalizeContext(); err != nil {
return nil, err
}
out = append(out, &cp)
}
}
@ -108,6 +148,14 @@ func (m *Memory) ListAllActiveWatches(_ context.Context) ([]*domain.RadarWatch,
return out, nil
}
func cloneWatch(w *domain.RadarWatch) domain.RadarWatch {
cp := *w
cp.Terms = append([]string(nil), w.Terms...)
cp.ExcludeTerms = append([]string(nil), w.ExcludeTerms...)
cp.Regions = append([]string(nil), w.Regions...)
return cp
}
func (m *Memory) CountActiveWatches(_ context.Context, ownerUID int64) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -11,6 +11,20 @@ import (
)
func (s *MonStore) SaveWatch(ctx context.Context, w *domain.RadarWatch) error {
if w == nil {
return domain.ErrValidation
}
if err := w.NormalizeContext(); err != nil {
return err
}
var old domain.RadarWatch
if err := s.watches.FindOne(ctx, &old, bson.M{"_id": w.ID}); err == nil {
if old.ContextMode == domain.WatchContextProduct && (old.BrandID != w.BrandID || old.ProductID != w.ProductID) {
return domain.ErrValidation
}
} else if err != mon.ErrNotFound {
return err
}
_, err := s.watches.ReplaceOne(ctx, bson.M{"_id": w.ID}, w, options.Replace().SetUpsert(true))
return err
}
@ -27,11 +41,33 @@ func (s *MonStore) GetWatch(ctx context.Context, id string) (*domain.RadarWatch,
return &w, nil
}
func (s *MonStore) DeleteWatch(ctx context.Context, id string) error {
res, err := s.watches.DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
return err
}
if res == 0 {
return domain.ErrNotFound
}
return nil
}
func (s *MonStore) ListWatches(ctx context.Context, ownerUID int64, f domain.WatchListFilter) ([]*domain.RadarWatch, int64, error) {
q := bson.M{"owner_uid": ownerUID}
if f.Status != "" {
q["status"] = f.Status
}
if f.ContextMode == domain.WatchContextGeneric {
q["$or"] = bson.A{bson.M{"context_mode": domain.WatchContextGeneric}, bson.M{"context_mode": bson.M{"$exists": false}}, bson.M{"context_mode": ""}}
} else if f.ContextMode != "" {
q["context_mode"] = f.ContextMode
}
if f.BrandID != "" {
q["brand_id"] = f.BrandID
}
if f.ProductID != "" {
q["product_id"] = f.ProductID
}
total, err := s.watches.CountDocuments(ctx, q)
if err != nil {
return nil, 0, err
@ -48,6 +84,11 @@ func (s *MonStore) ListWatches(ctx context.Context, ownerUID int64, f domain.Wat
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetSkip(int64((page-1)*ps)).
SetLimit(int64(ps)))
for _, w := range list {
if w != nil && w.ContextMode == "" {
w.ContextMode = domain.WatchContextGeneric
}
}
return list, total, err
}

View File

@ -0,0 +1,55 @@
package usecase
import (
"encoding/json"
"os"
"strings"
"testing"
)
type accuracyCase struct {
ID string `json:"id"`
Text string `json:"text"`
ProductTerms []string `json:"product_terms"`
ExpectedDemand bool `json:"expected_demand"`
ExpectedProductEvidence bool `json:"expected_product_evidence"`
ExpectedExclude bool `json:"expected_exclude"`
ExpectedStale bool `json:"expected_stale"`
ExpectedUnknownTime bool `json:"expected_unknown_time"`
}
func loadAccuracyCases(t *testing.T) []accuracyCase {
t.Helper()
raw, err := os.ReadFile("testdata/opportunity_inbox_accuracy.json")
if err != nil {
t.Fatal(err)
}
var cases []accuracyCase
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
return cases
}
func TestAccuracyFixtureSchema(t *testing.T) {
cases := loadAccuracyCases(t)
if len(cases) < 10 {
t.Fatalf("fixture needs at least 10 cases, got %d", len(cases))
}
seen := map[string]bool{}
for _, item := range cases {
if item.ID == "" || seen[item.ID] || strings.TrimSpace(item.Text) == "" {
t.Fatalf("invalid or duplicate case: %+v", item)
}
seen[item.ID] = true
if item.ExpectedDemand && len(item.ProductTerms) == 0 {
t.Fatalf("demand case lacks product evidence: %s", item.ID)
}
if item.ExpectedExclude && item.ExpectedDemand {
t.Fatalf("exclude case cannot be demand: %s", item.ID)
}
if strings.Contains(strings.ToLower(item.Text), "bearer ") || strings.Contains(item.Text, "sk-") {
t.Fatalf("fixture contains token-like text: %s", item.ID)
}
}
}

View File

@ -0,0 +1,116 @@
package usecase
import (
"net/url"
"strings"
"unicode/utf8"
"apps/backend/internal/module/radar/domain"
)
const (
PrefilterPass = "pass"
PrefilterReview = "review"
PrefilterRejected = "rejected"
)
type PrefilterStats struct {
Deduped, Pass, Review, Rejected int
}
// NormalizeCandidate removes transport noise before any score or meter is
// touched. It also establishes one stable external identity for a URL hit.
func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost {
if c == nil {
return nil
}
out := *c
out.ExternalID = canonicalCandidateURL(out.ExternalID)
out.Permalink = canonicalCandidateURL(out.Permalink)
if out.ExternalID == "" {
out.ExternalID = out.Permalink
}
if out.Permalink == "" {
out.Permalink = out.ExternalID
}
out.Text = strings.TrimSpace(strings.Join(strings.Fields(out.Text), " "))
out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " "))
out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@")
out.Classification = strings.ToLower(strings.TrimSpace(out.Classification))
if out.Classification == "" {
out.Classification = classifyCandidate(strings.ToLower(out.Text + " " + out.Title))
}
return &out
}
func canonicalCandidateURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return raw
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Fragment = ""
u.RawQuery = ""
return strings.TrimRight(u.String(), "/")
}
// PrefilterCandidates is cheap, deterministic and deliberately conservative:
// only obvious provider/noise/announcement posts are rejected. A weak demand
// signal is retained as review so the judge can still rescue an unusual but
// relevant user wording.
func PrefilterCandidates(in []*domain.CandidatePost, watch *domain.RadarWatch, product *ProductContextSnapshot) ([]*domain.CandidatePost, PrefilterStats) {
terms := []string{}
if watch != nil {
terms = append(terms, watch.Terms...)
}
if product != nil {
terms = append(terms, product.PainPoints...)
terms = append(terms, product.ProductContext)
terms = append(terms, product.MatchTags...)
terms = append(terms, product.ProviderCapabilityTerms...)
}
seen := map[string]bool{}
out := make([]*domain.CandidatePost, 0, len(in))
stats := PrefilterStats{}
for _, raw := range in {
c := NormalizeCandidate(raw)
if c == nil || c.ExternalID == "" || c.Permalink == "" || utf8.RuneCountInString(c.Text) < 2 {
stats.Rejected++
continue
}
if seen[c.ExternalID] {
stats.Deduped++
continue
}
seen[c.ExternalID] = true
if c.Classification == "provider_offer" || c.Classification == "announcement" || c.Classification == "noise" {
stats.Rejected++
// Keep hard-reject candidates in the stream so the existing judge
// path can persist an auditable rejected opportunity without an AI
// call. This preserves the inbox's explanation surface.
out = append(out, c)
continue
}
blob := strings.ToLower(c.Text + " " + c.Title)
matched := false
for _, term := range terms {
term = strings.ToLower(strings.TrimSpace(term))
if term != "" && strings.Contains(blob, term) {
matched = true
break
}
}
if matched || c.Classification == "seeking_help" || c.Classification == "seeking_recommendation" {
stats.Pass++
} else {
stats.Review++
}
out = append(out, c)
}
return out, stats
}

View File

@ -0,0 +1,29 @@
package usecase
import (
"testing"
"apps/backend/internal/module/radar/domain"
)
func TestPrefilterNormalizesDedupesAndKeepsWeakDemandForReview(t *testing.T) {
items, stats := PrefilterCandidates([]*domain.CandidatePost{
{Permalink: "https://Threads.net/@a/post/1?utm_source=x", Text: "泛紅換季怎麼辦"},
{Permalink: "https://threads.net/@a/post/1", Text: "重複"},
{Permalink: "https://threads.net/@b/post/2", Text: "今天天氣很好"},
{Permalink: "https://threads.net/@c/post/3", Text: "限時優惠立即購買"},
}, &domain.RadarWatch{Terms: []string{"泛紅"}}, nil)
if len(items) != 3 || stats.Deduped != 1 || stats.Rejected != 1 || stats.Pass != 1 || stats.Review != 1 {
t.Fatalf("unexpected prefilter result: items=%d stats=%+v", len(items), stats)
}
if items[0].ExternalID != "https://threads.net/@a/post/1" {
t.Fatalf("url was not canonicalized: %q", items[0].ExternalID)
}
}
func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) {
items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}})
if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 {
t.Fatalf("product name should remain review, got items=%d stats=%+v", len(items), stats)
}
}

View File

@ -0,0 +1,74 @@
package usecase
import (
"context"
"fmt"
"strings"
"time"
"apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain"
)
// GetCostPreview is deliberately read-only: it resolves the likely key mode
// and reads the current balance, but never calls PrepareCall or reserves a
// credit. The actual operation remains the only place that meters usage.
func (s *Service) GetCostPreview(ctx context.Context, ownerUID int64, action, watchID, productID string, candidateLimit int) (*domain.CostPreview, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
action = strings.ToLower(strings.TrimSpace(action))
if action != "sweep" && action != "explore" && action != "demand_map_enrich" && action != "reply" {
return nil, fmt.Errorf("%w: unknown cost preview action %q", domain.ErrValidation, action)
}
if candidateLimit <= 0 {
candidateLimit = 20
}
if candidateLimit > 100 {
candidateLimit = 100
}
searchCalls := 0
if action == "sweep" || action == "explore" {
searchCalls = 1
if productID != "" {
if plan, err := s.BuildProductQueryPlan(ctx, ownerUID, productID); err == nil && len(plan.Groups) > 0 {
searchCalls = len(plan.Groups)
}
}
}
maxAI := 0
fixed := 0
min := searchCalls * usageDomain.MeterCost(usageDomain.MeterWebSearch)
max := min
switch action {
case "sweep", "explore":
maxAI = candidateLimit
max += maxAI * usageDomain.MeterCost(usageDomain.MeterAIResearch)
case "demand_map_enrich", "reply":
fixed = usageDomain.MeterCost(usageDomain.MeterAICopy)
min, max = fixed, fixed
}
mode := usageDomain.KeyModePlatform
meter := usageDomain.MeterWebSearch
if action == "demand_map_enrich" || action == "reply" {
meter = usageDomain.MeterAICopy
}
if s.ResolveKey != nil {
if resolved, _, err := s.ResolveKey(ctx, ownerUID, meter); err == nil && resolved != "" {
mode = resolved
}
}
remaining := 0
if s.Usage != nil {
if summary, err := s.Usage.GetSummary(ctx, ownerUID, ""); err == nil && summary != nil {
remaining = summary.Platform.CreditsRemaining
}
}
return &domain.CostPreview{
PreviewID: domain.NewID(), Action: action, KeyMode: mode,
FixedCredits: fixed, MinCredits: min, MaxCredits: max,
SearchCalls: searchCalls, MaxAICandidates: maxAI, RemainingCredits: remaining,
EstimateBasis: "免費前處理provider 成功後依 web_searchai_researchai_copy meter 計點",
ExpiresAt: time.Now().UTC().Add(5 * time.Minute).UnixNano(),
}, nil
}

Some files were not shown because too many files have changed in this diff Show More