fix all bug
This commit is contained in:
parent
85d2ad37f6
commit
806cd51333
|
|
@ -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")},
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 > `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
# Call Home 會議重點與 Action Items
|
||||
|
||||
**日期:** 2026/07/28
|
||||
|
||||
## 會議重點
|
||||
|
||||
### 1. 整合方向
|
||||
|
||||
Call Home 可拆成三個部分:
|
||||
|
||||
1. **Trigger/State**:事件收集、分類、去重與 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 現有 Recipient/Receiver 設定,不另外開發獨立模組。
|
||||
|
||||
### 3. 目前主要缺口
|
||||
|
||||
- 尚未定義統一 Event Payload 與 Correlation Key。
|
||||
- SCC-FLEX 尚缺完整 Deduplication、Soft/Hard 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。
|
||||
- 初期先支援 Compute/Supermicro Hardware。
|
||||
- CDU、PDU、Liquid Cooling 等產品後續再評估。
|
||||
|
||||
## Action Items
|
||||
|
||||
| Owner | Action Item | Status |
|
||||
|---|---|---|
|
||||
| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
|
||||
| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
|
||||
| Daniel/PM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
|
||||
| PM/Service Team | 提供可自動開單的 Event 白名單、必要 Payload/附件及維護 Owner。 | 待確認 |
|
||||
| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
|
||||
| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、Soft/Hard State、Deduplication 與 Idempotency。 | 待排程 |
|
||||
| Daniel/IT/Service Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
|
||||
| PM/產品團隊 | 決定 Event 選擇方式及 Ticket List/Ask-to-Close 是否列入產品範圍。 | 待討論 |
|
||||
|
||||
## 下一步
|
||||
|
||||
下一次會議只需要確認四件事:
|
||||
|
||||
1. Service Team Event 白名單。
|
||||
2. SSM 可提供的資料與介面。
|
||||
3. Salesforce Ticket Lifecycle 與責任人。
|
||||
4. License 與產品範圍。
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 1–6 組 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{
|
||||
"dropIndexes": "radar_demand_maps",
|
||||
"indexNames": ["owner_product_demand_map", "owner_demand_input_version"]
|
||||
}
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[
|
||||
{ "dropIndexes": "crm_contacts", "index": "owner_contacts_active_stage_touch" }
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 支援 band/status/watch/日期區間。
|
||||
// Status 與 Statuses 擇一:Statuses 非空時用 $in;否則 Status 做單值比對。
|
||||
type OpportunityListFilter struct {
|
||||
Band string
|
||||
Status string
|
||||
Statuses []string // 多狀態;$in(今日頁:qualified/accepted/dismissed)
|
||||
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(今日頁:qualified/accepted/dismissed)
|
||||
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 80/50 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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_search/ai_research/ai_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
Loading…
Reference in New Issue