Compare commits

...

1 Commits

Author SHA1 Message Date
王性驊 031ba7769e add 首頁 2026-08-03 05:52:02 +00:00
272 changed files with 24285 additions and 610 deletions

View File

@ -21,6 +21,7 @@ test-integration:
./internal/module/usage/... ./internal/module/ai/... ./internal/module/search/... \ ./internal/module/usage/... ./internal/module/ai/... ./internal/module/search/... \
./internal/module/permission/... ./internal/module/studio/... \ ./internal/module/permission/... ./internal/module/studio/... \
./internal/module/inspire/... ./internal/module/scout/... \ ./internal/module/inspire/... ./internal/module/scout/... \
./internal/module/radar/... ./internal/logic/radar/ \
./internal/logic/extension/ ./internal/logic/media/ \ ./internal/logic/extension/ ./internal/logic/media/ \
-count=1 -timeout 180s -count=1 -timeout 180s

View File

@ -60,6 +60,10 @@ func ownerIndex(sortField, name string) mongo.IndexModel {
// These are deliberately all non-unique. Adding a uniqueness constraint to existing data can make // These are deliberately all non-unique. Adding a uniqueness constraint to existing data can make
// CreateMany fail and take a deploy down, so that belongs in its own migration with a duplicate // CreateMany fail and take a deploy down, so that belongs in its own migration with a duplicate
// pre-check rather than here. // pre-check rather than here.
//
// Never list an index here that a migration creates as unique: Mongo rejects a second createIndex
// with the same name and a different spec (IndexKeySpecsConflict, code 86), so the duplicate would
// make this whole command panic once the migration has run.
func indexModels() map[string][]mongo.IndexModel { func indexModels() map[string][]mongo.IndexModel {
return map[string][]mongo.IndexModel{ return map[string][]mongo.IndexModel{
// members.uid is read on every authenticated request via the auth middleware, and it is a // members.uid is read on every authenticated request via the auth middleware, and it is a
@ -154,6 +158,37 @@ func indexModels() map[string][]mongo.IndexModel {
"growth_ws_members": { "growth_ws_members": {
{Keys: bson.D{{Key: "workspace_id", Value: 1}, {Key: "uid", Value: 1}}, Options: options.Index().SetName("workspace_member")}, {Keys: bson.D{{Key: "workspace_id", Value: 1}, {Key: "uid", Value: 1}}, Options: options.Index().SetName("workspace_member")},
}, },
// demand-radar. Names and specs match migration 000014 exactly so both paths are
// idempotent; the two unique keys (radar_opportunities.owner_opportunity_external and
// crm_contacts.owner_contact_identity) are intentionally absent and owned by that migration.
"radar_watches": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_watches_status")},
ownerIndex("created_at", "owner_watches_created"),
},
"radar_sweeps": {
ownerIndex("created_at", "owner_sweeps_created"),
{Keys: bson.D{{Key: "watch_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("watch_sweeps_created")},
{Keys: bson.D{{Key: "job_id", Value: 1}}, Options: options.Index().SetName("sweep_job")},
},
"radar_opportunities": {
ownerIndex("created_at", "owner_opportunities_created"),
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "intent_band", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_opportunity_band_status")},
},
"radar_replies": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "opportunity_id", Value: 1}, {Key: "variant", Value: 1}}, Options: options.Index().SetName("owner_reply_variant")},
},
"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")},
},
"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")},
},
// The follow-up scan is a cross-tenant due sweep, so its driving index is not owner-scoped.
"crm_followups": {
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "due_at", Value: 1}}, Options: options.Index().SetName("followup_due_scan")},
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "contact_id", Value: 1}}, Options: options.Index().SetName("owner_contact_followup")},
},
"billing_checkout_attempts": { "billing_checkout_attempts": {
{Keys: bson.D{{Key: "uid", Value: 1}, {Key: "request_id", Value: 1}}, Options: options.Index().SetName("uid_request")}, {Keys: bson.D{{Key: "uid", Value: 1}, {Key: "request_id", Value: 1}}, Options: options.Index().SetName("uid_request")},
{Keys: bson.D{{Key: "session_id", Value: 1}}, Options: options.Index().SetName("checkout_session")}, {Keys: bson.D{{Key: "session_id", Value: 1}}, Options: options.Index().SetName("checkout_session")},

View File

@ -0,0 +1,57 @@
// radarjudgesample exports recent opportunities for human judge calibration (T535).
package main
import (
"context"
"encoding/csv"
"flag"
"fmt"
"os"
"time"
"apps/backend/internal/config"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/module/radar/repository"
"github.com/zeromicro/go-zero/core/conf"
)
func main() {
f := flag.String("f", "etc/gateway.yaml", "config")
owner := flag.Int64("owner", 0, "owner uid")
out := flag.String("o", "judge-sample.csv", "output csv")
flag.Parse()
var c config.Config
conf.MustLoad(*f, &c)
c.ApplyEnv()
repo := repository.NewMonStore(c.Mongo.URI, c.Mongo.Database)
ctx := context.Background()
list, _, err := repo.ListOpportunities(ctx, *owner, domain.OpportunityListFilter{Page: 1, PageSize: 100})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
w := csv.NewWriter(os.Stdout)
if *out != "-" {
fp, err := os.Create(*out)
if err != nil {
panic(err)
}
defer fp.Close()
w = csv.NewWriter(fp)
}
_ = w.Write([]string{"id", "status", "intent_score", "intent_band", "region_match", "text", "human_label", "notes"})
for _, o := range list {
_ = w.Write([]string{o.ID, o.Status, fmt.Sprintf("%d", o.IntentScore), o.IntentBand, o.RegionMatch, trim(o.Text, 120), "", ""})
}
w.Flush()
fmt.Fprintf(os.Stderr, "exported %d rows at %s\n", len(list), time.Now().UTC().Format(time.RFC3339))
}
func trim(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}

View File

@ -30,6 +30,10 @@ import (
scoutRepo "apps/backend/internal/module/scout/repository" scoutRepo "apps/backend/internal/module/scout/repository"
growthRepo "apps/backend/internal/module/growth/repository" growthRepo "apps/backend/internal/module/growth/repository"
growthUC "apps/backend/internal/module/growth/usecase" growthUC "apps/backend/internal/module/growth/usecase"
crmRepo "apps/backend/internal/module/crm/repository"
crmUC "apps/backend/internal/module/crm/usecase"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
scoutUC "apps/backend/internal/module/scout/usecase" scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish" studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository" studioRepo "apps/backend/internal/module/studio/repository"
@ -154,7 +158,26 @@ func main() {
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0) _, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
} }
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox + growth)", workerID, interval) radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
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 { return nil, path, err }
o := make([]radarUC.ThreadHit, 0, len(hits))
for _, h := range hits { o = append(o, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet}) }
return o, path, nil
})
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
})
radarSvc.Notifier = radarUC.NotifierFromAppNotif(&workerRadarNotif{App: appN})
crmSvc := crmUC.New(crmRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
crmSvc.Notifier = &workerCrmFollowUpNotif{App: appN}
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + scout + radar_sweep + followup + outbox + growth)", workerID, interval)
fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval) fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
@ -231,6 +254,14 @@ func main() {
logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err) logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error()) _, _ = jobs.FailJob(ctx, j.ID, err.Error())
} }
case jobDomain.TemplateRadarSweep:
logx.Infof("worker %s radar_sweep job %s ref=%s", workerID, j.ID, j.RefID)
if err := runRadarSweep(ctx, jobs, radarSvc, j); err != nil {
logx.Errorf("worker %s radar_sweep %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s radar_sweep %s ok", workerID, j.ID)
}
default: default:
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID) logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType) _, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
@ -238,9 +269,9 @@ func main() {
} }
// 2) One worker owns an outbox tick and renews its lease while claims run. // 2) One worker owns an outbox tick and renews its lease while claims run.
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL) processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
// 3) 巡場維護:過期 outcome + 清終態 job(單一 worker、低頻 // 3) 巡場維護:過期 outcome + 清終態 job + 雷達每日排程 + 追蹤掃描(單一 worker、低頻
if time.Since(lastMaintenance) >= maintenanceEvery { if time.Since(lastMaintenance) >= maintenanceEvery {
if runMaintenance(ctx, growthSvc, jobs, maintenanceLock, workerID) { if runMaintenance(ctx, growthSvc, jobs, radarSvc, crmSvc, maintenanceLock, workerID) {
lastMaintenance = time.Now() lastMaintenance = time.Now()
} }
} }
@ -254,6 +285,8 @@ func runMaintenance(
ctx context.Context, ctx context.Context,
growthSvc *growthUC.Service, growthSvc *growthUC.Service,
jobs *jobUC.Service, jobs *jobUC.Service,
radarSvc *radarUC.Service,
crmSvc *crmUC.Service,
lock *redislock.Lock, lock *redislock.Lock,
workerID string, workerID string,
) bool { ) bool {
@ -281,9 +314,75 @@ func runMaintenance(
} else if purged > 0 { } else if purged > 0 {
logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged) logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged)
} }
// 雷達每日排程UTC 22:00 之後為每個 active watch 建一筆 radar_sweep同日去重
if n, err := radarSvc.ScheduleDailySweeps(ctx, time.Now().UTC()); err != nil {
logx.Errorf("worker %s radar daily schedule: %v", workerID, err)
} else if n > 0 {
logx.Infof("worker %s radar daily schedule ensured %d watch job(s)", workerID, n)
}
// 待追蹤到期掃描:通知站內鈴鐺,第二次無動作 → escalated。
if crmSvc != nil {
if n, err := crmSvc.ScanFollowUps(ctx, 0); err != nil {
logx.Errorf("worker %s followup scan: %v", workerID, err)
} else if n > 0 {
logx.Infof("worker %s followup scan notified %d", workerID, n)
}
}
return true return true
} }
type workerRadarNotif struct{ App *appnotifUC.Service }
func (b *workerRadarNotif) InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error {
if b == nil || b.App == nil {
return nil
}
return b.App.NotifyJobState(ctx, ownerUID, refType+":"+refID, "radar", "failed", title+": "+body, 0)
}
type workerCrmFollowUpNotif struct{ App *appnotifUC.Service }
func (b *workerCrmFollowUpNotif) NotifyFollowUp(ctx context.Context, ownerUID int64, contactID, followUpID string) error {
if b == nil || b.App == nil {
return nil
}
return b.App.NotifyJobState(ctx, ownerUID, "followup:"+followUpID, "radar_followup", "succeeded",
"待追蹤到期:請回訪聯絡人", 100)
}
// 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
if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil {
return fmt.Errorf("radar_sweep payload: %w", err)
}
watchID := strings.TrimSpace(payload.WatchID)
if watchID == "" {
if i := strings.LastIndex(j.RefID, ":"); i > 0 {
watchID = j.RefID[:i]
}
}
if watchID == "" {
return fmt.Errorf("radar_sweep missing watch_id")
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "雷達巡檢 · 抓取中"); err != nil {
return err
}
res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID)
if err != nil {
return err
}
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 判定 %d · 截斷 %d", res.Created, res.Judged, res.Truncated)
if res.FetchFailed {
return fmt.Errorf("%s", res.FailedReason)
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil {
return err
}
_, err = jobs.SucceedJob(ctx, j.ID, summary)
return err
}
func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redislock.Lock, workerID string, ttl time.Duration) { func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redislock.Lock, workerID string, ttl time.Duration) {
locked, err := lock.Acquire(ctx) locked, err := lock.Acquire(ctx)
if err != nil { if err != nil {

View File

@ -0,0 +1,278 @@
syntax = "v1"
// demand-radar: contacts / touches / follow-ups / conversion / stats
// spec: docs/product/demand-radar/spec.md §5.2
// 未實作能力一律回 501crmDomain.ErrNotReady禁止 102000 空成功。
// 命名紅線:不得以 lead 指稱銷售線索。
type (
// ---------- Contact ----------
ContactPublic {
Id string `json:"id"`
SourcePlatform string `json:"source_platform"`
AuthorHandle string `json:"author_handle"`
DisplayName string `json:"display_name,optional"`
Stage string `json:"stage"` // new_found | engaged | dm_sent | replied | quoted | won | lost
NeedsFollowUp bool `json:"needs_follow_up"`
FollowUpDays int `json:"follow_up_days"`
LastTouchAt int64 `json:"last_touch_at,optional"`
OpportunityIds []string `json:"opportunity_ids"`
OpportunityCount int `json:"opportunity_count"`
MergedFrom []string `json:"merged_from,optional"`
TopIntentBand string `json:"top_intent_band,optional"`
TopIntentScore int `json:"top_intent_score,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
ContactBrief {
Id string `json:"id"`
SourcePlatform string `json:"source_platform"`
AuthorHandle string `json:"author_handle"`
DisplayName string `json:"display_name,optional"`
Stage string `json:"stage"`
}
// StageCount — 八格視圖用stage 值另含 needs_follow_up跨階段檢視非階段值
StageCount {
Stage string `json:"stage"`
Count int `json:"count"`
}
ListContactsReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Stage string `form:"stage,optional"`
// FollowUp: true | false留空不篩選
FollowUp string `form:"follow_up,optional"`
Band string `form:"band,optional"`
// Sort: last_touch_at | intent_score預設 last_touch_at 倒序)
Sort string `form:"sort,optional"`
}
ContactListData {
List []ContactPublic `json:"list"`
Pagination Pagination `json:"pagination"`
StageCounts []StageCount `json:"stage_counts"`
}
ContactTouchPublic {
Id string `json:"id"`
ContactId string `json:"contact_id"`
Type string `json:"type"` // stage | reply | note | conversion
FromStage string `json:"from_stage,optional"`
ToStage string `json:"to_stage,optional"`
Body string `json:"body,optional"`
ActorUid int64 `json:"actor_uid"`
CreatedAt int64 `json:"created_at"`
}
ContactOpportunityBrief {
Id string `json:"id"`
Permalink string `json:"permalink"`
Text string `json:"text"`
IntentScore int `json:"intent_score"`
IntentBand string `json:"intent_band"`
CreatedAt int64 `json:"created_at"`
}
GetContactReq {
Id string `path:"id"`
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
}
ContactDetailData {
Contact ContactPublic `json:"contact"`
Touches []ContactTouchPublic `json:"touches"`
Pagination Pagination `json:"pagination"`
Opportunities []ContactOpportunityBrief `json:"opportunities"`
}
ContactIdReq {
Id string `path:"id"`
}
UpdateContactStageReq {
Id string `path:"id"`
Stage string `json:"stage"`
Note string `json:"note,optional"`
}
SetContactFollowUpReq {
Id string `path:"id"`
NeedsFollowUp bool `json:"needs_follow_up"`
Days int `json:"days,optional"`
}
CreateContactNoteReq {
Id string `path:"id"`
Body string `json:"body"`
}
MergeContactReq {
Id string `path:"id"`
SourceContactId string `json:"source_contact_id"`
}
UnmergeContactReq {
Id string `path:"id"`
MergedContactId string `json:"merged_contact_id"`
}
// ---------- Conversion寫既有 growth_outcomes不另建成交帳 ----------
CreateCrmConversionReq {
Id string `path:"id"`
Amount float64 `json:"amount,optional"`
Currency string `json:"currency,optional"`
Note string `json:"note,optional"`
}
UpdateCrmConversionReq {
Id string `path:"id"`
Amount float64 `json:"amount,optional"`
Currency string `json:"currency,optional"`
Note string `json:"note,optional"`
}
DeleteCrmConversionReq {
Id string `path:"id"`
}
CrmConversionData {
ContactId string `json:"contact_id"`
OutcomeId string `json:"outcome_id,optional"`
Stage string `json:"stage"`
Amount float64 `json:"amount,optional"`
Currency string `json:"currency,optional"`
Note string `json:"note,optional"`
UpdatedAt int64 `json:"updated_at"`
}
// ---------- FollowUp ----------
FollowUpPublic {
Id string `json:"id"`
ContactId string `json:"contact_id"`
DueAt int64 `json:"due_at"`
Status string `json:"status"` // scheduled | notified | done | snoozed | escalated
NotifiedCount int `json:"notified_count"`
Contact ContactBrief `json:"contact"`
CreatedAt int64 `json:"created_at"`
}
ListFollowUpsReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Status string `form:"status,optional"`
}
FollowUpListData {
List []FollowUpPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
FollowUpIdReq {
Id string `path:"id"`
}
SnoozeFollowUpReq {
Id string `path:"id"`
Days int `json:"days"`
}
GenerateFollowUpMessageReq {
Id string `path:"id"`
}
GenerateFollowUpMessageData {
Text string `json:"text"`
}
// ---------- Stats樣本 < 5 只給絕對數) ----------
TermConversionStat {
Term string `json:"term"`
Accepted int `json:"accepted"`
Replied int `json:"replied"`
Won int `json:"won"`
ConversionRate float64 `json:"conversion_rate,optional"`
InsufficientSample bool `json:"insufficient_sample"`
}
VariantConversionStat {
Variant string `json:"variant"`
Used int `json:"used"`
Replied int `json:"replied"`
Won int `json:"won"`
SuccessRate float64 `json:"success_rate,optional"`
InsufficientSample bool `json:"insufficient_sample"`
}
SourceConversionStat {
Source string `json:"source"` // radar | scout | manual_import
Won int `json:"won"`
InsufficientSample bool `json:"insufficient_sample"`
}
CrmStatsReq {
From int64 `form:"from,optional"`
To int64 `form:"to,optional"`
}
CrmStatsData {
Terms []TermConversionStat `json:"terms"`
Variants []VariantConversionStat `json:"variants"`
Sources []SourceConversionStat `json:"sources"`
}
)
@server (
group: crm
prefix: /api/v1/crm
middleware: AuthJWT
)
service gateway {
@handler ListContacts
get /contacts (ListContactsReq) returns (ContactListData)
@handler GetContact
get /contacts/:id (GetContactReq) returns (ContactDetailData)
@handler UpdateContactStage
post /contacts/:id/stage (UpdateContactStageReq) returns (ContactPublic)
@handler SetContactFollowUp
post /contacts/:id/follow-up (SetContactFollowUpReq) returns (ContactPublic)
@handler CreateContactNote
post /contacts/:id/notes (CreateContactNoteReq) returns (ContactTouchPublic)
@handler MergeContact
post /contacts/:id/merge (MergeContactReq) returns (ContactPublic)
@handler UnmergeContact
post /contacts/:id/unmerge (UnmergeContactReq) returns (ContactPublic)
@handler CreateContactConversion
post /contacts/:id/conversion (CreateCrmConversionReq) returns (CrmConversionData)
@handler UpdateContactConversion
put /contacts/:id/conversion (UpdateCrmConversionReq) returns (CrmConversionData)
@handler DeleteContactConversion
delete /contacts/:id/conversion (DeleteCrmConversionReq) returns (OkData)
@handler ListFollowUps
get /followups (ListFollowUpsReq) returns (FollowUpListData)
@handler DoneFollowUp
post /followups/:id/done (FollowUpIdReq) returns (FollowUpPublic)
@handler SnoozeFollowUp
post /followups/:id/snooze (SnoozeFollowUpReq) returns (FollowUpPublic)
@handler GenerateFollowUpMessage
post /followups/:id/message (GenerateFollowUpMessageReq) returns (GenerateFollowUpMessageData)
@handler GetCrmStats
get /stats (CrmStatsReq) returns (CrmStatsData)
}

View File

@ -25,3 +25,5 @@ import "m5.api"
import "invite.api" import "invite.api"
import "growth.api" import "growth.api"
import "growth_p2.api" import "growth_p2.api"
import "radar.api"
import "crm.api"

View File

@ -312,6 +312,14 @@ type (
ScoutCrawlerSessionReq { ScoutCrawlerSessionReq {
Token string `json:"token"` Token string `json:"token"`
} }
// 升級為商機:複製成 Opportunity不改 outreach_status
PromoteScoutPostData {
OpportunityId string `json:"opportunity_id"`
Status string `json:"status"`
IntentBand string `json:"intent_band,optional"`
IntentScore int `json:"intent_score,optional"`
}
) )
@server ( @server (
@ -450,6 +458,9 @@ service gateway {
@handler SendOutreach @handler SendOutreach
post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic) post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic)
@handler PromoteScoutPost
post /posts/:id/promote (ScoutPostIdPath) returns (PromoteScoutPostData)
@handler RemoveScoutPost @handler RemoveScoutPost
delete /posts/:id (ScoutPostIdPath) returns (OkData) delete /posts/:id (ScoutPostIdPath) returns (OkData)

View File

@ -0,0 +1,350 @@
syntax = "v1"
// demand-radar: service profile / radar watches / sweeps / opportunities / replies
// spec: docs/product/demand-radar/spec.md §5.2
// 未實作能力一律回 501radarDomain.ErrNotReady禁止 102000 空成功。
type (
// ---------- ServiceProfile ----------
ServiceItem {
Name string `json:"name"`
PriceMin float64 `json:"price_min,optional"`
PriceMax float64 `json:"price_max,optional"`
Currency string `json:"currency,optional"`
}
ServiceCasePublic {
Title string `json:"title"`
Summary string `json:"summary,optional"`
Link string `json:"link,optional"`
}
FaqItem {
Question string `json:"question"`
Answer string `json:"answer"`
}
ServiceProfilePublic {
Exists bool `json:"exists"`
Services []ServiceItem `json:"services"`
Cases []ServiceCasePublic `json:"cases"`
Forbidden []string `json:"forbidden"`
Faq []FaqItem `json:"faq"`
ServiceAreas []string `json:"service_areas"`
RemoteOk bool `json:"remote_ok"`
Availability string `json:"availability,optional"`
ToneNote string `json:"tone_note,optional"`
UpdatedAt int64 `json:"updated_at,optional"`
}
UpsertServiceProfileReq {
Services []ServiceItem `json:"services"`
Cases []ServiceCasePublic `json:"cases,optional"`
Forbidden []string `json:"forbidden,optional"`
Faq []FaqItem `json:"faq,optional"`
ServiceAreas []string `json:"service_areas,optional"`
RemoteOk bool `json:"remote_ok,optional"`
Availability string `json:"availability,optional"`
ToneNote string `json:"tone_note,optional"`
}
// ---------- 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"`
}
ListWatchesReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Status string `form:"status,optional"`
}
WatchListData {
List []RadarWatchPublic `json:"list"`
Pagination Pagination `json:"pagination"`
ActiveCount int `json:"active_count"`
MaxActive int `json:"max_active"`
ProfileExists bool `json:"profile_exists"`
}
CreateWatchReq {
Terms []string `json:"terms"`
ExcludeTerms []string `json:"exclude_terms,optional"`
Regions []string `json:"regions,optional"`
Enabled bool `json:"enabled,optional"`
}
UpdateWatchReq {
Id string `path:"id"`
Terms []string `json:"terms,optional"`
ExcludeTerms []string `json:"exclude_terms,optional"`
Regions []string `json:"regions,optional"`
}
WatchIdReq {
Id string `path:"id"`
}
SuggestWatchTermsReq {
Limit int `json:"limit,optional"`
}
WatchTermSuggestion {
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"` // include | exclude
}
WatchSuggestData {
List []WatchTermSuggestion `json:"list"`
}
TriggerSweepData {
JobId string `json:"job_id"`
SweepId string `json:"sweep_id,optional"`
}
// ---------- Opportunity ----------
OpportunityReason {
Dimension string `json:"dimension"` // authenticity | intent | region | freshness | fit
Score int `json:"score"`
Reason string `json:"reason"`
}
OpportunityOverride {
FromBand string `json:"from_band,optional"`
ToBand string `json:"to_band,optional"`
FromStatus string `json:"from_status,optional"`
ToStatus string `json:"to_status,optional"`
ActorUid int64 `json:"actor_uid"`
At int64 `json:"at"`
}
OpportunityPublic {
Id string `json:"id"`
WatchId string `json:"watch_id,optional"`
Source string `json:"source"` // threads | manual | scout_promote
SourceScoutPostId string `json:"source_scout_post_id,optional"`
ExternalId string `json:"external_id"`
Permalink string `json:"permalink"`
AuthorHandle string `json:"author_handle"`
Text string `json:"text"`
PostedAt int64 `json:"posted_at"`
Status string `json:"status"` // judging | qualified | rejected | accepted | dismissed
IntentScore int `json:"intent_score"`
IntentBand string `json:"intent_band"` // high | mid | low
Reasons []OpportunityReason `json:"reasons"`
RegionDetected string `json:"region_detected,optional"`
RegionMatch string `json:"region_match"` // match | mismatch | unknown
FreshnessHours int `json:"freshness_hours"`
MatchedService string `json:"matched_service,optional"`
MatchedTerms []string `json:"matched_terms"`
RejectReason string `json:"reject_reason,optional"`
Override *OpportunityOverride `json:"override,optional"`
ContactId string `json:"contact_id,optional"`
DefaultReply *ReplyVariantPublic `json:"default_reply,optional"`
CreatedAt int64 `json:"created_at"`
}
RadarTodayStats {
Total int `json:"total"`
High int `json:"high"`
Mid int `json:"mid"`
Low int `json:"low"`
}
RadarTodayData {
Stats RadarTodayStats `json:"stats"`
High []OpportunityPublic `json:"high"`
Mid []OpportunityPublic `json:"mid"`
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 string `json:"empty_reason,optional"`
EmptyHint string `json:"empty_hint,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"`
}
OpportunityListData {
List []OpportunityPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
OpportunityIdReq {
Id string `path:"id"`
}
AcceptOpportunityReq {
Id string `path:"id"`
}
AcceptOpportunityData {
OpportunityId string `json:"opportunity_id"`
ContactId string `json:"contact_id,optional"`
Status string `json:"status"`
}
DismissOpportunityReq {
Id string `path:"id"`
Reason string `json:"reason,optional"`
}
OverrideOpportunityReq {
Id string `path:"id"`
Band string `json:"band,optional"`
Status string `json:"status,optional"`
Note string `json:"note,optional"`
}
// ---------- ReplyVariant ----------
ReplyVariantPublic {
Id string `json:"id"`
OpportunityId string `json:"opportunity_id"`
Variant string `json:"variant"` // public_comment | dm | no_sales | professional | humorous
Text string `json:"text"`
UsedAt int64 `json:"used_at,optional"`
SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy
CreatedAt int64 `json:"created_at"`
}
ListRepliesReq {
Id string `path:"id"`
}
ReplyListData {
List []ReplyVariantPublic `json:"list"`
}
CreateReplyReq {
Id string `path:"id"`
Variant string `json:"variant"`
}
// MarkReplyUsedReq: channel=outbox|manual_copydm 僅 manual_copy
MarkReplyUsedReq {
Id string `path:"id"`
ReplyId string `path:"replyId"`
Channel string `json:"channel"` // outbox | manual_copy
}
MarkReplyUsedData {
Reply ReplyVariantPublic `json:"reply"`
HealthAdvice string `json:"health_advice,optional"`
}
// ---------- 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"`
}
ListSweepsReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
WatchId string `form:"watch_id,optional"`
From int64 `form:"from,optional"`
To int64 `form:"to,optional"`
}
SweepListData {
List []RadarSweepPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
)
@server (
group: radar
prefix: /api/v1/radar
middleware: AuthJWT
)
service gateway {
@handler GetServiceProfile
get /service-profile returns (ServiceProfilePublic)
@handler UpsertServiceProfile
put /service-profile (UpsertServiceProfileReq) returns (ServiceProfilePublic)
@handler ListWatches
get /watches (ListWatchesReq) returns (WatchListData)
@handler CreateWatch
post /watches (CreateWatchReq) returns (RadarWatchPublic)
@handler SuggestWatchTerms
post /watches/suggest (SuggestWatchTermsReq) returns (WatchSuggestData)
@handler GetWatch
get /watches/:id (WatchIdReq) returns (RadarWatchPublic)
@handler UpdateWatch
put /watches/:id (UpdateWatchReq) returns (RadarWatchPublic)
@handler ArchiveWatch
delete /watches/:id (WatchIdReq) returns (OkData)
@handler PauseWatch
post /watches/:id/pause (WatchIdReq) returns (RadarWatchPublic)
@handler ResumeWatch
post /watches/:id/resume (WatchIdReq) returns (RadarWatchPublic)
@handler TriggerWatchSweep
post /watches/:id/sweep (WatchIdReq) returns (TriggerSweepData)
@handler GetRadarToday
get /today returns (RadarTodayData)
@handler ListOpportunities
get /opportunities (ListOpportunitiesReq) returns (OpportunityListData)
@handler GetOpportunity
get /opportunities/:id (OpportunityIdReq) returns (OpportunityPublic)
@handler AcceptOpportunity
post /opportunities/:id/accept (AcceptOpportunityReq) returns (AcceptOpportunityData)
@handler DismissOpportunity
post /opportunities/:id/dismiss (DismissOpportunityReq) returns (OpportunityPublic)
@handler OverrideOpportunity
post /opportunities/:id/override (OverrideOpportunityReq) returns (OpportunityPublic)
@handler ListOpportunityReplies
get /opportunities/:id/replies (ListRepliesReq) returns (ReplyListData)
@handler CreateOpportunityReply
post /opportunities/:id/replies (CreateReplyReq) returns (ReplyVariantPublic)
@handler MarkOpportunityReplyUsed
post /opportunities/:id/replies/:replyId/mark-used (MarkReplyUsedReq) returns (MarkReplyUsedData)
@handler ListSweeps
get /sweeps (ListSweepsReq) returns (SweepListData)
}

View File

@ -0,0 +1,17 @@
[
{ "dropIndexes": "radar_watches", "index": "owner_watches_status" },
{ "dropIndexes": "radar_watches", "index": "owner_watches_created" },
{ "dropIndexes": "radar_sweeps", "index": "owner_sweeps_created" },
{ "dropIndexes": "radar_sweeps", "index": "watch_sweeps_created" },
{ "dropIndexes": "radar_sweeps", "index": "sweep_job" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunities_created" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunity_band_status" },
{ "dropIndexes": "radar_opportunities", "index": "owner_opportunity_external" },
{ "dropIndexes": "radar_replies", "index": "owner_reply_variant" },
{ "dropIndexes": "crm_contacts", "index": "owner_contact_identity" },
{ "dropIndexes": "crm_contacts", "index": "owner_contacts_stage_touch" },
{ "dropIndexes": "crm_contacts", "index": "owner_contacts_follow_up" },
{ "dropIndexes": "crm_touches", "index": "owner_touches_created" },
{ "dropIndexes": "crm_followups", "index": "followup_due_scan" },
{ "dropIndexes": "crm_followups", "index": "owner_contact_followup" }
]

View File

@ -0,0 +1,52 @@
[
{
"createIndexes": "radar_watches",
"indexes": [
{ "key": { "owner_uid": 1, "status": 1 }, "name": "owner_watches_status" },
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_watches_created" }
]
},
{
"createIndexes": "radar_sweeps",
"indexes": [
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_sweeps_created" },
{ "key": { "watch_id": 1, "created_at": -1 }, "name": "watch_sweeps_created" },
{ "key": { "job_id": 1 }, "name": "sweep_job" }
]
},
{
"createIndexes": "radar_opportunities",
"indexes": [
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_opportunities_created" },
{ "key": { "owner_uid": 1, "intent_band": 1, "status": 1 }, "name": "owner_opportunity_band_status" },
{ "key": { "owner_uid": 1, "external_id": 1 }, "name": "owner_opportunity_external", "unique": true }
]
},
{
"createIndexes": "radar_replies",
"indexes": [
{ "key": { "owner_uid": 1, "opportunity_id": 1, "variant": 1 }, "name": "owner_reply_variant" }
]
},
{
"createIndexes": "crm_contacts",
"indexes": [
{ "key": { "owner_uid": 1, "source_platform": 1, "author_handle": 1 }, "name": "owner_contact_identity", "unique": true },
{ "key": { "owner_uid": 1, "stage": 1, "last_touch_at": -1 }, "name": "owner_contacts_stage_touch" },
{ "key": { "owner_uid": 1, "needs_follow_up": 1 }, "name": "owner_contacts_follow_up" }
]
},
{
"createIndexes": "crm_touches",
"indexes": [
{ "key": { "owner_uid": 1, "contact_id": 1, "created_at": -1 }, "name": "owner_touches_created" }
]
},
{
"createIndexes": "crm_followups",
"indexes": [
{ "key": { "status": 1, "due_at": 1 }, "name": "followup_due_scan" },
{ "key": { "owner_uid": 1, "contact_id": 1 }, "name": "owner_contact_followup" }
]
}
]

View File

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

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 CreateContactNoteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateContactNoteReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewCreateContactNoteLogic(r.Context(), svcCtx)
data, err := l.CreateContactNote(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 DeleteContactConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteCrmConversionReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewDeleteContactConversionLogic(r.Context(), svcCtx)
data, err := l.DeleteContactConversion(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 DoneFollowUpHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.FollowUpIdReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewDoneFollowUpLogic(r.Context(), svcCtx)
data, err := l.DoneFollowUp(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 GenerateFollowUpMessageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateFollowUpMessageReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewGenerateFollowUpMessageLogic(r.Context(), svcCtx)
data, err := l.GenerateFollowUpMessage(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 GetContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetContactReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewGetContactLogic(r.Context(), svcCtx)
data, err := l.GetContact(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 GetCrmStatsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CrmStatsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewGetCrmStatsLogic(r.Context(), svcCtx)
data, err := l.GetCrmStats(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 ListContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListContactsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewListContactsLogic(r.Context(), svcCtx)
data, err := l.ListContacts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 ListFollowUpsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListFollowUpsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewListFollowUpsLogic(r.Context(), svcCtx)
data, err := l.ListFollowUps(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 MergeContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.MergeContactReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewMergeContactLogic(r.Context(), svcCtx)
data, err := l.MergeContact(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 SetContactFollowUpHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SetContactFollowUpReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewSetContactFollowUpLogic(r.Context(), svcCtx)
data, err := l.SetContactFollowUp(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 SnoozeFollowUpHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SnoozeFollowUpReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewSnoozeFollowUpLogic(r.Context(), svcCtx)
data, err := l.SnoozeFollowUp(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 UnmergeContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UnmergeContactReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewUnmergeContactLogic(r.Context(), svcCtx)
data, err := l.UnmergeContact(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 UpdateContactConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateCrmConversionReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewUpdateContactConversionLogic(r.Context(), svcCtx)
data, err := l.UpdateContactConversion(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package 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 UpdateContactStageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateContactStageReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewUpdateContactStageLogic(r.Context(), svcCtx)
data, err := l.UpdateContactStage(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ArchiveWatchHandler(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.NewArchiveWatchLogic(r.Context(), svcCtx)
data, err := l.ArchiveWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
// 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"
)
func GetRadarTodayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := radar.NewGetRadarTodayLogic(r.Context(), svcCtx)
data, err := l.GetRadarToday()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,20 @@
// 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"
)
func GetServiceProfileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := radar.NewGetServiceProfileLogic(r.Context(), svcCtx)
data, err := l.GetServiceProfile()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetWatchHandler(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.NewGetWatchLogic(r.Context(), svcCtx)
data, err := l.GetWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PauseWatchHandler(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.NewPauseWatchLogic(r.Context(), svcCtx)
data, err := l.PauseWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ResumeWatchHandler(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.NewResumeWatchLogic(r.Context(), svcCtx)
data, err := l.ResumeWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func TriggerWatchSweepHandler(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.NewTriggerWatchSweepLogic(r.Context(), svcCtx)
data, err := l.TriggerWatchSweep(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import (
billing "apps/backend/internal/handler/billing" billing "apps/backend/internal/handler/billing"
checkups "apps/backend/internal/handler/checkups" checkups "apps/backend/internal/handler/checkups"
compose "apps/backend/internal/handler/compose" compose "apps/backend/internal/handler/compose"
crm "apps/backend/internal/handler/crm"
extension "apps/backend/internal/handler/extension" extension "apps/backend/internal/handler/extension"
health "apps/backend/internal/handler/health" health "apps/backend/internal/handler/health"
insightsapi "apps/backend/internal/handler/insightsapi" insightsapi "apps/backend/internal/handler/insightsapi"
@ -31,6 +32,7 @@ import (
proxy "apps/backend/internal/handler/proxy" proxy "apps/backend/internal/handler/proxy"
publictools "apps/backend/internal/handler/publictools" publictools "apps/backend/internal/handler/publictools"
publicutm "apps/backend/internal/handler/publicutm" publicutm "apps/backend/internal/handler/publicutm"
radar "apps/backend/internal/handler/radar"
research "apps/backend/internal/handler/research" research "apps/backend/internal/handler/research"
scout "apps/backend/internal/handler/scout" scout "apps/backend/internal/handler/scout"
settings "apps/backend/internal/handler/settings" settings "apps/backend/internal/handler/settings"
@ -315,6 +317,90 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/compose"), rest.WithPrefix("/api/v1/compose"),
) )
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/contacts",
Handler: crm.ListContactsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/contacts/:id",
Handler: crm.GetContactHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/conversion",
Handler: crm.CreateContactConversionHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/contacts/:id/conversion",
Handler: crm.UpdateContactConversionHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/contacts/:id/conversion",
Handler: crm.DeleteContactConversionHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/follow-up",
Handler: crm.SetContactFollowUpHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/merge",
Handler: crm.MergeContactHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/notes",
Handler: crm.CreateContactNoteHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/stage",
Handler: crm.UpdateContactStageHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/contacts/:id/unmerge",
Handler: crm.UnmergeContactHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/followups",
Handler: crm.ListFollowUpsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/followups/:id/done",
Handler: crm.DoneFollowUpHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/followups/:id/message",
Handler: crm.GenerateFollowUpMessageHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/followups/:id/snooze",
Handler: crm.SnoozeFollowUpHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/stats",
Handler: crm.GetCrmStatsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/crm"),
)
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -532,12 +618,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/generate-image", Path: "/upload",
Handler: media.GenerateImageHandler(serverCtx), Handler: media.UploadHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -547,11 +632,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/upload", Path: "/generate-image",
Handler: media.UploadHandler(serverCtx), Handler: media.GenerateImageHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -941,6 +1027,121 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/public/u"), rest.WithPrefix("/api/v1/public/u"),
) )
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/opportunities",
Handler: radar.ListOpportunitiesHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/opportunities/:id",
Handler: radar.GetOpportunityHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/opportunities/:id/accept",
Handler: radar.AcceptOpportunityHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/opportunities/:id/dismiss",
Handler: radar.DismissOpportunityHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/opportunities/:id/override",
Handler: radar.OverrideOpportunityHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/opportunities/:id/replies",
Handler: radar.ListOpportunityRepliesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/opportunities/:id/replies",
Handler: radar.CreateOpportunityReplyHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/opportunities/:id/replies/:replyId/mark-used",
Handler: radar.MarkOpportunityReplyUsedHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/service-profile",
Handler: radar.GetServiceProfileHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/service-profile",
Handler: radar.UpsertServiceProfileHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/sweeps",
Handler: radar.ListSweepsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/today",
Handler: radar.GetRadarTodayHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/watches",
Handler: radar.ListWatchesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches",
Handler: radar.CreateWatchHandler(serverCtx),
},
// 靜態路徑必須在 /watches/:id 之前,否則 suggest 會被當成 id。
{
Method: http.MethodPost,
Path: "/watches/suggest",
Handler: radar.SuggestWatchTermsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/watches/:id",
Handler: radar.GetWatchHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/watches/:id",
Handler: radar.UpdateWatchHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/watches/:id",
Handler: radar.ArchiveWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/pause",
Handler: radar.PauseWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/resume",
Handler: radar.ResumeWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/sweep",
Handler: radar.TriggerWatchSweepHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/radar"),
)
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -1045,6 +1246,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/posts/:id/mark-published", Path: "/posts/:id/mark-published",
Handler: scout.MarkPublishedHandler(serverCtx), Handler: scout.MarkPublishedHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/posts/:id/promote",
Handler: scout.PromoteScoutPostHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/posts/:id/send", Path: "/posts/:id/send",

View File

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

View File

@ -0,0 +1,35 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateContactConversionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateContactConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContactConversionLogic {
return &CreateContactConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *CreateContactConversionLogic) CreateContactConversion(req *types.CreateCrmConversionReq) (*types.CrmConversionData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, outcomeID, err := l.svcCtx.Crm.ReportConversion(l.ctx, uid, req.Id, req.Amount, req.Currency, req.Note)
if err != nil {
return nil, err
}
return &types.CrmConversionData{
ContactId: c.ID, OutcomeId: outcomeID, Stage: c.Stage,
Amount: req.Amount, Currency: req.Currency, Note: req.Note, UpdatedAt: c.UpdatedAt,
}, nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateContactNoteLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateContactNoteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContactNoteLogic {
return &CreateContactNoteLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *CreateContactNoteLogic) CreateContactNote(req *types.CreateContactNoteReq) (*types.ContactTouchPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
t, err := l.svcCtx.Crm.AddNote(l.ctx, uid, req.Id, req.Body)
if err != nil {
return nil, err
}
return crmmap.Touch(t), nil
}

View File

@ -0,0 +1,31 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteContactConversionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteContactConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContactConversionLogic {
return &DeleteContactConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *DeleteContactConversionLogic) DeleteContactConversion(req *types.DeleteCrmConversionReq) (*types.OkData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if err := l.svcCtx.Crm.DeleteConversion(l.ctx, uid, req.Id); err != nil {
return nil, err
}
return &types.OkData{Ok: true}, nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DoneFollowUpLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDoneFollowUpLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DoneFollowUpLogic {
return &DoneFollowUpLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *DoneFollowUpLogic) DoneFollowUp(req *types.FollowUpIdReq) (*types.FollowUpPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
f, err := l.svcCtx.Crm.DoneFollowUp(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return crmmap.FollowUp(f), nil
}

View File

@ -0,0 +1,32 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GenerateFollowUpMessageLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGenerateFollowUpMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateFollowUpMessageLogic {
return &GenerateFollowUpMessageLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GenerateFollowUpMessageLogic) GenerateFollowUpMessage(req *types.GenerateFollowUpMessageReq) (*types.GenerateFollowUpMessageData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
text, err := l.svcCtx.Crm.GenerateFollowUpMessage(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return &types.GenerateFollowUpMessageData{Text: text}, nil
}

View File

@ -0,0 +1,61 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetContactLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetContactLogic {
return &GetContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetContactLogic) GetContact(req *types.GetContactReq) (*types.ContactDetailData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, touches, total, err := l.svcCtx.Crm.GetContact(l.ctx, uid, int64(req.Page), int64(req.PageSize), req.Id)
if err != nil {
return nil, err
}
briefs := make([]types.ContactOpportunityBrief, 0)
for _, raw := range l.svcCtx.Crm.OpportunityBriefs(l.ctx, uid, c.OpportunityIDs) {
b := types.ContactOpportunityBrief{}
if v, ok := raw["id"].(string); ok {
b.Id = v
}
if v, ok := raw["permalink"].(string); ok {
b.Permalink = v
}
if v, ok := raw["text"].(string); ok {
b.Text = v
}
if v, ok := raw["intent_score"].(int); ok {
b.IntentScore = v
}
if v, ok := raw["intent_band"].(string); ok {
b.IntentBand = v
}
if v, ok := raw["created_at"].(int64); ok {
b.CreatedAt = v
}
briefs = append(briefs, b)
}
return &types.ContactDetailData{
Contact: *crmmap.Contact(c),
Touches: crmmap.TouchList(touches),
Pagination: crmmap.Pagination(req.Page, req.PageSize, total),
Opportunities: briefs,
}, nil
}

View File

@ -0,0 +1,42 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCrmStatsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCrmStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmStatsLogic {
return &GetCrmStatsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetCrmStatsLogic) GetCrmStats(req *types.CrmStatsReq) (*types.CrmStatsData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
stats, err := l.svcCtx.Crm.Stats(l.ctx, uid, req.From, req.To)
if err != nil {
return nil, err
}
won := 0
if v, ok := stats["won"].(int); ok {
won = v
}
return &types.CrmStatsData{
Terms: []types.TermConversionStat{},
Variants: []types.VariantConversionStat{},
Sources: []types.SourceConversionStat{
{Source: "radar", Won: won, InsufficientSample: won < 5},
},
}, nil
}

View File

@ -0,0 +1,47 @@
package crm
import (
"context"
"strings"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListContactsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContactsLogic {
return &ListContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ListContactsLogic) ListContacts(req *types.ListContactsReq) (*types.ContactListData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
f := domain.ContactListFilter{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
f.FollowUp = &v
case "false", "0":
v := false
f.FollowUp = &v
}
list, total, counts, err := l.svcCtx.Crm.ListContacts(l.ctx, uid, f)
if err != nil {
return nil, err
}
return &types.ContactListData{
List: crmmap.ContactList(list), Pagination: crmmap.Pagination(req.Page, req.PageSize, total),
StageCounts: crmmap.StageCounts(counts),
}, nil
}

View File

@ -0,0 +1,50 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListFollowUpsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListFollowUpsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListFollowUpsLogic {
return &ListFollowUpsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ListFollowUpsLogic) ListFollowUps(req *types.ListFollowUpsReq) (*types.FollowUpListData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
list, total, err := l.svcCtx.Crm.ListFollowUps(l.ctx, uid, domain.FollowUpListFilter{
Status: req.Status, Page: req.Page, PageSize: req.PageSize,
})
if err != nil {
return nil, err
}
out := make([]types.FollowUpPublic, 0, len(list))
for _, f := range list {
p := crmmap.FollowUp(f)
if p == nil {
continue
}
if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil {
p.Contact = types.ContactBrief{
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
DisplayName: c.DisplayName, Stage: c.Stage,
}
}
out = append(out, *p)
}
return &types.FollowUpListData{List: out, Pagination: crmmap.Pagination(req.Page, req.PageSize, total)}, nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type MergeContactLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewMergeContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MergeContactLogic {
return &MergeContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *MergeContactLogic) MergeContact(req *types.MergeContactReq) (*types.ContactPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, err := l.svcCtx.Crm.Merge(l.ctx, uid, req.Id, req.SourceContactId)
if err != nil {
return nil, err
}
return crmmap.Contact(c), nil
}

View File

@ -0,0 +1,14 @@
package crm
import (
"fmt"
crmDomain "apps/backend/internal/module/crm/domain"
)
// notReady is returned by every crm capability that is routed but not implemented yet.
// It maps to HTTP 501 / code 501010 in internal/response, so a caller can never mistake
// a missing implementation for a successful empty result.
func notReady(capability string) error {
return fmt.Errorf("%w: %s", crmDomain.ErrNotReady, capability)
}

View File

@ -0,0 +1,34 @@
package crm
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"apps/backend/internal/domain"
crmDomain "apps/backend/internal/module/crm/domain"
"apps/backend/internal/response"
)
// notReady envelope 契約:殘留未實作 capability 須 501不可 102000 空成功。
func TestNotReadyEnvelope(t *testing.T) {
err := notReady("example.capability")
if !errors.Is(err, crmDomain.ErrNotReady) {
t.Fatalf("expected ErrNotReady, got %v", err)
}
rec := httptest.NewRecorder()
response.Write(context.Background(), rec, nil, err)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected HTTP 501, got %d", rec.Code)
}
var env response.Envelope
if decErr := json.NewDecoder(rec.Body).Decode(&env); decErr != nil {
t.Fatalf("decode envelope: %v", decErr)
}
if env.Code == domain.SuccessCode {
t.Fatal("not-ready must not use success code")
}
}

View File

@ -0,0 +1,16 @@
package crm
import (
"context"
"apps/backend/internal/middleware"
"apps/backend/internal/response"
)
func ownerUID(ctx context.Context) (int64, error) {
uid, ok := middleware.UIDFrom(ctx)
if !ok || uid <= 0 {
return 0, response.Biz(401, 401001, "missing authorization")
}
return uid, nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type SetContactFollowUpLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSetContactFollowUpLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetContactFollowUpLogic {
return &SetContactFollowUpLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *SetContactFollowUpLogic) SetContactFollowUp(req *types.SetContactFollowUpReq) (*types.ContactPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, err := l.svcCtx.Crm.SetFollowUp(l.ctx, uid, req.Id, req.NeedsFollowUp, req.Days)
if err != nil {
return nil, err
}
return crmmap.Contact(c), nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type SnoozeFollowUpLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSnoozeFollowUpLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SnoozeFollowUpLogic {
return &SnoozeFollowUpLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *SnoozeFollowUpLogic) SnoozeFollowUp(req *types.SnoozeFollowUpReq) (*types.FollowUpPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
f, err := l.svcCtx.Crm.SnoozeFollowUp(l.ctx, uid, req.Id, req.Days)
if err != nil {
return nil, err
}
return crmmap.FollowUp(f), nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UnmergeContactLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUnmergeContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UnmergeContactLogic {
return &UnmergeContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *UnmergeContactLogic) UnmergeContact(req *types.UnmergeContactReq) (*types.ContactPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, err := l.svcCtx.Crm.Unmerge(l.ctx, uid, req.Id, req.MergedContactId)
if err != nil {
return nil, err
}
return crmmap.Contact(c), nil
}

View File

@ -0,0 +1,35 @@
package crm
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateContactConversionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateContactConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContactConversionLogic {
return &UpdateContactConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *UpdateContactConversionLogic) UpdateContactConversion(req *types.UpdateCrmConversionReq) (*types.CrmConversionData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, outcomeID, err := l.svcCtx.Crm.UpdateConversion(l.ctx, uid, req.Id, req.Amount, req.Currency, req.Note)
if err != nil {
return nil, err
}
return &types.CrmConversionData{
ContactId: c.ID, OutcomeId: outcomeID, Stage: c.Stage,
Amount: req.Amount, Currency: req.Currency, Note: req.Note, UpdatedAt: c.UpdatedAt,
}, nil
}

View File

@ -0,0 +1,33 @@
package crm
import (
"context"
"apps/backend/internal/logic/crmmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateContactStageLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateContactStageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContactStageLogic {
return &UpdateContactStageLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *UpdateContactStageLogic) UpdateContactStage(req *types.UpdateContactStageReq) (*types.ContactPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
c, err := l.svcCtx.Crm.UpdateStage(l.ctx, uid, req.Id, req.Stage, req.Note)
if err != nil {
return nil, err
}
return crmmap.Contact(c), nil
}

View File

@ -0,0 +1,98 @@
package crmmap
import (
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/types"
)
func Contact(c *domain.Contact) *types.ContactPublic {
if c == nil {
return nil
}
ids := c.OpportunityIDs
if ids == nil {
ids = []string{}
}
return &types.ContactPublic{
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
DisplayName: c.DisplayName, Stage: c.Stage, NeedsFollowUp: c.NeedsFollowUp,
FollowUpDays: c.FollowUpDays, LastTouchAt: c.LastTouchAt,
OpportunityIds: ids, OpportunityCount: len(ids), MergedFrom: c.MergedFrom,
TopIntentBand: c.TopIntentBand, TopIntentScore: c.TopIntentScore,
CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt,
}
}
func ContactList(list []*domain.Contact) []types.ContactPublic {
out := make([]types.ContactPublic, 0, len(list))
for _, c := range list {
if p := Contact(c); p != nil {
out = append(out, *p)
}
}
return out
}
func Touch(t *domain.ContactTouch) *types.ContactTouchPublic {
if t == nil {
return nil
}
return &types.ContactTouchPublic{
Id: t.ID, ContactId: t.ContactID, Type: t.Type,
FromStage: t.FromStage, ToStage: t.ToStage, Body: t.Body,
ActorUid: t.ActorUID, CreatedAt: t.CreatedAt,
}
}
func TouchList(list []*domain.ContactTouch) []types.ContactTouchPublic {
out := make([]types.ContactTouchPublic, 0, len(list))
for _, t := range list {
if p := Touch(t); p != nil {
out = append(out, *p)
}
}
return out
}
func FollowUp(f *domain.FollowUp) *types.FollowUpPublic {
if f == nil {
return nil
}
return &types.FollowUpPublic{
Id: f.ID, ContactId: f.ContactID, DueAt: f.DueAt, Status: f.Status,
NotifiedCount: f.NotifiedCount, CreatedAt: f.CreatedAt,
Contact: types.ContactBrief{},
}
}
func FollowUpList(list []*domain.FollowUp) []types.FollowUpPublic {
out := make([]types.FollowUpPublic, 0, len(list))
for _, f := range list {
if p := FollowUp(f); p != nil {
out = append(out, *p)
}
}
return out
}
func StageCounts(m map[string]int) []types.StageCount {
out := make([]types.StageCount, 0, len(m))
for k, v := range m {
out = append(out, types.StageCount{Stage: k, Count: v})
}
return out
}
func Pagination(page, pageSize int, total int64) types.Pagination {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
tp := int(total) / pageSize
if int(total)%pageSize != 0 {
tp++
}
return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp}
}

View File

@ -0,0 +1,32 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type AcceptOpportunityLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAcceptOpportunityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AcceptOpportunityLogic {
return &AcceptOpportunityLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *AcceptOpportunityLogic) AcceptOpportunity(req *types.AcceptOpportunityReq) (*types.AcceptOpportunityData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, contactID, err := l.svcCtx.Radar.AcceptOpportunity(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return &types.AcceptOpportunityData{OpportunityId: o.ID, ContactId: contactID, Status: o.Status}, nil
}

View File

@ -0,0 +1,36 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ArchiveWatchLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewArchiveWatchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ArchiveWatchLogic {
return &ArchiveWatchLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// ArchiveWatch 是軟刪不再排入每日巡但歷史商機與統計都保留spec §3.1)。
func (l *ArchiveWatchLogic) ArchiveWatch(req *types.WatchIdReq) (resp *types.OkData, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
if err := l.svcCtx.Radar.ArchiveWatch(l.ctx, uid, req.Id); err != nil {
return nil, err
}
return &types.OkData{Ok: true, Message: "watch archived"}, nil
}

View File

@ -0,0 +1,33 @@
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 CreateOpportunityReplyLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateOpportunityReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateOpportunityReplyLogic {
return &CreateOpportunityReplyLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *CreateOpportunityReplyLogic) CreateOpportunityReply(req *types.CreateReplyReq) (*types.ReplyVariantPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
r, err := l.svcCtx.Radar.GenerateReply(l.ctx, uid, req.Id, req.Variant)
if err != nil {
return nil, err
}
return radarmap.Reply(r), nil
}

View File

@ -0,0 +1,43 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateWatchLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateWatchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateWatchLogic {
return &CreateWatchLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateWatchLogic) CreateWatch(req *types.CreateWatchReq) (resp *types.RadarWatchPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
w, err := l.svcCtx.Radar.CreateWatch(l.ctx, uid, radarUC.WatchInput{
Terms: req.Terms,
ExcludeTerms: req.ExcludeTerms,
Regions: req.Regions,
Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
return radarmap.Watch(w), nil
}

View File

@ -0,0 +1,33 @@
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 DismissOpportunityLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDismissOpportunityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DismissOpportunityLogic {
return &DismissOpportunityLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *DismissOpportunityLogic) DismissOpportunity(req *types.DismissOpportunityReq) (*types.OpportunityPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, err := l.svcCtx.Radar.DismissOpportunity(l.ctx, uid, req.Id, req.Reason)
if err != nil {
return nil, err
}
return radarmap.Opportunity(o), nil
}

View File

@ -0,0 +1,33 @@
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 GetOpportunityLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetOpportunityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOpportunityLogic {
return &GetOpportunityLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetOpportunityLogic) GetOpportunity(req *types.OpportunityIdReq) (*types.OpportunityPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, err := l.svcCtx.Radar.GetOpportunity(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return radarmap.Opportunity(o), nil
}

View File

@ -0,0 +1,62 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
radaruc "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRadarTodayLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetRadarTodayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarTodayLogic {
return &GetRadarTodayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetRadarTodayLogic) GetRadarToday() (*types.RadarTodayData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
t, err := l.svcCtx.Radar.GetToday(l.ctx, uid)
if err != nil {
return nil, err
}
return &types.RadarTodayData{
Stats: types.RadarTodayStats{Total: t.Stats.Total, High: t.Stats.High, Mid: t.Stats.Mid, Low: t.Stats.Low},
High: mapTodayCards(t.High),
Mid: mapTodayCards(t.Mid),
Low: mapTodayCards(t.Low),
TruncatedCount: t.TruncatedCount,
LastSweptAt: t.LastSweptAt,
EmptyReason: t.EmptyReason,
EmptyHint: t.EmptyHint,
}, nil
}
func mapTodayCards(cards []radaruc.TodayOpportunity) []types.OpportunityPublic {
out := make([]types.OpportunityPublic, 0, len(cards))
for _, c := range cards {
if c.Opportunity == nil {
continue
}
p := radarmap.Opportunity(c.Opportunity)
if p == nil {
continue
}
if c.DefaultReply != nil {
p.DefaultReply = radarmap.Reply(c.DefaultReply)
}
out = append(out, *p)
}
return out
}

View File

@ -0,0 +1,43 @@
package radar
import (
"context"
"errors"
"apps/backend/internal/logic/radarmap"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetServiceProfileLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetServiceProfileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetServiceProfileLogic {
return &GetServiceProfileLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetServiceProfileLogic) GetServiceProfile() (resp *types.ServiceProfilePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
p, err := l.svcCtx.Radar.GetServiceProfile(l.ctx, uid)
// 還沒建檔不是錯誤:表單要能開空的。誠實之處在 exists=false。
if errors.Is(err, radarDomain.ErrNotFound) {
return radarmap.ServiceProfile(nil), nil
}
if err != nil {
return nil, err
}
return radarmap.ServiceProfile(p), nil
}

View File

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

View File

@ -0,0 +1,37 @@
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 ListOpportunitiesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListOpportunitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOpportunitiesLogic {
return &ListOpportunitiesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesReq) (*types.OpportunityListData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
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,
})
if err != nil {
return nil, err
}
return &types.OpportunityListData{List: radarmap.OpportunityList(list), Pagination: radarmap.Pagination(req.Page, req.PageSize, total)}, nil
}

View File

@ -0,0 +1,33 @@
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 ListOpportunityRepliesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListOpportunityRepliesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOpportunityRepliesLogic {
return &ListOpportunityRepliesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ListOpportunityRepliesLogic) ListOpportunityReplies(req *types.ListRepliesReq) (*types.ReplyListData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
list, err := l.svcCtx.Radar.ListReplies(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return &types.ReplyListData{List: radarmap.ReplyList(list)}, nil
}

View File

@ -0,0 +1,36 @@
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 ListSweepsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListSweepsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListSweepsLogic {
return &ListSweepsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *ListSweepsLogic) ListSweeps(req *types.ListSweepsReq) (*types.SweepListData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
list, total, err := l.svcCtx.Radar.ListSweeps(l.ctx, uid, domain.SweepListFilter{
WatchID: req.WatchId, Page: req.Page, PageSize: req.PageSize,
})
if err != nil {
return nil, err
}
return &types.SweepListData{List: radarmap.SweepList(list), Pagination: radarmap.Pagination(req.Page, req.PageSize, total)}, nil
}

View File

@ -0,0 +1,58 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListWatchesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListWatchesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListWatchesLogic {
return &ListWatchesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.WatchListData, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
filter := radarDomain.WatchListFilter{Status: req.Status, Page: req.Page, PageSize: req.PageSize}
list, total, err := l.svcCtx.Radar.ListWatches(l.ctx, uid, filter)
if err != nil {
return nil, err
}
activeCount, err := l.svcCtx.Radar.CountActiveWatches(l.ctx, uid)
if err != nil {
return nil, err
}
maxActive, err := l.svcCtx.Radar.MaxActiveWatches(l.ctx, uid)
if err != nil {
return nil, err
}
// profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒。
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
if err != nil {
return nil, err
}
return &types.WatchListData{
List: radarmap.WatchList(list),
Pagination: radarmap.Pagination(req.Page, req.PageSize, total),
ActiveCount: int(activeCount),
MaxActive: maxActive,
ProfileExists: hasProfile,
}, nil
}

View File

@ -0,0 +1,348 @@
package radar
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
usageDomain "apps/backend/internal/module/usage/domain"
usageRepo "apps/backend/internal/module/usage/repository"
usageUC "apps/backend/internal/module/usage/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
/*
M1 驗收spec §9.1SP-01SP-02RW-01RW-04
這裡走 handler 呼叫的 logic 層與真的 usecaserepository只有兩處換成假的
儲存層用 memory不需要 mongo 就能跑AI fakeSuggestAI不打 provider不花錢
每個 case 的失敗訊息都帶 spec ID壞掉時能直接對回驗收表
*/
type fakeSuggestAI struct {
reply string
err error
calls int
prompt string
}
func (f *fakeSuggestAI) Complete(_ context.Context, _, _, prompt string) (string, error) {
f.calls++
f.prompt = prompt
return f.reply, f.err
}
func (f *fakeSuggestAI) CompleteStream(ctx context.Context, apiKey, model, prompt string, _ func(string) error) (string, error) {
return f.Complete(ctx, apiKey, model, prompt)
}
func (f *fakeSuggestAI) ListModels(context.Context, string) ([]string, error) { return nil, nil }
const fakeSuggestReply = `[
{"term":"台北 婚攝 推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"},
{"term":"婚禮 攝影 價格","reason":"問價格的人通常已經在比較廠商","usage":"include"},
{"term":"徵 婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"}
]`
type m1Env struct {
ctx context.Context
svcCtx *svc.ServiceContext
ai *fakeSuggestAI
usage *usageUC.Service
uid int64
}
func newM1Env(t *testing.T, uid int64, maxActive int) *m1Env {
t.Helper()
ai := &fakeSuggestAI{reply: fakeSuggestReply}
key := func(meter string) string { return fmt.Sprintf("%d:%s", uid, meter) }
usage := usageUC.New(usageRepo.NewMemory(), &usageUC.StaticResolver{Map: map[string]string{
key(usageDomain.MeterAICopy): usageDomain.KeyModePlatform,
key(usageDomain.MeterAIResearch): usageDomain.KeyModePlatform,
}})
radar := radarUC.New(radarRepo.NewMemory())
radar.Quota = radarUC.FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30}
radar.AI = ai
radar.Usage = usage
return &m1Env{
ctx: middleware.WithUID(context.Background(), uid),
svcCtx: &svc.ServiceContext{Radar: radar},
ai: ai,
usage: usage,
uid: uid,
}
}
func (e *m1Env) seedProfile(t *testing.T) {
t.Helper()
if _, err := NewUpsertServiceProfileLogic(e.ctx, e.svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("seed service profile: %v", err)
}
}
func (e *m1Env) createWatch(t *testing.T, term string, enabled bool) *types.RadarWatchPublic {
t.Helper()
w, err := NewCreateWatchLogic(e.ctx, e.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{term},
Enabled: enabled,
})
if err != nil {
t.Fatalf("create watch %q: %v", term, err)
}
return w
}
func (e *m1Env) list(t *testing.T, status string) *types.WatchListData {
t.Helper()
data, err := NewListWatchesLogic(e.ctx, e.svcCtx).ListWatches(&types.ListWatchesReq{
Page: 1,
PageSize: 50,
Status: status,
})
if err != nil {
t.Fatalf("list watches (status=%q): %v", status, err)
}
return data
}
// sweepCandidates 是每日排程真正會巡的集合RW-02RW-04 的重點就是它有沒有變。
func (e *m1Env) sweepCandidates(t *testing.T) []string {
t.Helper()
list, err := e.svcCtx.Radar.ListActiveWatches(e.ctx, e.uid)
if err != nil {
t.Fatalf("list active watches: %v", err)
}
ids := make([]string, 0, len(list))
for _, w := range list {
ids = append(ids, w.ID)
}
return ids
}
// SP-01新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) {
env := newM1Env(t, 42, 5)
_, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("SP-01: active watch was created without a service profile")
}
envelope := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(envelope.Message, "service-profile") {
t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message)
}
// 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。
if got := env.list(t, ""); got.Pagination.Total != 0 {
t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total)
}
// 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。
env.createWatch(t, "婚攝 推薦", false)
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 {
t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got)
}
}
// SP-02填完服務檔案後 GET 要拿回全部欄位forbidden[] 是回覆生成的硬性過濾詞,尤其不能掉。
func TestM1_SP02_ServiceProfileReadsBackEveryField(t *testing.T) {
env := newM1Env(t, 42, 5)
env.seedProfile(t)
got, err := NewGetServiceProfileLogic(env.ctx, env.svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("SP-02: get service profile: %v", err)
}
if !got.Exists {
t.Fatal("SP-02: exists = false right after saving")
}
if len(got.Services) != 1 || got.Services[0].Name != "婚禮攝影" ||
got.Services[0].PriceMin != 18000 || got.Services[0].PriceMax != 36000 {
t.Fatalf("SP-02: services = %+v", got.Services)
}
if len(got.Forbidden) != 1 || got.Forbidden[0] != "保證接到案" {
t.Fatalf("SP-02: forbidden = %v, want the saved words readable", got.Forbidden)
}
if len(got.Cases) != 1 || len(got.Faq) != 1 || len(got.ServiceAreas) != 1 {
t.Fatalf("SP-02: cases/faq/areas lost: %+v", got)
}
if got.Availability != "平日全天" || got.ToneNote != "親切、不推銷" {
t.Fatalf("SP-02: free-text fields lost: %+v", got)
}
if got.UpdatedAt <= 0 {
t.Fatalf("SP-02: updated_at = %d, want unix nanoseconds", got.UpdatedAt)
}
}
// RW-01上限 1 的方案已有一個 active再建一個要被擋訊息要同時說出上限與升級路徑。
func TestM1_RW01_SecondActiveWatchOverQuotaIsRejected(t *testing.T) {
env := newM1Env(t, 42, 1)
env.seedProfile(t)
first := env.createWatch(t, "婚攝 推薦", true)
_, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"活動紀錄"},
Enabled: true,
})
if err == nil {
t.Fatal("RW-01: second active watch accepted on a 1-watch plan")
}
envelope := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(envelope.Message, "1") || !strings.Contains(envelope.Message, "upgrade") {
t.Fatalf("RW-01: message = %q, want the current limit plus an upgrade hint", envelope.Message)
}
// 擋下不影響既有那筆,也不能偷偷降級它。
after := env.list(t, "")
if after.ActiveCount != 1 || after.MaxActive != 1 {
t.Fatalf("RW-01: quota fields = active %d / max %d", after.ActiveCount, after.MaxActive)
}
if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != first.Id {
t.Fatalf("RW-01: sweep candidates = %v, want only the existing watch", ids)
}
// 停用狀態不佔配額:使用者可以先備好,暫停舊的再啟用。
if _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"活動紀錄"},
Enabled: false,
}); err != nil {
t.Fatalf("RW-01: paused watch should not consume the active quota: %v", err)
}
}
// RW-02pause 後不再排入每日巡,但資料還在,恢復後照樣回到巡的名單。
func TestM1_RW02_PausedWatchLeavesTheDailySweep(t *testing.T) {
env := newM1Env(t, 42, 5)
env.seedProfile(t)
w := env.createWatch(t, "婚攝 推薦", true)
paused, err := NewPauseWatchLogic(env.ctx, env.svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
if err != nil {
t.Fatalf("RW-02: pause: %v", err)
}
if paused.Status != "paused" {
t.Fatalf("RW-02: status = %q after pause", paused.Status)
}
if ids := env.sweepCandidates(t); len(ids) != 0 {
t.Fatalf("RW-02: paused watch is still scheduled for sweeps: %v", ids)
}
// 設定保留:暫停是「先別巡」,不是刪掉。
still := env.list(t, "")
if still.Pagination.Total != 1 || still.ActiveCount != 0 {
t.Fatalf("RW-02: list = %+v, want the watch kept but inactive", still)
}
if len(still.List[0].Terms) != 1 || still.List[0].Terms[0] != "婚攝 推薦" {
t.Fatalf("RW-02: terms lost on pause: %+v", still.List[0])
}
resumed, err := NewResumeWatchLogic(env.ctx, env.svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || resumed.Status != "active" {
t.Fatalf("RW-02: resume = %+v, err = %v", resumed, err)
}
if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != w.Id {
t.Fatalf("RW-02: resumed watch missing from sweeps: %v", ids)
}
}
// RW-03有服務檔案就給得出帶理由的建議而且只是建議 —— 不會自己建立訂閱。
func TestM1_RW03_SuggestReturnsAdoptableTerms(t *testing.T) {
env := newM1Env(t, 42, 5)
env.seedProfile(t)
data, err := NewSuggestWatchTermsLogic(env.ctx, env.svcCtx).SuggestWatchTerms(&types.SuggestWatchTermsReq{})
if err != nil {
t.Fatalf("RW-03: suggest: %v", err)
}
if len(data.List) != 3 {
t.Fatalf("RW-03: got %d suggestions, want 3", len(data.List))
}
for _, s := range data.List {
if strings.TrimSpace(s.Term) == "" || strings.TrimSpace(s.Reason) == "" {
t.Fatalf("RW-03: suggestion without term or reason: %+v", s)
}
if s.Usage != "include" && s.Usage != "exclude" {
t.Fatalf("RW-03: suggestion %q has usage %q", s.Term, s.Usage)
}
}
// prompt 必須帶服務檔案,否則建議跟這個人的生意無關。
if !strings.Contains(env.ai.prompt, "婚禮攝影") {
t.Fatal("RW-03: prompt did not include the member's services")
}
if env.ai.calls != 1 {
t.Fatalf("RW-03: AI called %d times for one suggest request", env.ai.calls)
}
// 建議不建立任何 watch採用與否是使用者的決定。
if got := env.list(t, ""); got.Pagination.Total != 0 {
t.Fatalf("RW-03: suggest created %d watches", got.Pagination.Total)
}
// 逐條採用=把建議當成 create 的輸入,這條路徑要真的走得通。
adopted, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{data.List[0].Term},
ExcludeTerms: []string{data.List[2].Term},
Enabled: true,
})
if err != nil {
t.Fatalf("RW-03: adopting a suggestion failed: %v", err)
}
if len(adopted.Terms) != 1 || len(adopted.ExcludeTerms) != 1 {
t.Fatalf("RW-03: adopted watch = %+v", adopted)
}
// 計費走既有 ai_copysource 標 radar.suggestspec §5.5)。
events, err := env.usage.ListEvents(env.ctx, env.uid, usageDomain.CurrentMonthKey(), "all", 0)
if err != nil {
t.Fatalf("RW-03: list usage events: %v", err)
}
if len(events) != 1 || events[0].Meter != usageDomain.MeterAICopy || events[0].Source != "radar.suggest" {
t.Fatalf("RW-03: usage events = %+v, want one ai_copy/radar.suggest", events)
}
}
// RW-04封存是終點 —— 不再巡、不能恢復,但歷史還看得到。
func TestM1_RW04_ArchivedWatchStopsProducingAndStaysVisible(t *testing.T) {
env := newM1Env(t, 42, 5)
env.seedProfile(t)
keep := env.createWatch(t, "婚攝 推薦", true)
drop := env.createWatch(t, "活動紀錄", true)
if _, err := NewArchiveWatchLogic(env.ctx, env.svcCtx).ArchiveWatch(&types.WatchIdReq{Id: drop.Id}); err != nil {
t.Fatalf("RW-04: archive: %v", err)
}
if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != keep.Id {
t.Fatalf("RW-04: sweep candidates = %v, want only the surviving watch", ids)
}
// 軟刪:列表仍看得到歷史,只是狀態是 archived。
all := env.list(t, "")
if all.Pagination.Total != 2 || all.ActiveCount != 1 {
t.Fatalf("RW-04: list = total %d / active %d, want 2 / 1", all.Pagination.Total, all.ActiveCount)
}
archived := env.list(t, "archived")
if archived.Pagination.Total != 1 || archived.List[0].Id != drop.Id {
t.Fatalf("RW-04: archived filter = %+v", archived.List)
}
// 不可復活:否則配額與統計都會出現無法解釋的跳動。
if _, err := NewResumeWatchLogic(env.ctx, env.svcCtx).ResumeWatch(&types.WatchIdReq{Id: drop.Id}); err == nil {
t.Fatal("RW-04: archived watch was resumed")
} else {
assertStatus(t, err, http.StatusBadRequest, 400100)
}
if _, err := NewPauseWatchLogic(env.ctx, env.svcCtx).PauseWatch(&types.WatchIdReq{Id: drop.Id}); err == nil {
t.Fatal("RW-04: archived watch accepted a pause")
}
}

View File

@ -0,0 +1,37 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type MarkOpportunityReplyUsedLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewMarkOpportunityReplyUsedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkOpportunityReplyUsedLogic {
return &MarkOpportunityReplyUsedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *MarkOpportunityReplyUsedLogic) MarkOpportunityReplyUsed(req *types.MarkReplyUsedReq) (*types.MarkReplyUsedData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
r, advice, err := l.svcCtx.Radar.MarkReplyUsed(l.ctx, uid, req.Id, req.ReplyId, req.Channel)
if err != nil {
return nil, err
}
pub := radarmap.Reply(r)
if pub == nil {
return nil, err
}
return &types.MarkReplyUsedData{Reply: *pub, HealthAdvice: advice}, nil
}

View File

@ -0,0 +1,14 @@
package radar
import (
"fmt"
radarDomain "apps/backend/internal/module/radar/domain"
)
// notReady is returned by every radar capability that is routed but not implemented yet.
// It maps to HTTP 501 / code 501010 in internal/response, so a caller can never mistake
// a missing implementation for a successful empty result.
func notReady(capability string) error {
return fmt.Errorf("%w: %s", radarDomain.ErrNotReady, capability)
}

View File

@ -0,0 +1,34 @@
package radar
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"apps/backend/internal/domain"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/response"
)
// notReady envelope 契約:若日後有殘留未實作 route 仍須回 501501010不可 102000 空成功。
func TestNotReadyEnvelope(t *testing.T) {
err := notReady("example.capability")
if !errors.Is(err, radarDomain.ErrNotReady) {
t.Fatalf("expected ErrNotReady, got %v", err)
}
rec := httptest.NewRecorder()
response.Write(context.Background(), rec, nil, err)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected HTTP 501, got %d", rec.Code)
}
var env response.Envelope
if decErr := json.NewDecoder(rec.Body).Decode(&env); decErr != nil {
t.Fatalf("decode envelope: %v", decErr)
}
if env.Code == domain.SuccessCode {
t.Fatal("not-ready must not use success code")
}
}

View File

@ -0,0 +1,33 @@
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 OverrideOpportunityLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewOverrideOpportunityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OverrideOpportunityLogic {
return &OverrideOpportunityLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *OverrideOpportunityLogic) OverrideOpportunity(req *types.OverrideOpportunityReq) (*types.OpportunityPublic, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
o, err := l.svcCtx.Radar.OverrideOpportunity(l.ctx, uid, req.Id, req.Band, req.Status)
if err != nil {
return nil, err
}
return radarmap.Opportunity(o), nil
}

View File

@ -0,0 +1,22 @@
package radar
import (
"context"
"apps/backend/internal/middleware"
"apps/backend/internal/response"
)
/*
ownerUID 取登入 uid radar 全部資源的隔離依據
request 若帶了 uid 一律忽略整個 group 只讀寫自己的資料spec §5.1§7
少了這道就等於開放任意越權讀寫
*/
func ownerUID(ctx context.Context) (int64, error) {
uid, ok := middleware.UIDFrom(ctx)
if !ok || uid <= 0 {
return 0, response.Biz(401, 401001, "missing authorization")
}
return uid, nil
}

View File

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

View File

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

View File

@ -0,0 +1,164 @@
package radar
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func testCtx(uid int64) (context.Context, *svc.ServiceContext) {
svcCtx := &svc.ServiceContext{Radar: radarUC.New(radarRepo.NewMemory())}
return middleware.WithUID(context.Background(), uid), svcCtx
}
func validProfileReq() *types.UpsertServiceProfileReq {
return &types.UpsertServiceProfileReq{
Services: []types.ServiceItem{{Name: "婚禮攝影", PriceMin: 18000, PriceMax: 36000, Currency: "TWD"}},
Cases: []types.ServiceCasePublic{{Title: "陽明山戶外婚禮"}},
Forbidden: []string{"保證接到案"},
Faq: []types.FaqItem{{Question: "可以加時嗎?", Answer: "可以"}},
ServiceAreas: []string{"TPE"},
Availability: "平日全天",
ToneNote: "親切、不推銷",
}
}
// SP-02填完服務檔案後 GET 要能讀回全部欄位forbidden[] 尤其重要 —— 它是回覆生成的硬性過濾詞。
func TestServiceProfileRoundTripsThroughHTTPTypes(t *testing.T) {
ctx, svcCtx := testCtx(42)
if _, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := NewGetServiceProfileLogic(ctx, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get: %v", err)
}
if !got.Exists {
t.Fatal("exists = false after upsert")
}
if len(got.Services) != 1 || got.Services[0].Name != "婚禮攝影" || got.Services[0].PriceMax != 36000 {
t.Fatalf("services = %+v", got.Services)
}
if len(got.Forbidden) != 1 || got.Forbidden[0] != "保證接到案" {
t.Fatalf("forbidden = %v", got.Forbidden)
}
if len(got.Faq) != 1 || len(got.Cases) != 1 || len(got.ServiceAreas) != 1 {
t.Fatalf("faq/cases/areas lost: %+v", got)
}
if got.Availability != "平日全天" || got.ToneNote != "親切、不推銷" {
t.Fatalf("free-text fields lost: %+v", got)
}
if got.UpdatedAt <= 0 {
t.Fatalf("updated_at = %d, want ns timestamp", got.UpdatedAt)
}
}
// 未建檔要回 exists=false 的 200不是 404表單得先開得起來才有東西填。
func TestGetBeforeFirstSaveReportsAbsentProfile(t *testing.T) {
ctx, svcCtx := testCtx(42)
got, err := NewGetServiceProfileLogic(ctx, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Exists {
t.Fatal("exists = true without any saved profile")
}
// 空陣列而非 null前端直接 map不必每個欄位判 null。
if got.Services == nil || got.Cases == nil || got.Forbidden == nil || got.Faq == nil || got.ServiceAreas == nil {
t.Fatalf("absent profile must use empty slices, got %+v", got)
}
}
func TestServiceProfileIsolatedByJWTOwner(t *testing.T) {
ctxA, svcCtx := testCtx(42)
if _, err := NewUpsertServiceProfileLogic(ctxA, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("upsert as 42: %v", err)
}
// 同一個 store、另一個登入者request 沒有任何欄位能指定 owner所以看不到別人的檔案。
ctxB := middleware.WithUID(context.Background(), 43)
got, err := NewGetServiceProfileLogic(ctxB, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get as 43: %v", err)
}
if got.Exists {
t.Fatal("owner 43 read owner 42's service profile")
}
// 43 覆寫自己的檔案不會動到 42 的。
req := validProfileReq()
req.Services[0].Name = "另一種服務"
if _, err := NewUpsertServiceProfileLogic(ctxB, svcCtx).UpsertServiceProfile(req); err != nil {
t.Fatalf("upsert as 43: %v", err)
}
back, err := NewGetServiceProfileLogic(ctxA, svcCtx).GetServiceProfile()
if err != nil {
t.Fatalf("get as 42: %v", err)
}
if back.Services[0].Name != "婚禮攝影" {
t.Fatalf("owner 42's profile was overwritten by owner 43: %+v", back.Services)
}
}
func TestServiceProfileRequiresAuth(t *testing.T) {
svcCtx := &svc.ServiceContext{Radar: radarUC.New(radarRepo.NewMemory())}
anon := context.Background()
if _, err := NewGetServiceProfileLogic(anon, svcCtx).GetServiceProfile(); err == nil {
t.Fatal("GET without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
if _, err := NewUpsertServiceProfileLogic(anon, svcCtx).UpsertServiceProfile(validProfileReq()); err == nil {
t.Fatal("PUT without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
}
// 驗證失敗要是明確的 400100能對到欄位的訊息不是 500 也不是空成功。
func TestInvalidProfileMapsToValidationError(t *testing.T) {
ctx, svcCtx := testCtx(42)
req := validProfileReq()
req.Services[0].PriceMin = 50000
req.Services[0].PriceMax = 10000
_, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(req)
if err == nil {
t.Fatal("reversed price range was accepted")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if env.Message == "" {
t.Fatal("validation error carried no message")
}
}
func assertStatus(t *testing.T, err error, wantHTTP int, wantCode int64) response.Envelope {
t.Helper()
rec := httptest.NewRecorder()
response.Write(context.Background(), rec, nil, err)
if rec.Code != wantHTTP {
t.Fatalf("HTTP = %d, want %d (err=%v)", rec.Code, wantHTTP, err)
}
var env response.Envelope
if decErr := json.NewDecoder(rec.Body).Decode(&env); decErr != nil {
t.Fatalf("decode envelope: %v", decErr)
}
if env.Code != wantCode {
t.Fatalf("code = %d, want %d", env.Code, wantCode)
}
return env
}

View File

@ -0,0 +1,41 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type SuggestWatchTermsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSuggestWatchTermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SuggestWatchTermsLogic {
return &SuggestWatchTermsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// SuggestWatchTerms 只回建議,不寫入任何 watch使用者要逐條決定採不採用RW-03
func (l *SuggestWatchTermsLogic) SuggestWatchTerms(req *types.SuggestWatchTermsReq) (resp *types.WatchSuggestData, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
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})
}
return &types.WatchSuggestData{List: out}, nil
}

View File

@ -0,0 +1,32 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type TriggerWatchSweepLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewTriggerWatchSweepLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TriggerWatchSweepLogic {
return &TriggerWatchSweepLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *TriggerWatchSweepLogic) TriggerWatchSweep(req *types.WatchIdReq) (*types.TriggerSweepData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
jobID, err := l.svcCtx.Radar.TriggerSweep(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
return &types.TriggerSweepData{JobId: jobID}, nil
}

View File

@ -0,0 +1,52 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateWatchLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateWatchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWatchLogic {
return &UpdateWatchLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateWatchLogic) UpdateWatch(req *types.UpdateWatchReq) (resp *types.RadarWatchPublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
/*
欄位缺席nil不動 [] 清空差別很重要只想改地區的請求不該
把關鍵字一起清掉而想清掉排除詞的人也得有辦法表達
*/
patch := radarUC.WatchPatch{}
if req.Terms != nil {
patch.Terms = &req.Terms
}
if req.ExcludeTerms != nil {
patch.ExcludeTerms = &req.ExcludeTerms
}
if req.Regions != nil {
patch.Regions = &req.Regions
}
w, err := l.svcCtx.Radar.UpdateWatch(l.ctx, uid, req.Id, patch)
if err != nil {
return nil, err
}
return radarmap.Watch(w), nil
}

View File

@ -0,0 +1,37 @@
package radar
import (
"context"
"apps/backend/internal/logic/radarmap"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpsertServiceProfileLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpsertServiceProfileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertServiceProfileLogic {
return &UpsertServiceProfileLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpsertServiceProfileLogic) UpsertServiceProfile(req *types.UpsertServiceProfileReq) (resp *types.ServiceProfilePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
p, err := l.svcCtx.Radar.UpsertServiceProfile(l.ctx, uid, radarmap.ServiceProfileInput(req))
if err != nil {
return nil, err
}
return radarmap.ServiceProfile(p), nil
}

View File

@ -0,0 +1,228 @@
package radar
import (
"context"
"net/http"
"strings"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func watchCtx(t *testing.T, uid int64, maxActive int, withProfile bool) (context.Context, *svc.ServiceContext) {
t.Helper()
radar := radarUC.New(radarRepo.NewMemory())
radar.Quota = radarUC.FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30}
svcCtx := &svc.ServiceContext{Radar: radar}
ctx := middleware.WithUID(context.Background(), uid)
if withProfile {
if _, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("seed profile: %v", err)
}
}
return ctx, svcCtx
}
func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, term string, enabled bool) *types.RadarWatchPublic {
t.Helper()
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{term},
Enabled: enabled,
})
if err != nil {
t.Fatalf("create watch %q: %v", term, err)
}
return w
}
// SP-01沒建服務檔案就建 active 訂閱 → 400100訊息要指向服務檔案而不是只說「失敗」。
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, false)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("active watch created without a service profile")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "service-profile") {
t.Fatalf("message must point at the service profile, got %q", env.Message)
}
}
// RW-01上限 1 時第二個 active 被拒,訊息要有目前上限與升級提示。
func TestCreateWatchOverQuotaCarriesLimitAndUpgradeHint(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 1, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"活動紀錄"},
Enabled: true,
})
if err == nil {
t.Fatal("second active watch accepted on a 1-watch plan")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "1") || !strings.Contains(env.Message, "upgrade") {
t.Fatalf("message = %q, want the current limit plus an upgrade hint", env.Message)
}
}
func TestListWatchesReportsQuotaAndProfileState(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
createWatch(t, ctx, svcCtx, "活動紀錄", false)
data, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(data.List) != 2 || data.Pagination.Total != 2 {
t.Fatalf("list = %d items, total = %d", len(data.List), data.Pagination.Total)
}
// 前端要靠這三個值在建立之前就決定顯示引導還是升級提示。
if data.ActiveCount != 1 || data.MaxActive != 5 || !data.ProfileExists {
t.Fatalf("quota fields = active %d / max %d / profile %v", data.ActiveCount, data.MaxActive, data.ProfileExists)
}
paused, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20, Status: "paused"})
if err != nil {
t.Fatalf("list paused: %v", err)
}
if len(paused.List) != 1 || paused.List[0].Status != "paused" {
t.Fatalf("status filter returned %+v", paused.List)
}
}
// RW-02RW-04pause 退出 active 清單archive 之後不能 resume。
func TestPauseResumeArchiveThroughHTTPLayer(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
paused, err := NewPauseWatchLogic(ctx, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || paused.Status != "paused" {
t.Fatalf("pause = %+v, err = %v", paused, err)
}
after, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil || after.ActiveCount != 0 {
t.Fatalf("active_count = %d after pause (err %v)", after.ActiveCount, err)
}
resumed, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || resumed.Status != "active" {
t.Fatalf("resume = %+v, err = %v", resumed, err)
}
ok, err := NewArchiveWatchLogic(ctx, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || !ok.Ok {
t.Fatalf("archive = %+v, err = %v", ok, err)
}
if _, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id}); err == nil {
t.Fatal("archived watch was resumed")
} else {
assertStatus(t, err, http.StatusBadRequest, 400100)
}
// 軟刪:列表還看得到,只是不再是 active。
list, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list.List) != 1 || list.List[0].Status != "archived" || list.ActiveCount != 0 {
t.Fatalf("archive should be a soft delete, got %+v", list.List)
}
}
func TestUpdateWatchLeavesOmittedFieldsAlone(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
created, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
ExcludeTerms: []string{"徵才"},
Regions: []string{"TPE"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
updated, err := NewUpdateWatchLogic(ctx, svcCtx).UpdateWatch(&types.UpdateWatchReq{
Id: created.Id,
Regions: []string{"KHH"},
})
if err != nil {
t.Fatalf("update: %v", err)
}
if len(updated.Terms) != 1 || len(updated.ExcludeTerms) != 1 {
t.Fatalf("omitted fields were cleared: %+v", updated)
}
if len(updated.Regions) != 1 || updated.Regions[0] != "KHH" {
t.Fatalf("regions = %v", updated.Regions)
}
}
func TestWatchEndpointsRejectOtherOwners(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
other := middleware.WithUID(context.Background(), 43)
for name, call := range map[string]func() error{
"get": func() error {
_, err := NewGetWatchLogic(other, svcCtx).GetWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"pause": func() error {
_, err := NewPauseWatchLogic(other, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"resume": func() error {
_, err := NewResumeWatchLogic(other, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"archive": func() error {
_, err := NewArchiveWatchLogic(other, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"update": func() error {
_, err := NewUpdateWatchLogic(other, svcCtx).UpdateWatch(&types.UpdateWatchReq{Id: w.Id, Regions: []string{"KHH"}})
return err
},
} {
t.Run(name, func(t *testing.T) {
err := call()
if err == nil {
t.Fatalf("owner 43 could %s owner 42's watch", name)
}
assertStatus(t, err, http.StatusForbidden, 403003)
})
}
}
func TestWatchEndpointsRequireAuth(t *testing.T) {
_, svcCtx := watchCtx(t, 42, 5, true)
anon := context.Background()
if _, err := NewListWatchesLogic(anon, svcCtx).ListWatches(&types.ListWatchesReq{}); err == nil {
t.Fatal("list without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
if _, err := NewCreateWatchLogic(anon, svcCtx).CreateWatch(&types.CreateWatchReq{Terms: []string{"婚攝"}}); err == nil {
t.Fatal("create without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
}
func TestMissingWatchReportsNotFound(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
if _, err := NewGetWatchLogic(ctx, svcCtx).GetWatch(&types.WatchIdReq{Id: "does-not-exist"}); err == nil {
t.Fatal("missing watch returned success")
} else {
assertStatus(t, err, http.StatusNotFound, 404001)
}
}

View File

@ -0,0 +1,228 @@
package radarmap
import (
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/types"
)
/*
ServiceProfile domain 檔案轉成 API 形狀
p nil 代表使用者還沒建檔 exists=false 的空殼而不是 404 表單本來就要能開空的
exists 這個欄位必須誠實訂閱閘門SP-01與前端引導都看它
*/
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
if p == nil {
return &types.ServiceProfilePublic{
Exists: false,
Services: []types.ServiceItem{},
Cases: []types.ServiceCasePublic{},
Forbidden: []string{},
Faq: []types.FaqItem{},
ServiceAreas: []string{},
}
}
services := make([]types.ServiceItem, 0, len(p.Services))
for _, s := range p.Services {
services = append(services, types.ServiceItem{
Name: s.Name,
PriceMin: s.PriceMin,
PriceMax: s.PriceMax,
Currency: s.Currency,
})
}
cases := make([]types.ServiceCasePublic, 0, len(p.Cases))
for _, c := range p.Cases {
cases = append(cases, types.ServiceCasePublic{Title: c.Title, Summary: c.Summary, Link: c.Link})
}
faq := make([]types.FaqItem, 0, len(p.Faq))
for _, f := range p.Faq {
faq = append(faq, types.FaqItem{Question: f.Question, Answer: f.Answer})
}
forbidden := p.Forbidden
if forbidden == nil {
forbidden = []string{}
}
areas := p.ServiceAreas
if areas == nil {
areas = []string{}
}
return &types.ServiceProfilePublic{
Exists: true,
Services: services,
Cases: cases,
Forbidden: forbidden,
Faq: faq,
ServiceAreas: areas,
RemoteOk: p.RemoteOk,
Availability: p.Availability,
ToneNote: p.ToneNote,
UpdatedAt: p.UpdatedAt,
}
}
func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
if w == nil {
return nil
}
terms, excludes, regions := w.Terms, w.ExcludeTerms, w.Regions
if terms == nil {
terms = []string{}
}
if excludes == nil {
excludes = []string{}
}
if regions == nil {
regions = []string{}
}
return &types.RadarWatchPublic{
Id: w.ID,
Terms: terms,
ExcludeTerms: excludes,
Regions: regions,
Status: w.Status,
LastSweptAt: w.LastSweptAt,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
func WatchList(list []*domain.RadarWatch) []types.RadarWatchPublic {
out := make([]types.RadarWatchPublic, 0, len(list))
for _, w := range list {
if p := Watch(w); p != nil {
out = append(out, *p)
}
}
return out
}
func Pagination(page, pageSize int, total int64) types.Pagination {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
tp := int(total) / pageSize
if int(total)%pageSize != 0 {
tp++
}
return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp}
}
// ServiceProfileInput 把 PUT 的 request 轉成 domain。owner_uid 一律由 logic 從 JWT 帶入。
func ServiceProfileInput(req *types.UpsertServiceProfileReq) *domain.ServiceProfile {
if req == nil {
return nil
}
services := make([]domain.ServiceItem, 0, len(req.Services))
for _, s := range req.Services {
services = append(services, domain.ServiceItem{
Name: s.Name,
PriceMin: s.PriceMin,
PriceMax: s.PriceMax,
Currency: s.Currency,
})
}
cases := make([]domain.ServiceCase, 0, len(req.Cases))
for _, c := range req.Cases {
cases = append(cases, domain.ServiceCase{Title: c.Title, Summary: c.Summary, Link: c.Link})
}
faq := make([]domain.FaqItem, 0, len(req.Faq))
for _, f := range req.Faq {
faq = append(faq, domain.FaqItem{Question: f.Question, Answer: f.Answer})
}
return &domain.ServiceProfile{
Services: services,
Cases: cases,
Forbidden: req.Forbidden,
Faq: faq,
ServiceAreas: req.ServiceAreas,
RemoteOk: req.RemoteOk,
Availability: req.Availability,
ToneNote: req.ToneNote,
}
}
func Opportunity(o *domain.Opportunity) *types.OpportunityPublic {
if o == nil {
return nil
}
reasons := make([]types.OpportunityReason, 0, len(o.Reasons))
for _, r := range o.Reasons {
reasons = append(reasons, types.OpportunityReason{Dimension: r.Dimension, Score: r.Score, Reason: r.Reason})
}
terms := o.MatchedTerms
if terms == nil {
terms = []string{}
}
out := &types.OpportunityPublic{
Id: o.ID, WatchId: o.WatchID, Source: o.Source, SourceScoutPostId: o.SourceScoutPostID,
ExternalId: o.ExternalID, Permalink: o.Permalink, AuthorHandle: o.AuthorHandle, Text: o.Text,
PostedAt: o.PostedAt, Status: o.Status, IntentScore: o.IntentScore, IntentBand: o.IntentBand,
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,
}
if o.Override != nil {
out.Override = &types.OpportunityOverride{
FromBand: o.Override.FromBand, ToBand: o.Override.ToBand,
FromStatus: o.Override.FromStatus, ToStatus: o.Override.ToStatus,
ActorUid: o.Override.ActorUID, At: o.Override.At,
}
}
return out
}
func OpportunityList(list []*domain.Opportunity) []types.OpportunityPublic {
out := make([]types.OpportunityPublic, 0, len(list))
for _, o := range list {
if p := Opportunity(o); p != nil {
out = append(out, *p)
}
}
return out
}
func Sweep(s *domain.RadarSweep) *types.RadarSweepPublic {
if s == nil {
return nil
}
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,
}
}
func SweepList(list []*domain.RadarSweep) []types.RadarSweepPublic {
out := make([]types.RadarSweepPublic, 0, len(list))
for _, s := range list {
if p := Sweep(s); p != nil {
out = append(out, *p)
}
}
return out
}
func Reply(r *domain.ReplyVariant) *types.ReplyVariantPublic {
if r == nil {
return nil
}
return &types.ReplyVariantPublic{
Id: r.ID, OpportunityId: r.OpportunityID, Variant: r.Variant, Text: r.Text,
UsedAt: r.UsedAt, SentChannel: r.SentChannel, CreatedAt: r.CreatedAt,
}
}
func ReplyList(list []*domain.ReplyVariant) []types.ReplyVariantPublic {
out := make([]types.ReplyVariantPublic, 0, len(list))
for _, r := range list {
if p := Reply(r); p != nil {
out = append(out, *p)
}
}
return out
}

View File

@ -0,0 +1,52 @@
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 PromoteScoutPostLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPromoteScoutPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PromoteScoutPostLogic {
return &PromoteScoutPostLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *PromoteScoutPostLogic) PromoteScoutPost(req *types.ScoutPostIdPath) (*types.PromoteScoutPostData, error) {
if l.svcCtx.Scout == nil || l.svcCtx.Radar == nil {
return nil, response.Biz(503, 503001, "scout/radar not configured")
}
uid, ok := middleware.UIDFrom(l.ctx)
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
p, err := l.svcCtx.Scout.GetPost(l.ctx, uid, req.Id)
if err != nil {
return nil, err
}
externalID := p.ExternalID
if externalID == "" {
externalID = p.ID
}
o, err := l.svcCtx.Radar.PromoteFromScout(
l.ctx, uid, p.ID, externalID, p.Permalink, p.Author, p.Text, p.CreatedAt,
)
if err != nil {
return nil, err
}
return &types.PromoteScoutPostData{
OpportunityId: o.ID,
Status: o.Status,
IntentBand: o.IntentBand,
IntentScore: o.IntentScore,
}, nil
}

View File

@ -26,6 +26,12 @@ func UIDFrom(ctx context.Context) (int64, bool) {
return v, ok return v, ok
} }
// WithUID 讓 logic 層測試能組出「已登入」的 ctx。ctxUID 是私有型別,
// 沒有這個入口就只能繞過 UIDFrom 驗權,那測到的就不是真的授權路徑了。
func WithUID(ctx context.Context, uid int64) context.Context {
return context.WithValue(ctx, ctxUID, uid)
}
func RolesFrom(ctx context.Context) []string { func RolesFrom(ctx context.Context) []string {
v, _ := ctx.Value(ctxRoles).([]string) v, _ := ctx.Value(ctxRoles).([]string)
return v return v

View File

@ -0,0 +1,129 @@
package domain
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
func NowNano() int64 { return time.Now().UTC().UnixNano() }
func NewID() string { return uuid.NewString() }
const (
StageNewFound = "new_found"
StageEngaged = "engaged"
StageDMSent = "dm_sent"
StageReplied = "replied"
StageQuoted = "quoted"
StageWon = "won"
StageLost = "lost"
PlatformThreads = "threads"
TouchStage = "stage"
TouchReply = "reply"
TouchNote = "note"
TouchConversion = "conversion"
FollowUpScheduled = "scheduled"
FollowUpNotified = "notified"
FollowUpDone = "done"
FollowUpSnoozed = "snoozed"
FollowUpEscalated = "escalated"
DefaultFollowUpDays = 3
)
type Contact struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
SourcePlatform string `bson:"source_platform" json:"source_platform"`
AuthorHandle string `bson:"author_handle" json:"author_handle"`
DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"`
Stage string `bson:"stage" json:"stage"`
NeedsFollowUp bool `bson:"needs_follow_up" json:"needs_follow_up"`
FollowUpDays int `bson:"follow_up_days" json:"follow_up_days"`
LastTouchAt int64 `bson:"last_touch_at,omitempty" json:"last_touch_at,omitempty"`
OpportunityIDs []string `bson:"opportunity_ids" json:"opportunity_ids"`
MergedFrom []string `bson:"merged_from,omitempty" json:"merged_from,omitempty"`
TopIntentBand string `bson:"top_intent_band,omitempty" json:"top_intent_band,omitempty"`
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"`
}
type ContactTouch struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
ContactID string `bson:"contact_id" json:"contact_id"`
Type string `bson:"type" json:"type"`
FromStage string `bson:"from_stage,omitempty" json:"from_stage,omitempty"`
ToStage string `bson:"to_stage,omitempty" json:"to_stage,omitempty"`
Body string `bson:"body,omitempty" json:"body,omitempty"`
ActorUID int64 `bson:"actor_uid" json:"actor_uid"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
}
type FollowUp struct {
ID string `bson:"_id" json:"id"`
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
ContactID string `bson:"contact_id" json:"contact_id"`
DueAt int64 `bson:"due_at" json:"due_at"`
Status string `bson:"status" json:"status"`
NotifiedCount int `bson:"notified_count" json:"notified_count"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type ContactListFilter struct {
Stage string
FollowUp *bool
Band string
Sort string // last_touch_at | intent_score
Page int
PageSize int
}
type FollowUpListFilter struct {
Status string
Page int
PageSize int
}
func IsStage(s string) bool {
switch s {
case StageNewFound, StageEngaged, StageDMSent, StageReplied, StageQuoted, StageWon, StageLost:
return true
}
return false
}
func (c *Contact) Normalize() error {
if c.OwnerUID <= 0 {
return fmt.Errorf("%w: owner_uid required", ErrValidation)
}
c.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(c.AuthorHandle), "@")
if c.AuthorHandle == "" {
return fmt.Errorf("%w: author_handle required", ErrValidation)
}
if c.SourcePlatform == "" {
c.SourcePlatform = PlatformThreads
}
if c.Stage == "" {
c.Stage = StageNewFound
}
if !IsStage(c.Stage) {
return fmt.Errorf("%w: unknown stage %q", ErrValidation, c.Stage)
}
if c.FollowUpDays <= 0 {
c.FollowUpDays = DefaultFollowUpDays
}
if c.OpportunityIDs == nil {
c.OpportunityIDs = []string{}
}
return nil
}

View File

@ -0,0 +1,12 @@
package domain
import "errors"
var (
ErrNotFound = errors.New("crm not found")
ErrForbidden = errors.New("crm access denied")
ErrValidation = errors.New("crm validation")
// ErrNotReady marks a crm capability whose route exists but has no implementation yet.
// Returning it keeps the contract honest: never answer 102000 with empty data.
ErrNotReady = errors.New("crm capability not ready")
)

View File

@ -0,0 +1,20 @@
package domain
import "context"
type Repository interface {
// Contact unique key: owner_uid + source_platform + author_handle
UpsertContactByIdentity(ctx context.Context, c *Contact) (*Contact, error)
GetContact(ctx context.Context, id string) (*Contact, error)
SaveContact(ctx context.Context, c *Contact) error
ListContacts(ctx context.Context, ownerUID int64, f ContactListFilter) ([]*Contact, int64, error)
CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error)
InsertTouch(ctx context.Context, t *ContactTouch) error
ListTouches(ctx context.Context, ownerUID int64, contactID string, page, pageSize int) ([]*ContactTouch, int64, error)
SaveFollowUp(ctx context.Context, f *FollowUp) error
GetFollowUp(ctx context.Context, id string) (*FollowUp, error)
ListFollowUps(ctx context.Context, ownerUID int64, f FollowUpListFilter) ([]*FollowUp, int64, error)
ListDueFollowUps(ctx context.Context, now int64, limit int) ([]*FollowUp, error)
}

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