Compare commits

..

No commits in common. "main" and "feat/frontend" have entirely different histories.

666 changed files with 4600 additions and 64947 deletions

View File

@ -31,7 +31,7 @@ B 未過關前,不大寫業務後端。
- 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。
- 不複製任天堂 / Nook UI不用 emoji 當主 icon。
- 字體Inter + Noto Sans TC中文單一家族可變字重 400700
- 字體Inter + Taipei Sans TC
- mock | live 同一 repository 介面。
- 詳見 `docs/product/haixun-console/spec.md`

View File

@ -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

View File

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

View File

@ -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])
}

View File

@ -18,22 +18,18 @@ import (
"apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase"
crmRepo "apps/backend/internal/module/crm/repository"
crmUC "apps/backend/internal/module/crm/usecase"
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
growthRepo "apps/backend/internal/module/growth/repository"
growthUC "apps/backend/internal/module/growth/usecase"
jobDomain "apps/backend/internal/module/job/domain"
jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase"
memberDomain "apps/backend/internal/module/member/domain"
memberRepo "apps/backend/internal/module/member/repository"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
growthRepo "apps/backend/internal/module/growth/repository"
growthUC "apps/backend/internal/module/growth/usecase"
scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository"
@ -158,35 +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))
// Product watches are validated again inside the worker at execution time.
// Keep the worker on the same read-only Scout catalog bridge as the gateway;
// without it queued product sweeps fail with "product catalog unavailable".
radarSvc.ProductSource = &workerRadarProductContextBridge{Scout: scoutSvc}
radarSvc.Usage = usageSvc
radarSvc.HitFetch = radarUC.HitFetcherFunc(func(ctx context.Context, ownerUID int64, terms []string, limit int) ([]radarUC.ThreadHit, string, error) {
hits, path, err := scoutSvc.SearchHitsOnly(ctx, ownerUID, terms, limit)
if err != nil {
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, PublishedAt: h.PublishedAt})
}
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)
@ -263,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)
@ -278,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()
}
}
@ -294,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 {
@ -323,109 +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)
}
type workerRadarProductContextBridge struct {
Scout *scoutUC.Service
}
func (b *workerRadarProductContextBridge) GetBrand(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) {
brand, err := b.Scout.GetBrand(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return &radarUC.ProductBrand{
ID: brand.ID, OwnerUID: brand.OwnerUID, DisplayName: brand.DisplayName,
TargetAudience: brand.TargetAudience, UpdatedAt: brand.UpdatedAt,
}, nil
}
func (b *workerRadarProductContextBridge) GetProduct(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) {
product, err := b.Scout.GetProduct(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return &radarUC.ProductCatalogProduct{
ID: product.ID, OwnerUID: product.OwnerUID, BrandID: product.BrandID,
Label: product.Label, ProductContext: product.ProductContext,
PainPoints: product.PainPoints, MatchTags: product.MatchTags,
ProviderCapabilityTerms: product.ProviderCapabilityTerms,
ProviderExcludeTerms: product.ProviderExcludeTerms, UpdatedAt: product.UpdatedAt,
}, nil
}
// runRadarSweep executes fetch → judge → persist for one watch.
func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Service, j *jobDomain.Job) error {
var payload jobUC.RadarSweepPayload
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 {
// RunSweep persists a user-facing reason on fetch failures. Return that
// instead of leaking the crawler/provider transport response into Jobs UI.
if res != nil && res.FetchFailed && strings.TrimSpace(res.FailedReason) != "" {
return errors.New(res.FailedReason)
}
return err
}
if res.FetchFailed {
return fmt.Errorf("%s", res.FailedReason)
}
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 再次命中 %d · 判定 %d · 截斷 %d", res.Created, res.Rematched, res.Judged, res.Truncated)
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 {
@ -487,57 +345,20 @@ func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redisloc
}
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
var payload struct {
RunID string `json:"run_id"`
scoutDomain.RunBrief
}
if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil {
var brief scoutDomain.RunBrief
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
return fmt.Errorf("invalid scout scan payload: %w", err)
}
if payload.RunID == "" {
// 部署前排入的舊 job 只存平面 RunBriefrun id 一律等於 Job.RefID
payload.RunID = j.RefID
}
if payload.RunID == "" || payload.RunID != j.RefID {
return fmt.Errorf("scout run/job reference mismatch")
}
run, err := scout.GetRun(ctx, j.OwnerUID, payload.RunID)
if err != nil {
return err
}
if run.JobID != j.ID {
return fmt.Errorf("scout run job binding mismatch")
}
// The run owns the normalized target (20 by default). Older payloads may
// omit target_count, so make the worker honor the run contract rather than
// accidentally stopping after the small legacy fan-out default.
if payload.TargetCount <= 0 {
payload.TargetCount = run.TargetCount
}
if _, err := scout.StartRun(ctx, j.OwnerUID, payload.RunID, j.ID); err != nil {
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed")
return err
}
posts, err := scout.RunScanForRun(ctx, j.OwnerUID, payload.RunID, &payload.RunBrief)
posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief)
if err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error())
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 75, fmt.Sprintf("海巡 · 篩選完成 · 合格 %d / 目標 %d", len(posts), run.TargetCount)); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed")
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil {
return err
}
if err := scout.PublishRun(ctx, j.OwnerUID, payload.RunID, posts); err != nil {
_ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error())
return err
}
// 已發佈即成功;進度回報失敗只記 log不可把 job 標為失敗
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已發佈 %d 筆候選", len(posts))); err != nil {
logx.Errorf("scout scan %s progress after publish: %v", j.ID, err)
}
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))
return err
}

View File

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

View File

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

View File

