Compare commits
No commits in common. "feat/new_feat" and "main" have entirely different histories.
feat/new_f
...
main
|
|
@ -21,7 +21,6 @@ test-integration:
|
|||
./internal/module/usage/... ./internal/module/ai/... ./internal/module/search/... \
|
||||
./internal/module/permission/... ./internal/module/studio/... \
|
||||
./internal/module/inspire/... ./internal/module/scout/... \
|
||||
./internal/module/radar/... ./internal/logic/radar/ \
|
||||
./internal/logic/extension/ ./internal/logic/media/ \
|
||||
-count=1 -timeout 180s
|
||||
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ func ownerIndex(sortField, name string) mongo.IndexModel {
|
|||
// 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
|
||||
// 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 {
|
||||
return map[string][]mongo.IndexModel{
|
||||
// members.uid is read on every authenticated request via the auth middleware, and it is a
|
||||
|
|
@ -116,7 +112,7 @@ func indexModels() map[string][]mongo.IndexModel {
|
|||
ownerIndex("created_at", "owner_posts_created"),
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_brand_posts")},
|
||||
},
|
||||
"scout_homework": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_homework")}},
|
||||
"scout_homework": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_homework")}},
|
||||
"inspire_elements": {ownerIndex("updated_at", "owner_elements_updated")},
|
||||
"inspire_sessions": {ownerIndex("updated_at", "owner_sessions_updated")},
|
||||
"usage_events": {
|
||||
|
|
@ -158,37 +154,6 @@ func indexModels() map[string][]mongo.IndexModel {
|
|||
"growth_ws_members": {
|
||||
{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": {
|
||||
{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")},
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
// 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])
|
||||
}
|
||||
|
|
@ -30,10 +30,6 @@ import (
|
|||
scoutRepo "apps/backend/internal/module/scout/repository"
|
||||
growthRepo "apps/backend/internal/module/growth/repository"
|
||||
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"
|
||||
studioPublish "apps/backend/internal/module/studio/publish"
|
||||
studioRepo "apps/backend/internal/module/studio/repository"
|
||||
|
|
@ -158,26 +154,7 @@ func main() {
|
|||
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
|
||||
}
|
||||
|
||||
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)
|
||||
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox + growth)", workerID, interval)
|
||||
fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
|
|
@ -254,14 +231,6 @@ func main() {
|
|||
logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err)
|
||||
_, _ = 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:
|
||||
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
|
||||
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
|
||||
|
|
@ -269,9 +238,9 @@ func main() {
|
|||
}
|
||||
// 2) One worker owns an outbox tick and renews its lease while claims run.
|
||||
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
|
||||
// 3) 巡場維護:過期 outcome + 清終態 job + 雷達每日排程 + 追蹤掃描(單一 worker、低頻)
|
||||
// 3) 巡場維護:過期 outcome + 清終態 job(單一 worker、低頻)
|
||||
if time.Since(lastMaintenance) >= maintenanceEvery {
|
||||
if runMaintenance(ctx, growthSvc, jobs, radarSvc, crmSvc, maintenanceLock, workerID) {
|
||||
if runMaintenance(ctx, growthSvc, jobs, maintenanceLock, workerID) {
|
||||
lastMaintenance = time.Now()
|
||||
}
|
||||
}
|
||||
|
|
@ -285,8 +254,6 @@ func runMaintenance(
|
|||
ctx context.Context,
|
||||
growthSvc *growthUC.Service,
|
||||
jobs *jobUC.Service,
|
||||
radarSvc *radarUC.Service,
|
||||
crmSvc *crmUC.Service,
|
||||
lock *redislock.Lock,
|
||||
workerID string,
|
||||
) bool {
|
||||
|
|
@ -314,75 +281,9 @@ func runMaintenance(
|
|||
} else if purged > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
locked, err := lock.Acquire(ctx)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
syntax = "v1"
|
||||
|
||||
// demand-radar: contacts / touches / follow-ups / conversion / stats
|
||||
// spec: docs/product/demand-radar/spec.md §5.2
|
||||
// 未實作能力一律回 501(crmDomain.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)
|
||||
}
|
||||
|
|
@ -25,5 +25,3 @@ import "m5.api"
|
|||
import "invite.api"
|
||||
import "growth.api"
|
||||
import "growth_p2.api"
|
||||
import "radar.api"
|
||||
import "crm.api"
|
||||
|
|
|
|||
|
|
@ -312,14 +312,6 @@ type (
|
|||
ScoutCrawlerSessionReq {
|
||||
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 (
|
||||
|
|
@ -458,9 +450,6 @@ service gateway {
|
|||
@handler SendOutreach
|
||||
post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic)
|
||||
|
||||
@handler PromoteScoutPost
|
||||
post /posts/:id/promote (ScoutPostIdPath) returns (PromoteScoutPostData)
|
||||
|
||||
@handler RemoveScoutPost
|
||||
delete /posts/:id (ScoutPostIdPath) returns (OkData)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,350 +0,0 @@
|
|||
syntax = "v1"
|
||||
|
||||
// demand-radar: service profile / radar watches / sweeps / opportunities / replies
|
||||
// spec: docs/product/demand-radar/spec.md §5.2
|
||||
// 未實作能力一律回 501(radarDomain.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_copy(dm 僅 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)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
[
|
||||
{ "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" }
|
||||
]
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
[
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
billing "apps/backend/internal/handler/billing"
|
||||
checkups "apps/backend/internal/handler/checkups"
|
||||
compose "apps/backend/internal/handler/compose"
|
||||
crm "apps/backend/internal/handler/crm"
|
||||
extension "apps/backend/internal/handler/extension"
|
||||
health "apps/backend/internal/handler/health"
|
||||
insightsapi "apps/backend/internal/handler/insightsapi"
|
||||
|
|
@ -32,7 +31,6 @@ import (
|
|||
proxy "apps/backend/internal/handler/proxy"
|
||||
publictools "apps/backend/internal/handler/publictools"
|
||||
publicutm "apps/backend/internal/handler/publicutm"
|
||||
radar "apps/backend/internal/handler/radar"
|
||||
research "apps/backend/internal/handler/research"
|
||||
scout "apps/backend/internal/handler/scout"
|
||||
settings "apps/backend/internal/handler/settings"
|
||||
|
|
@ -317,90 +315,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
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(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -618,11 +532,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/upload",
|
||||
Handler: media.UploadHandler(serverCtx),
|
||||
Path: "/generate-image",
|
||||
Handler: media.GenerateImageHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||
rest.WithPrefix("/api/v1/media"),
|
||||
)
|
||||
|
||||
|
|
@ -632,12 +547,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/generate-image",
|
||||
Handler: media.GenerateImageHandler(serverCtx),
|
||||
Path: "/upload",
|
||||
Handler: media.UploadHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||
rest.WithPrefix("/api/v1/media"),
|
||||
)
|
||||
|
||||
|
|
@ -1027,121 +941,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
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(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -1246,11 +1045,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/posts/:id/mark-published",
|
||||
Handler: scout.MarkPublishedHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/posts/:id/promote",
|
||||
Handler: scout.PromoteScoutPostHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/posts/:id/send",
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
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.1):SP-01、SP-02、RW-01~RW-04。
|
||||
|
||||
這裡走 handler 呼叫的 logic 層與真的 usecase/repository,只有兩處換成假的:
|
||||
儲存層用 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-02/RW-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-02:pause 後不再排入每日巡,但資料還在,恢復後照樣回到巡的名單。
|
||||
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_copy,source 標 radar.suggest(spec §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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 仍須回 501/501010,不可 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
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-02/RW-04:pause 退出 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -26,12 +26,6 @@ func UIDFrom(ctx context.Context) (int64, bool) {
|
|||
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 {
|
||||
v, _ := ctx.Value(ctxRoles).([]string)
|
||||
return v
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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")
|
||||
)
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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
Loading…
Reference in New Issue