@ -1,18 +1,9 @@
import { chromium, type BrowserContext, type Page } from "playwright";
import { chromium, type Page } from "playwright";
import { createServer } from "node:http";
type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
type ResolveRequest = { storage_state?: string; permalink?: string };
type Post = {
permalink: string;
author: string;
text: string;
track?: "top" | "recent" | "both";
serp_rank?: number;
published_at?: string;
published_label?: string;
};
type Post = { permalink: string; author: string; text: string };
const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
const token = process.env.SCOUT_CRAWLER_TOKEN || "";
@ -26,258 +17,42 @@ function isThreadsURL(value: string): boolean {
}
}
function normalizePermalink(href: string): string {
const absolute = href.startsWith("http") ? href : `https://www.threads.com${href}`;
try {
const u = new URL(absolute);
u.hash = "";
u.search = "";
u.pathname = u.pathname.replace(/\/+$/, "");
return u.toString();
} catch {
return absolute.split("?")[0]!.replace(/\/+$/, "");
}
}
/** 口語錨點:不能單獨代表主題相關 */
const ANCHORS = new Set(["求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", "有人知道", "請問"]);
/**
*
*
*/
function textMatchesQuery(text: string, query: string): boolean {
const body = text.replace(/\s+/g, "").toLowerCase();
if (!body) return false;
const q = query.replace(/\u3000/g, " ").trim();
if (!q) return true;
const parts = q.split(/\s+/).filter((p) => [...p].length >= 2);
const tokens = parts.length ? parts : [...q].length >= 2 ? [q] : [];
const content: string[] = [];
const anchors: string[] = [];
for (const p of tokens) {
const t = p.toLowerCase();
if (ANCHORS.has(t)) anchors.push(t.replace(/\s+/g, ""));
else content.push(t.replace(/\s+/g, ""));
}
if (content.length > 0) {
return content.some((c) => body.includes(c));
}
if (anchors.length > 0) {
return anchors.some((a) => body.includes(a));
}
// 查詢過短無法斷詞時,退回要求正文含原始查詢字串,避免放行整條推薦流
return body.includes(q.replace(/\s+/g, "").toLowerCase());
}
function parsePublishedFromCardText(text: string): { iso?: string; label?: string } {
const raw = text.replace(/\s+/g, " ").trim();
if (!raw) return {};
const head = raw.slice(0, 100);
const now = Date.now();
const relPatterns: Array<{ re: RegExp; ms: (n: number) => number; label: (n: number) => string }> = [
{ re: /剛剛|just now/i, ms: () => 0, label: () => "剛剛" },
{ re: /(\d+)\s*(秒|s|sec)/i, ms: (n) => n * 1000, label: (n) => `${n}` },
{ re: /(\d+)\s*(分|分鐘|m|min)/i, ms: (n) => n * 60_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(小時|時|h|hr)/i, ms: (n) => n * 3_600_000, label: (n) => `${n}小時` },
{ re: /(\d+)\s*(天|日|d|day)/i, ms: (n) => n * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(週|周|w|week)/i, ms: (n) => n * 7 * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(月|mo|month)/i, ms: (n) => n * 30 * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*年前/i, ms: (n) => n * 365 * 86_400_000, label: (n) => `${n}` },
];
for (const p of relPatterns) {
const m = head.match(p.re);
if (!m) continue;
const n = m[1] ? Number(m[1]) : 0;
if (!Number.isFinite(n) && !/剛剛|just now/i.test(m[0])) continue;
return { iso: new Date(now - p.ms(Number.isFinite(n) ? n : 0)).toISOString(), label: p.label(Number.isFinite(n) ? n : 0) };
}
return {};
}
/**
* page.evaluate SERP locator query
* document /post/ nav 100% query
*/
async function readPosts(page: Page, query: string, limit: number): Promise<Post[]> {
type Raw = { href: string; author: string; text: string };
const raws = await page.evaluate((maxScan: number) => {
const out: Raw[] = [];
const seen = new Set<string>();
const anchors = Array.from(document.querySelectorAll('a[href*="/post/"]')) as HTMLAnchorElement[];
for (const a of anchors) {
if (out.length >= maxScan) break;
const href = a.getAttribute("href") || "";
if (!href.includes("/post/")) continue;
// 略過 nav / header 內連結
if (a.closest("nav, header, [role='navigation']")) continue;
const key = href.split("?")[0] || href;
if (seen.has(key)) continue;
seen.add(key);
// 找小範圍卡片:往上最多 8 層,取文字長度 20800 的最近祖先
let el: HTMLElement | null = a;
let best = "";
for (let depth = 0; depth < 8 && el; depth++) {
const t = (el.innerText || "").replace(/\s+/g, " ").trim();
if (t.length >= 20 && t.length <= 1200) {
best = t;
// 再往上若突然暴衝(整欄 feed就停在 best
const parent = el.parentElement;
if (parent) {
const pt = (parent.innerText || "").replace(/\s+/g, " ").trim();
if (pt.length > t.length * 3 && pt.length > 1500) break;
}
}
el = el.parentElement;
}
if (best.length < 12) {
best = (a.innerText || "").replace(/\s+/g, " ").trim();
}
if (best.length < 8) continue;
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
out.push({ href, author, text: best.slice(0, 2000) });
}
return out;
}, Math.min(limit * 4, 80));
const posts: Post[] = [];
let rank = 0;
for (const r of raws) {
if (posts.length >= limit) break;
const permalink = normalizePermalink(r.href);
async function readPosts(page: Page, limit: number): Promise<Post[]> {
const posts = new Map<string, Post>();
const links = page.locator('a[href*="/post/"]');
const count = Math.min(await links.count(), 50);
for (let i = 0; i < count && posts.size < limit; i++) {
const link = links.nth(i);
const href = await link.getAttribute("href").catch(() => null);
if (!href) continue;
const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`;
if (!isThreadsURL(permalink)) continue;
if (!textMatchesQuery(r.text, query)) continue;
const { iso, label } = parsePublishedFromCardText(r.text);
rank += 1;
posts.push({
permalink,
author: r.author,
text: r.text,
serp_rank: rank,
published_at: iso,
published_label: label,
});
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
const scope = link.locator("xpath=ancestor::div[position()<=6]").first();
const text = (await scope.innerText().catch(() => "")).trim();
if (text.length < 5) continue;
posts.set(permalink, { permalink, author, text: text.slice(0, 2000) });
}
return posts;
}
function searchURL(query: string, track: "top" | "recent"): string {
const q = encodeURIComponent(query);
// Top = defaultRecent = filter=recent與官方 web 一致)
if (track === "recent") {
return `https://www.threads.com/search?q=${q}&serp_type=default&filter=recent`;
}
return `https://www.threads.com/search?q=${q}&serp_type=default`;
}
async function searchTrack(page: Page, query: string, track: "top" | "recent", limit: number): Promise<Post[]> {
const url = searchURL(query, track);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
// 必須仍在搜尋頁,否則 captcha導向首頁會抓到無關貼文
const landed = page.url();
if (landed.includes("/login") || !landed.includes("/search")) {
const body = await page.locator("body").innerText().catch(() => "");
if (body.includes("登入") || landed.includes("/login")) throw new Error("crawler session expired");
// 不在 search直接空結果寧可少抓也不亂抓推薦流
return [];
}
const body = await page.locator("body").innerText().catch(() => "");
if (body.includes("登入") && body.length < 400) throw new Error("crawler session expired");
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
await page.waitForTimeout(900);
// 溫和捲動 2 次即可;過度捲動容易混進「為你推薦」
await page.mouse.wheel(0, 1000);
await page.waitForTimeout(700);
await page.mouse.wheel(0, 1000);
await page.waitForTimeout(600);
const posts = await readPosts(page, query, limit);
return posts.map((p) => ({ ...p, track }));
}
/** Recent 原序為主Top 獨有補後both 僅標記。合併後再做一次 query 相關性過濾。 */
function mergeRecentPrimary(top: Post[], recent: Post[], query: string, limit: number): Post[] {
const topMap = new Map<string, Post>();
for (const p of top) topMap.set(normalizePermalink(p.permalink), p);
const out: Post[] = [];
const seen = new Set<string>();
for (const p of recent) {
if (!textMatchesQuery(p.text, query)) continue;
const key = normalizePermalink(p.permalink);
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...p, track: topMap.has(key) ? "both" : "recent" });
if (out.length >= limit) return out;
}
for (const p of top) {
if (!textMatchesQuery(p.text, query)) continue;
const key = normalizePermalink(p.permalink);
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...p, track: "top" });
if (out.length >= limit) break;
}
return out;
return [...posts.values()];
}
async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> {
const state = JSON.parse(storageState) as { cookies?: unknown[] };
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
// 上層 fan-out 一詞一搜;若誤傳多詞只取第一組
const query = (terms.map((t) => t.trim()).filter(Boolean)[0] || "").slice(0, 180);
if (!query) return [];
const browser = await chromium.launch({ headless: true });
try {
const context: BrowserContext = await browser.newContext({
storageState: state,
locale: "zh-TW",
timezoneId: "Asia/Taipei",
userAgent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
});
const perTrack = Math.min(Math.max(limit, 10), 24);
const pageTop = await context.newPage();
const pageRecent = await context.newPage();
// allSettled 保留成功軌結果失敗軌各自重試一次session 失效重試無意義)
const isSessionError = (r: unknown) => r instanceof Error && r.message.includes("session");
const [topResult, recentResult] = await Promise.allSettled([
searchTrack(pageTop, query, "top", perTrack),
searchTrack(pageRecent, query, "recent", perTrack),
]);
let top: Post[] = topResult.status === "fulfilled" ? topResult.value : [];
let recent: Post[] = recentResult.status === "fulfilled" ? recentResult.value : [];
const firstFailure =
topResult.status === "rejected"
? topResult.reason
: recentResult.status === "rejected"
? recentResult.reason
: undefined;
if (topResult.status === "rejected" && !isSessionError(topResult.reason)) {
try {
top = await searchTrack(pageTop, query, "top", perTrack);
} catch {
/* keep empty */
}
}
if (recentResult.status === "rejected" && !isSessionError(recentResult.reason)) {
try {
recent = await searchTrack(pageRecent, query, "recent", perTrack);
} catch {
/* keep empty */
}
}
if (firstFailure !== undefined && top.length === 0 && recent.length === 0) throw firstFailure;
await pageTop.close().catch(() => undefined);
await pageRecent.close().catch(() => undefined);
const merged = mergeRecentPrimary(top, recent, query, limit);
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
const page = await context.newPage();
const query = terms.filter(Boolean).join(" ").slice(0, 180);
await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
await page.mouse.wheel(0, 900);
await page.waitForTimeout(1000);
const posts = await readPosts(page, limit);
await context.close();
return merged;
return posts;
} finally {
await browser.close();
}
@ -289,21 +64,12 @@ function shortcodeFromPermalink(permalink: string): string {
function findMediaID(value: unknown, shortcode: string): string {
if (!value || typeof value !== "object") return "";
if (Array.isArray(value)) {
for (const item of value) {
const id = findMediaID(item, shortcode);
if (id) return id;
}
return "";
}
if (Array.isArray(value)) { for (const item of value) { const id = findMediaID(item, shortcode); if (id) return id; } return ""; }
const record = value as Record<string, unknown>;
const code = String(record.code || record.shortcode || "");
const id = String(record.id || record.pk || record.media_id || "");
if (code === shortcode && /^\d{10,}$/.test(id)) return id;
for (const child of Object.values(record)) {
const found = findMediaID(child, shortcode);
if (found) return found;
}
for (const child of Object.values(record)) { const found = findMediaID(child, shortcode); if (found) return found; }
return "";
}
@ -334,36 +100,28 @@ async function resolve(storageState: string, permalink: string): Promise<string>
const responseReads: Promise<void>[] = [];
page.on("response", async (response) => {
if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
responseReads.push(
(async () => {
try {
const raw = await response.text();
if (!mediaID) {
try {
mediaID = findMediaID(JSON.parse(raw), shortcode);
} catch {
/* non-JSON */
}
}
if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
} catch {
/* ignored */
responseReads.push((async () => {
try {
const raw = await response.text();
if (!mediaID) {
try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ }
}
})(),
);
if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
} catch { /* ignored */ }
})());
});
await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForTimeout(2500);
await Promise.allSettled(responseReads);
if (!mediaID) mediaID = findMediaIDInText(await page.content(), shortcode);
if (!mediaID) {
mediaID = findMediaIDInText(await page.content(), shortcode);
}
await context.close();
if (!mediaID) throw new Error("Threads media ID could not be resolved");
if (!mediaID) throw new Error("Threads media ID could not be resolved")
return mediaID;
} finally {
await browser.close();
}
} finally { await browser.close(); }
}
if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");
@ -381,10 +139,7 @@ createServer(async (req, res) => {
const body = await new Promise<string>((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 300_000) req.destroy();
});
req.on("data", (chunk) => { raw += chunk; if (raw.length > 300_000) req.destroy(); });
req.on("end", () => resolve(raw));
req.on("error", reject);
});
@ -395,16 +150,10 @@ createServer(async (req, res) => {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
} else {
const input = JSON.parse(body) as SearchRequest;
const posts = await search(
String(input.storage_state || ""),
Array.isArray(input.terms) ? input.terms.slice(0, 12) : [],
Math.min(Math.max(Number(input.limit) || 10, 1), 30),
);
const posts = await search(String(input.storage_state || ""), Array.isArray(input.terms) ? input.terms.slice(0, 12) : [], Math.min(Math.max(Number(input.limit) || 10, 1), 30));
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts }));
}
} catch (error) {
res.writeHead(422, { "content-type": "application/json" }).end(
JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }),
);
res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }));
}
}).listen(port, "127.0.0.1");

View File

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

View File

@ -57,8 +57,6 @@ type (
PostCode string `json:"post_code,optional"`
PreferredLanguage string `json:"preferred_language,optional"`
Currency string `json:"currency,optional"`
// skipped | completed只寫一次已結束後再送會被忽略
OnboardingStatus string `json:"onboarding_status,optional"`
CurrentPassword string `json:"current_password,optional"`
NewPassword string `json:"new_password,optional"`
}

View File

@ -50,9 +50,6 @@ type MemberPublic {
PostCode string `json:"post_code,optional"`
PreferredLanguage string `json:"preferred_language,optional"`
Currency string `json:"currency,optional"`
// pending | skipped | completed尚未寫入視同 pending舊帳可後端補 completed
OnboardingStatus string `json:"onboarding_status,optional"`
OnboardingDoneAt int64 `json:"onboarding_done_at,optional"`
Identities []IdentityPublic `json:"identities,optional"`
JoinedAt int64 `json:"joined_at,optional"`
CreatedAt int64 `json:"created_at,optional"`

View File

@ -1,285 +0,0 @@
syntax = "v1"
// demand-radar: contacts / touches / follow-ups / conversion / stats
// spec: docs/product/demand-radar/spec.md §5.2
// 未實作能力一律回 501crmDomain.ErrNotReady禁止 102000 空成功。
// 命名紅線:不得以 lead 指稱銷售線索。
type (
// ---------- Contact ----------
ContactPublic {
Id string `json:"id"`
SourcePlatform string `json:"source_platform"`
AuthorHandle string `json:"author_handle"`
DisplayName string `json:"display_name,optional"`
Stage string `json:"stage"` // new_found | engaged | dm_sent | replied | quoted | won | lost
NeedsFollowUp bool `json:"needs_follow_up"`
FollowUpDays int `json:"follow_up_days"`
LastTouchAt int64 `json:"last_touch_at,optional"`
OpportunityIds []string `json:"opportunity_ids"`
OpportunityCount int `json:"opportunity_count"`
MergedFrom []string `json:"merged_from,optional"`
TopIntentBand string `json:"top_intent_band,optional"`
TopIntentScore int `json:"top_intent_score,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
ContactBrief {
Id string `json:"id"`
SourcePlatform string `json:"source_platform"`
AuthorHandle string `json:"author_handle"`
DisplayName string `json:"display_name,optional"`
Stage string `json:"stage"`
}
// StageCount — 八格視圖用stage 值另含 needs_follow_up跨階段檢視非階段值
StageCount {
Stage string `json:"stage"`
Count int `json:"count"`
}
ListContactsReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Query string `form:"query,optional"`
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"`
// UnavailableDimensions 列出「尚未實作」而非「查無資料」的維度,
// 前端才不會把功能缺口誤顯示為使用者還沒有數據。
UnavailableDimensions []string `json:"unavailable_dimensions"`
}
)
@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 DeleteContact
delete /contacts/:id (ContactIdReq) returns (OkData)
@handler UpdateContactStage
post /contacts/:id/stage (UpdateContactStageReq) returns (ContactPublic)
@handler SetContactFollowUp
post /contacts/:id/follow-up (SetContactFollowUpReq) returns (ContactPublic)
@handler CreateContactNote
post /contacts/:id/notes (CreateContactNoteReq) returns (ContactTouchPublic)
@handler MergeContact
post /contacts/:id/merge (MergeContactReq) returns (ContactPublic)
@handler UnmergeContact
post /contacts/:id/unmerge (UnmergeContactReq) returns (ContactPublic)
@handler CreateContactConversion
post /contacts/:id/conversion (CreateCrmConversionReq) returns (CrmConversionData)
@handler UpdateContactConversion
put /contacts/:id/conversion (UpdateCrmConversionReq) returns (CrmConversionData)
@handler DeleteContactConversion
delete /contacts/:id/conversion (DeleteCrmConversionReq) returns (OkData)
@handler ListFollowUps
get /followups (ListFollowUpsReq) returns (FollowUpListData)
@handler DoneFollowUp
post /followups/:id/done (FollowUpIdReq) returns (FollowUpPublic)
@handler SnoozeFollowUp
post /followups/:id/snooze (SnoozeFollowUpReq) returns (FollowUpPublic)
@handler GenerateFollowUpMessage
post /followups/:id/message (GenerateFollowUpMessageReq) returns (GenerateFollowUpMessageData)
@handler GetCrmStats
get /stats (CrmStatsReq) returns (CrmStatsData)
}

View File

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

View File

@ -188,8 +188,6 @@ type (
ProductContext string `json:"product_context"`
MatchTags []string `json:"match_tags"`
PainPoints []string `json:"pain_points"`
ProviderCapabilityTerms []string `json:"provider_capability_terms"`
ProviderExcludeTerms []string `json:"provider_exclude_terms"`
PlacementUrl string `json:"placement_url,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@ -204,8 +202,6 @@ type (
ProductContext string `json:"product_context,optional"`
MatchTags []string `json:"match_tags,optional"`
PainPoints []string `json:"pain_points,optional"`
ProviderCapabilityTerms []string `json:"provider_capability_terms,optional"`
ProviderExcludeTerms []string `json:"provider_exclude_terms,optional"`
PlacementUrl string `json:"placement_url,optional"`
}
ProductIdPath {
@ -247,61 +243,15 @@ type (
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"`
// TargetCount話題今日目標未指定時預設 20。主路徑不足時會加碼再搜次路徑補抓上限 40。
TargetCount int `json:"target_count,optional"`
}
ScoutScanReq {
Brief ScoutBriefPublic `json:"brief"`
}
ScoutScanJobData {
Job JobPublic `json:"job"`
Run ScoutRunPublic `json:"run"`
}
ScoutRunPublic {
Id string `json:"id"`
JobId string `json:"job_id"`
ThemeKey string `json:"theme_key"`
ThemeLabel string `json:"theme_label"`
Intent string `json:"intent"`
Mode string `json:"mode"`
BrandId string `json:"brand_id,optional"`
TargetCount int `json:"target_count"`
Status string `json:"status"`
SearchedCount int `json:"searched_count"`
DuplicateCount int `json:"duplicate_count"`
IrrelevantCount int `json:"irrelevant_count"`
EligibleCount int `json:"eligible_count"`
PendingCount int `json:"pending_count"`
ShortfallCount int `json:"shortfall_count"`
ShortfallReasons []string `json:"shortfall_reasons"`
CreatedAt int64 `json:"created_at"`
StartedAt int64 `json:"started_at,optional"`
CompletedAt int64 `json:"completed_at,optional"`
Error string `json:"error,optional"`
}
ScoutRunListReq {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
BrandId string `form:"brand_id,optional"`
Mode string `form:"mode,optional"`
}
ScoutRunListData {
List []ScoutRunPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
ScoutRunPostsReq {
RunId string `path:"runId"`
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ScoutRunPostsData {
Run ScoutRunPublic `json:"run"`
List []ScoutPostPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
ScoutPostPublic {
Id string `json:"id"`
RunId string `json:"run_id,optional"`
BrandId string `json:"brand_id,optional"`
Author string `json:"author"`
Text string `json:"text"`
@ -320,8 +270,6 @@ type (
ScanPath string `json:"scan_path,optional"`
Classification string `json:"classification,optional"`
Permalink string `json:"permalink,optional"`
// 原文發文時間unix nanoseconds未知時可為 0
PostedAt int64 `json:"posted_at,optional"`
CreatedAt int64 `json:"created_at"`
}
ScoutPostListData {
@ -364,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 (
@ -495,15 +435,6 @@ service gateway {
@handler RunScan
post /scan (ScoutScanReq) returns (ScoutScanJobData)
@handler ListScoutRuns
get /runs (ScoutRunListReq) returns (ScoutRunListData)
@handler ListScoutRunPosts
get /runs/:runId/posts (ScoutRunPostsReq) returns (ScoutRunPostsData)
@handler RemoveScoutRun
delete /runs/:runId (ScoutRunPostsReq) returns (OkData)
@handler ListScoutPosts
get /posts (ScoutPostListReq) returns (ScoutPostListData)
@ -519,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)

View File

@ -1,634 +0,0 @@
syntax = "v1"
// demand-radar: service profile / radar watches / sweeps / opportunities / replies
// spec: docs/product/demand-radar/spec.md §5.2
// 未實作能力一律回 501radarDomain.ErrNotReady禁止 102000 空成功。
type (
// ---------- ServiceProfile ----------
ServiceItem {
Name string `json:"name"`
PriceMin float64 `json:"price_min,optional"`
PriceMax float64 `json:"price_max,optional"`
Currency string `json:"currency,optional"`
}
ServiceCasePublic {
Title string `json:"title"`
Summary string `json:"summary,optional"`
Link string `json:"link,optional"`
}
FaqItem {
Question string `json:"question"`
Answer string `json:"answer"`
}
ServiceProfilePublic {
Exists bool `json:"exists"`
Services []ServiceItem `json:"services"`
Cases []ServiceCasePublic `json:"cases"`
Forbidden []string `json:"forbidden"`
Faq []FaqItem `json:"faq"`
ServiceAreas []string `json:"service_areas"`
RemoteOk bool `json:"remote_ok"`
Availability string `json:"availability,optional"`
ToneNote string `json:"tone_note,optional"`
UpdatedAt int64 `json:"updated_at,optional"`
}
UpsertServiceProfileReq {
Services []ServiceItem `json:"services"`
Cases []ServiceCasePublic `json:"cases,optional"`
Forbidden []string `json:"forbidden,optional"`
Faq []FaqItem `json:"faq,optional"`
ServiceAreas []string `json:"service_areas,optional"`
RemoteOk bool `json:"remote_ok,optional"`
Availability string `json:"availability,optional"`
ToneNote string `json:"tone_note,optional"`
}
// ---------- RadarWatch ----------
RadarWatchPublic {
Id string `json:"id"`
Terms []string `json:"terms"`
ExcludeTerms []string `json:"exclude_terms"`
Regions []string `json:"regions"`
Status string `json:"status"` // active | paused | archived
ContextMode string `json:"context_mode"` // generic | product
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
BrandNameSnapshot string `json:"brand_name_snapshot,optional"`
ProductLabelSnapshot string `json:"product_label_snapshot,optional"`
ContextBoundAt int64 `json:"context_bound_at,optional"`
PauseReason string `json:"pause_reason,optional"`
LastSweptAt int64 `json:"last_swept_at,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
// 只在建立當下這是使用者第一組訂閱、且已排入首巡時為 true不是持久狀態僅供前端顯示一次性提示。
FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
}
ListWatchesReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Status string `form:"status,optional"`
ContextMode string `form:"context_mode,optional"`
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,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"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,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"`
}
AssignProductReq {
Id string `path:"id"`
BrandId string `json:"brand_id"`
ProductId string `json:"product_id"`
}
SuggestWatchTermsReq {
Limit int `json:"limit,optional"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
}
WatchTermSuggestion {
Term string `json:"term"`
Reason string `json:"reason"`
Usage string `json:"usage"` // include | exclude
BasisKind string `json:"basis_kind,optional"`
BasisText string `json:"basis_text,optional"`
}
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"`
}
ProductFitReasonPublic {
Dimension string `json:"dimension"` // pain | scenario | audience | capability
Score int `json:"score"`
Reason string `json:"reason"`
CandidateExcerpt string `json:"candidate_excerpt,optional"`
ProductBasis string `json:"product_basis,optional"`
}
ProductMatchPublic {
BrandId string `json:"brand_id"`
ProductId string `json:"product_id"`
BrandNameSnapshot string `json:"brand_name_snapshot"`
ProductLabelSnapshot string `json:"product_label_snapshot"`
BrandUpdatedAt int64 `json:"brand_updated_at"`
ProductUpdatedAt int64 `json:"product_updated_at"`
ProductFitScore int `json:"product_fit_score"`
ProductFitBand string `json:"product_fit_band"` // strong | possible | weak
Eligible bool `json:"eligible"`
Excluded bool `json:"excluded"`
ExcludeReason string `json:"exclude_reason,optional"`
Reasons []ProductFitReasonPublic `json:"reasons"`
Risks []string `json:"risks"`
WatchIds []string `json:"watch_ids"`
MatchedTerms []string `json:"matched_terms"`
MatchedAt int64 `json:"matched_at"`
}
OpportunityPublic {
Id string `json:"id"`
WatchId string `json:"watch_id,optional"`
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"`
PrimaryBrandId string `json:"primary_brand_id,optional"`
PrimaryProductId string `json:"primary_product_id,optional"`
PrimaryBrandName string `json:"primary_brand_name,optional"`
PrimaryProductLabel string `json:"primary_product_label,optional"`
PrimaryProductFitScore int `json:"primary_product_fit_score,optional"`
PrimaryProductFitBand string `json:"primary_product_fit_band,optional"`
PrimaryProductOverridden bool `json:"primary_product_overridden,optional"`
ProductMatches []ProductMatchPublic `json:"product_matches"`
ReviewState string `json:"review_state"` // pending | completed | removed
PreviousReviewState string `json:"previous_review_state,optional"`
RemovalReason string `json:"removal_reason,optional"`
RemovalNote string `json:"removal_note,optional"`
RemovedAt int64 `json:"removed_at,optional"`
RemovedBy int64 `json:"removed_by,optional"`
LastMatchedAt int64 `json:"last_matched_at,optional"`
PriorityScore int `json:"priority_score"`
PriorityBand string `json:"priority_band"` // high | review | low
PainFitScore int `json:"pain_fit_score,optional"`
DemandIntentScore int `json:"demand_intent_score,optional"`
EvidenceQualityScore int `json:"evidence_quality_score,optional"`
FreshnessScore int `json:"freshness_score,optional"`
DemandEvidence []string `json:"demand_evidence,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
CreatedAt int64 `json:"created_at"`
}
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 | no_eligible_product_match
EmptyReason string `json:"empty_reason,optional"`
EmptyHint string `json:"empty_hint,optional"`
}
RadarTodayReq {
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,optional"`
FitBand string `form:"fit_band,optional"`
}
ListOpportunitiesReq {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Band string `form:"band,optional"`
Status string `form:"status,optional"`
WatchId string `form:"watch_id,optional"`
BrandId string `form:"brand_id,optional"`
ProductId string `form:"product_id,optional"`
FitBand string `form:"fit_band,optional"`
MatchState string `form:"match_state,optional"`
ReviewState string `form:"review_state,optional"`
TimeScope string `form:"time_scope,optional"` // today | 7d | all
PriorityBand string `form:"priority_band,optional"` // high | review | low
Sort string `form:"sort,optional"`
From int64 `form:"from,optional"`
To int64 `form:"to,optional"`
}
OpportunityListData {
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"`
}
ReviewStateReq {
Id string `path:"id"`
State string `json:"state"` // pending | completed | removed
RemovalReason string `json:"removal_reason,optional"`
RemovalNote string `json:"removal_note,optional"`
DuplicateOpportunityId string `json:"duplicate_of_opportunity_id,optional"`
}
DemandMapProductReq {
ProductId string `path:"productId"`
}
DemandMapPhrase {
Text string `json:"text"`
Kind string `json:"kind"`
BasisKind string `json:"basis_kind,optional"`
BasisText string `json:"basis_text,optional"`
Origin string `json:"origin"` // product | user | ai
Enabled bool `json:"enabled"`
}
DemandMapPublic {
ProductId string `json:"product_id"`
DemandInputVersion string `json:"demand_input_version"`
MapVersion int64 `json:"map_version"`
State string `json:"state"` // ready | incomplete | stale
PainPhrases []DemandMapPhrase `json:"pain_phrases"`
ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases"`
DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes"`
SolutionSignals []DemandMapPhrase `json:"solution_signals"`
ExclusionSignals []DemandMapPhrase `json:"exclusion_signals"`
SourceBasis []string `json:"source_basis"`
CustomPhrases []DemandMapPhrase `json:"custom_phrases"`
AiEnrichedAt int64 `json:"ai_enriched_at,optional"`
UpdatedAt int64 `json:"updated_at"`
}
UpdateDemandMapReq {
ProductId string `path:"productId"`
ExpectedMapVersion int64 `json:"expected_map_version"`
PainPhrases []DemandMapPhrase `json:"pain_phrases,optional"`
ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases,optional"`
DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes,optional"`
SolutionSignals []DemandMapPhrase `json:"solution_signals,optional"`
ExclusionSignals []DemandMapPhrase `json:"exclusion_signals,optional"`
CustomPhrases []DemandMapPhrase `json:"custom_phrases,optional"`
}
CostPreviewReq {
Action string `form:"action"` // sweep | explore | demand_map_enrich | reply
WatchId string `form:"watch_id,optional"`
ProductId string `form:"product_id,optional"`
CandidateLimit int `form:"candidate_limit,optional"`
}
CostPreviewPublic {
PreviewId string `json:"preview_id"`
Action string `json:"action"`
KeyMode string `json:"key_mode"` // platform | byok
FixedCredits int `json:"fixed_credits"`
MinCredits int `json:"min_credits"`
MaxCredits int `json:"max_credits"`
SearchCalls int `json:"search_calls"`
MaxAiCandidates int `json:"max_ai_candidates"`
RemainingCredits int `json:"remaining_credits"`
EstimateBasis string `json:"estimate_basis"`
ExpiresAt int64 `json:"expires_at"`
}
EnrichDemandMapReq {
ProductId string `path:"productId"`
PreviewId string `json:"preview_id"`
CreditCeiling int `json:"credit_ceiling"`
ExpectedMapVersion int64 `json:"expected_map_version"`
}
OverrideOpportunityReq {
Id string `path:"id"`
Band string `json:"band,optional"`
Status string `json:"status,optional"`
Note string `json:"note,optional"`
}
SetPrimaryProductReq {
Id string `path:"id"`
ProductId string `json:"product_id"`
Reason string `json:"reason"`
}
// ---------- ReplyVariant ----------
ReplyVariantPublic {
Id string `json:"id"`
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
// 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值。
OutboxId string `json:"outbox_id,optional"`
CreatedAt int64 `json:"created_at"`
}
ListRepliesReq {
Id string `path:"id"`
}
ReplyListData {
List []ReplyVariantPublic `json:"list"`
}
CreateReplyReq {
Id string `path:"id"`
Variant string `json:"variant"`
}
// MarkReplyUsedReq: channel=outbox|manual_copydm 僅 manual_copy
MarkReplyUsedReq {
Id string `path:"id"`
ReplyId string `path:"replyId"`
Channel string `json:"channel"` // outbox | manual_copy
// channel=outbox 時必填:要用哪個 Threads 帳號送出。
AccountId string `json:"account_id,optional"`
}
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"`
MatchEvaluatedCount int `json:"match_evaluated_count"`
MatchMergedCount int `json:"match_merged_count"`
FitRejectedCount int `json:"fit_rejected_count"`
FailedReason string `json:"failed_reason,optional"`
CreditsUsed int `json:"credits_used"`
StartedAt int64 `json:"started_at"`
EndedAt int64 `json:"ended_at,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
DedupedCount int `json:"deduped_count"`
PrefilterPassCount int `json:"prefilter_pass_count"`
PrefilterReviewCount int `json:"prefilter_review_count"`
PrefilterRejectedCount int `json:"prefilter_rejected_count"`
CachedJudgmentCount int `json:"cached_judgment_count"`
TombstoneMatchedCount int `json:"tombstone_matched_count"`
BudgetDeferredCount int `json:"budget_deferred_count"`
SweepStatus string `json:"status"` // complete | partial_budget | blocked_budget | failed
CreditSearch int `json:"credit_search"`
CreditDemandMap int `json:"credit_demand_map"`
CreditJudge int `json:"credit_judge"`
CreditReply int `json:"credit_reply"`
}
ListSweepsReq {
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"`
}
// ---------- Manual ImportP1不承諾全平台自動抓取的合規補位 ----------
ImportOpportunityItem {
Url string `json:"url"`
// 貼文內文;沒有官方 API爬蟲可讀任意網址判定一律吃使用者貼上的文字。
Text string `json:"text"`
// 缺省時從 threads 網址猜 @handle猜不到就留空。
Author string `json:"author,optional"`
// unix nanoseconds UTC缺省用匯入當下時間新鮮度以匯入時間計
PostedAt int64 `json:"posted_at,optional"`
}
ImportOpportunitiesReq {
Items []ImportOpportunityItem `json:"items"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
}
ImportedOpportunityResult {
Url string `json:"url"`
OpportunityId string `json:"opportunity_id,optional"`
Status string `json:"status"` // qualified | rejected | skipped | failed
IntentBand string `json:"intent_band,optional"`
IntentScore int `json:"intent_score,optional"`
// skippedfailed 時的人話原因qualifiedrejected 時通常留空。
Error string `json:"error,optional"`
}
ImportOpportunitiesData {
Results []ImportedOpportunityResult `json:"results"`
}
// ---------- Explore商機頁立即探索短詞 fan-out → 五問判定) ----------
ExploreOpportunitiesReq {
// 16 組 Threads 短詞(契約 A不合規整組擋下不默默修正。
Terms []string `json:"terms"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
PreviewId string `json:"preview_id,optional"`
CreditCeiling int `json:"credit_ceiling,optional"`
DemandInputVersion string `json:"demand_input_version,optional"`
DemandMapVersion int64 `json:"demand_map_version,optional"`
}
ExploreOpportunitiesData {
SweepId string `json:"sweep_id"`
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
MatchedCount int `json:"matched_count"`
MergedCount int `json:"merged_count"`
TruncatedCount int `json:"truncated_count"`
CreditsUsed int `json:"credits_used"`
DedupedCount int `json:"deduped_count"`
PrefilterPassCount int `json:"prefilter_pass_count"`
PrefilterReviewCount int `json:"prefilter_review_count"`
PrefilterRejectedCount int `json:"prefilter_rejected_count"`
CachedJudgmentCount int `json:"cached_judgment_count"`
TombstoneMatchedCount int `json:"tombstone_matched_count"`
BudgetDeferredCount int `json:"budget_deferred_count"`
SweepStatus string `json:"status"`
}
)
@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 AssignWatchProduct
post /watches/:id/assign-product (AssignProductReq) returns (RadarWatchPublic)
@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 DeleteArchivedWatch
delete /watches/:id/purge (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 (RadarTodayReq) 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 UpdateOpportunityReviewState
put /opportunities/:id/review-state (ReviewStateReq) returns (OpportunityPublic)
@handler OverrideOpportunity
post /opportunities/:id/override (OverrideOpportunityReq) returns (OpportunityPublic)
@handler SetPrimaryProduct
put /opportunities/:id/primary-product (SetPrimaryProductReq) returns (OpportunityPublic)
@handler GetDemandMap
get /products/:productId/demand-map (DemandMapProductReq) returns (DemandMapPublic)
@handler UpdateDemandMap
put /products/:productId/demand-map (UpdateDemandMapReq) returns (DemandMapPublic)
@handler EnrichDemandMap
post /products/:productId/demand-map/enrich (EnrichDemandMapReq) returns (DemandMapPublic)
@handler GetRadarCostPreview
get /cost-preview (CostPreviewReq) returns (CostPreviewPublic)
@handler ListOpportunityReplies
get /opportunities/:id/replies (ListRepliesReq) returns (ReplyListData)
@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)
@handler ImportOpportunities
post /import (ImportOpportunitiesReq) returns (ImportOpportunitiesData)
@handler ExploreOpportunities
post /explore (ExploreOpportunitiesReq) returns (ExploreOpportunitiesData)
}

View File

@ -285,8 +285,6 @@ type (
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
// Threads 話題標籤topic_tag150 字,可不加 #
TopicTag string `json:"topic_tag,optional"`
// everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
ReplyControl string `json:"reply_control,optional"`
}
// --- Own posts ---
@ -301,8 +299,6 @@ type (
RepliedAt int64 `json:"replied_at,optional"`
ParentReplyId string `json:"parent_reply_id,optional"`
IsMine bool `json:"is_mine,optional"`
// Threads hide_statusNOT_HUSHED / HIDDEN / …
HideStatus string `json:"hide_status,optional"`
}
OwnPostPublic {
@ -328,8 +324,6 @@ type (
FormulaDetail string `json:"formula_detail,optional"`
Replies []OwnPostReplyPublic `json:"replies"`
PublishedAt int64 `json:"published_at"`
// everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
ReplyControl string `json:"reply_control,optional"`
}
OwnPostListData {
@ -375,17 +369,6 @@ type (
PostId string `json:"post_id"`
}
OwnPostManageReplyReq {
PostId string `json:"post_id"`
ReplyId string `json:"reply_id"`
Hide bool `json:"hide"`
}
OwnPostSetReplyControlReq {
PostId string `json:"post_id"`
ReplyControl string `json:"reply_control"`
}
// --- Mentions ---
MentionPublic {
Id string `json:"id"`
@ -565,12 +548,6 @@ service gateway {
@handler OwnPostLoadReplies
post /load-replies (OwnPostLoadRepliesReq) returns (OwnPostPublic)
@handler OwnPostManageReply
post /manage-reply (OwnPostManageReplyReq) returns (OwnPostPublic)
@handler OwnPostSetReplyControl
post /reply-control (OwnPostSetReplyControlReq) returns (OwnPostPublic)
}
@server (

View File

@ -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" }
]

View File

@ -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" }
]
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
[
{ "dropIndexes": "jobs", "index": "radar_sweep_job_ref_unique" }
]

View File

@ -1,13 +0,0 @@
[
{
"createIndexes": "jobs",
"indexes": [
{
"key": { "owner_uid": 1, "template_type": 1, "ref_id": 1 },
"name": "radar_sweep_job_ref_unique",
"unique": true,
"partialFilterExpression": { "template_type": "radar_sweep" }
}
]
}
]

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 DeleteContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ContactIdReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewDeleteContactLogic(r.Context(), svcCtx)
data, err := l.DeleteContact(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

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

View File

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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 AssignWatchProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AssignProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewAssignWatchProductLogic(r.Context(), svcCtx)
data, err := l.AssignWatchProduct(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 DeleteArchivedWatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.WatchIdReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewDeleteArchivedWatchLogic(r.Context(), svcCtx)
data, err := l.DeleteArchivedWatch(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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 EnrichDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.EnrichDemandMapReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewEnrichDemandMapLogic(r.Context(), svcCtx)
data, err := l.EnrichDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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 ExploreOpportunitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ExploreOpportunitiesReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewExploreOpportunitiesLogic(r.Context(), svcCtx)
data, err := l.ExploreOpportunities(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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 GetDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DemandMapProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetDemandMapLogic(r.Context(), svcCtx)
data, err := l.GetDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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 GetRadarCostPreviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CostPreviewReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetRadarCostPreviewLogic(r.Context(), svcCtx)
data, err := l.GetRadarCostPreview(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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 GetRadarTodayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RadarTodayReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewGetRadarTodayLogic(r.Context(), svcCtx)
data, err := l.GetRadarToday(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 ImportOpportunitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ImportOpportunitiesReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewImportOpportunitiesLogic(r.Context(), svcCtx)
data, err := l.ImportOpportunities(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 SetPrimaryProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SetPrimaryProductReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewSetPrimaryProductLogic(r.Context(), svcCtx)
data, err := l.SetPrimaryProduct(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 UpdateDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateDemandMapReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewUpdateDemandMapLogic(r.Context(), svcCtx)
data, err := l.UpdateDemandMap(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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 UpdateOpportunityReviewStateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ReviewStateReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewUpdateOpportunityReviewStateLogic(r.Context(), svcCtx)
data, err := l.UpdateOpportunityReviewState(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,95 +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.MethodDelete,
Path: "/contacts/:id",
Handler: crm.DeleteContactHandler(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},
@ -623,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"),
)
@ -637,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"),
)
@ -809,16 +718,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/load-replies",
Handler: ownposts.OwnPostLoadRepliesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/manage-reply",
Handler: ownposts.OwnPostManageReplyHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/reply-control",
Handler: ownposts.OwnPostSetReplyControlHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/send-reply",
@ -1042,170 +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: "/cost-preview",
Handler: radar.GetRadarCostPreviewHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/explore",
Handler: radar.ExploreOpportunitiesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/import",
Handler: radar.ImportOpportunitiesHandler(serverCtx),
},
{
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.MethodPut,
Path: "/opportunities/:id/primary-product",
Handler: radar.SetPrimaryProductHandler(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.MethodPut,
Path: "/opportunities/:id/review-state",
Handler: radar.UpdateOpportunityReviewStateHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/products/:productId/demand-map",
Handler: radar.GetDemandMapHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/products/:productId/demand-map",
Handler: radar.UpdateDemandMapHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/products/:productId/demand-map/enrich",
Handler: radar.EnrichDemandMapHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/service-profile",
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),
},
{
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/assign-product",
Handler: radar.AssignWatchProductHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/pause",
Handler: radar.PauseWatchHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/watches/:id/purge",
Handler: radar.DeleteArchivedWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/resume",
Handler: radar.ResumeWatchHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/:id/sweep",
Handler: radar.TriggerWatchSweepHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/watches/suggest",
Handler: radar.SuggestWatchTermsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/radar"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -1310,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",
@ -1355,21 +1085,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/products/import",
Handler: scout.ImportProductHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/runs",
Handler: scout.ListScoutRunsHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/runs/:runId",
Handler: scout.RemoveScoutRunHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/runs/:runId/posts",
Handler: scout.ListScoutRunPostsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/scan",
@ -1550,35 +1265,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/utm"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodPut,
Path: "/:id/branding",
Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/members",
Handler: workspaces.ListWorkspaceMembersHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/members",
Handler: workspaces.AddWorkspaceMemberHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id/members/:uid",
Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/workspaces"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -1637,4 +1323,33 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
),
rest.WithPrefix("/api/v1/workspaces"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodPut,
Path: "/:id/branding",
Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/members",
Handler: workspaces.ListWorkspaceMembersHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/members",
Handler: workspaces.AddWorkspaceMemberHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id/members/:uid",
Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/workspaces"),
)
}

View File

@ -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 ListScoutRunPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunPostsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewListScoutRunPostsLogic(r.Context(), svcCtx)
data, err := l.ListScoutRunPosts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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 ListScoutRunsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunListReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewListScoutRunsLogic(r.Context(), svcCtx)
data, err := l.ListScoutRuns(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -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)
}
}

View File

@ -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 RemoveScoutRunHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScoutRunPostsReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := scout.NewRemoveScoutRunLogic(r.Context(), svcCtx)
data, err := l.RemoveScoutRun(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -34,7 +34,6 @@ func (l *LoginLogic) Login(req *types.AuthLoginReq) (resp *types.AuthSessionData
if err != nil {
return nil, err
}
m = ensureOnboarding(l.ctx, l.svcCtx, m)
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
return &types.AuthSessionData{
Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn),

View File

@ -30,7 +30,6 @@ func (l *MeLogic) Me() (resp *types.MemberPublic, err error) {
return nil, err
}
}
m = ensureOnboarding(l.ctx, l.svcCtx, m)
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
return types.MemberFromModelWithIdentities(m, ids), nil
}

View File

@ -1,49 +0,0 @@
package auth
import (
"context"
memberDomain "apps/backend/internal/module/member/domain"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
)
// ensureOnboarding fills completed for legacy members with an empty status who
// already have a brand, product, or demand watch. An explicit pending stays
// pending so a reset (for retest) is not immediately overwritten.
func ensureOnboarding(ctx context.Context, svcCtx *svc.ServiceContext, m *memberDomain.Member) *memberDomain.Member {
if m == nil || m.OnboardingStatus != "" {
return m
}
if !hasExistingSetup(ctx, svcCtx, m.UID) {
return m
}
status := memberDomain.OnboardingCompleted
updated, err := svcCtx.Auth.UpdateUserInfo(ctx, m.UID, &memberDomain.UpdateUserInfoPatch{
OnboardingStatus: &status,
})
if err != nil || updated == nil {
return m
}
return updated
}
func hasExistingSetup(ctx context.Context, svcCtx *svc.ServiceContext, uid int64) bool {
if svcCtx == nil || uid <= 0 {
return false
}
if svcCtx.Scout != nil {
if brands, err := svcCtx.Scout.ListBrands(ctx, uid); err == nil && len(brands) > 0 {
return true
}
if products, err := svcCtx.Scout.ListAllProducts(ctx, uid); err == nil && len(products) > 0 {
return true
}
}
if svcCtx.Radar != nil {
if _, total, err := svcCtx.Radar.ListWatches(ctx, uid, radarDomain.WatchListFilter{Page: 1, PageSize: 1}); err == nil && total > 0 {
return true
}
}
return false
}

View File

@ -29,7 +29,7 @@ func (l *ComposePublishSingleLogic) ComposePublishSingle(req *types.ComposePubli
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag, req.ReplyControl)
b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag)
if err != nil {
return nil, err
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,56 +0,0 @@
package crm
import (
"context"
crmUC "apps/backend/internal/module/crm/usecase"
"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
}
terms := make([]types.TermConversionStat, 0, len(stats.Terms))
for _, s := range stats.Terms {
row := types.TermConversionStat{
Term: s.Key, Accepted: s.Accepted, Replied: s.Replied, Won: s.Won,
InsufficientSample: s.Accepted < crmUC.StatsMinSample,
}
// 樣本不足只給絕對數不給會被過度解讀的比率spec §9.5
if !row.InsufficientSample {
row.ConversionRate = float64(s.Won) / float64(s.Accepted)
}
terms = append(terms, row)
}
sources := make([]types.SourceConversionStat, 0, len(stats.Sources))
for _, s := range stats.Sources {
sources = append(sources, types.SourceConversionStat{
Source: s.Key, Won: s.Won, InsufficientSample: s.Won < crmUC.StatsMinSample,
})
}
return &types.CrmStatsData{
Terms: terms,
Variants: []types.VariantConversionStat{},
Sources: sources,
UnavailableDimensions: stats.UnavailableDimensions,
}, nil
}

View File

@ -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{Query: req.Query, Stage: req.Stage, Band: req.Band, Sort: req.Sort, Page: req.Page, PageSize: req.PageSize}
switch strings.ToLower(req.FollowUp) {
case "true", "1":
v := true
f.FollowUp = &v
case "false", "0":
v := false
f.FollowUp = &v
}
list, total, counts, err := l.svcCtx.Crm.ListContacts(l.ctx, uid, f)
if err != nil {
return nil, err
}
return &types.ContactListData{
List: crmmap.ContactList(list), Pagination: crmmap.Pagination(req.Page, req.PageSize, total),
StageCounts: crmmap.StageCounts(counts),
}, nil
}

View File

@ -1,57 +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))
// 同一聯絡人常有多筆追蹤;快取避免整頁重複查同一筆
briefs := make(map[string]types.ContactBrief, len(list))
for _, f := range list {
p := crmmap.FollowUp(f)
if p == nil {
continue
}
brief, cached := briefs[f.ContactID]
if !cached {
if c, cerr := l.svcCtx.Crm.GetContactOnly(l.ctx, uid, f.ContactID); cerr == nil && c != nil {
brief = types.ContactBrief{
Id: c.ID, SourcePlatform: c.SourcePlatform, AuthorHandle: c.AuthorHandle,
DisplayName: c.DisplayName, Stage: c.Stage,
}
}
briefs[f.ContactID] = brief
}
p.Contact = brief
out = append(out, *p)
}
return &types.FollowUpListData{List: out, Pagination: crmmap.Pagination(req.Page, req.PageSize, total)}, nil
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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
}

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