diff --git a/backend/docs/phase1-methods.md b/backend/docs/phase1-methods.md new file mode 100644 index 0000000..a91ebe4 --- /dev/null +++ b/backend/docs/phase1-methods.md @@ -0,0 +1,176 @@ +# 全 Phase 新增功能說明 + +## Phase 1 — TA 精準度 + +### 1. Semantic Embedding Reranker + +**ScanPost entity 新增欄位**(`internal/model/scan_post/domain/entity/post.go`): + +| 欄位 | 型別 | 用途 | +|------|------|------| +| `Embedding` | `[]float32` | OpenAI text-embedding-3-small 向量 | +| `SemanticScore` | `int` | 語意相似度分數(0–100) | +| `EngagementPredicted` | `int` | 預測互動率 | +| `AudienceQualityScore` | `int` | 受眾品質分數(0–100) | +| `AuthorID` | `string` | 作者 ID(Threads user ID) | +| `AuthorAvatar` | `string` | 作者頭像 URL | +| `AuthorFollowers` | `int` | 作者粉絲數 | + +`ScanCandidate` struct 同步新增對應欄位(`internal/library/placement/dual_track.go`)。 + +### 2. Embedding Client + +```go +// internal/library/embedding/client.go +func NewClient(apiKey, model string) *Client +func (c *Client) Generate(ctx context.Context, text string) ([]float32, error) +``` + +- 預設使用 OpenAI `text-embedding-3-small`(可降級 local BGE-M3) +- Scorer: `CosineSimilarity(a, b []float32) float64` + +### 3. Placement Score V2 + +```go +// internal/library/placement/dual_track.go +func computePlacementScore(post *ScanCandidate, member productContext, brand productMeta, + semanticScore, predictedEngagement, audienceQuality *int) int +``` + +整合三項 pointer 參數(nil 表示欄位不存在,跳過該項): + +- **語意相似度**(`SemanticScore`):Embedding cosine → 0–100 +- **預測互動率**(`EngagementPredicted`):like+reply → 對數正規化 0–100 +- **受眾品質**(`AudienceQualityScore`):`computeAudienceQuality()` heuristic + +權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。 + +```go +func computeAudienceQuality(followers int) int +``` + +Heuristic:followers 在 500–50k 之間品質最佳,過低或過高遞減。 + +### 4. Creator Lookalike + +**端點**: `POST /api/v1/scan-post/lookalike`(需 JWT) + +```json +{ "source_post_id": "uuid", "brand_id": "...", "limit": 10 } + +→ { "list": [{ "post_id", "author_id", "author_name", "similarity", ... }] } +``` + +**演算法**: cosine similarity(門檻 ≥0.3),同 brand 內找 embedding 最接近的貼文回傳。 + +| 檔案 | | +|------|--| +| Handler | `internal/handler/scan_post/lookalike_handler.go` | +| Logic | `internal/logic/scan_post/lookalike_logic.go` | +| `cosineSimilarity` | `internal/logic/scan_post/lookalike_logic.go` — 全 Go 實作 | + +--- + +## Phase 2 — 營運流程 + +### 2.1 Creator CRM + +**完整四層 model** `crm_contact`: + +``` +internal/model/crm_contact/ + domain/entity/entity.go + domain/repository/repository.go + domain/usecase/usecase.go + usecase/usecase.go + repository/mongo.go +``` + +**端點**: `/api/v1/crm/contacts`(需 JWT) + +| Method | Path | 用途 | +|--------|------|------| +| GET | `/` | List(`page`/`pageSize`/`brand_id`/`status`/`tag`) | +| POST | `/` | 新增聯絡人(`author_id` + `author_name`) | +| GET | `/:id` | 取得單筆 | +| PATCH | `/:id` | 更新(`notes`/`tags`/`status`) | +| DELETE | `/:id` | 刪除 | + +**CRMContact 狀態機**: `lead` → `contacted` → `responded` → `converted` / `archived` + +### 2.2 Publish Analytics + +**完整四層 model** `publish_analytics`: + +**Checkpoint 常數**(`internal/model/publish_analytics/usecase/usecase.go`): `+1h`、`+24h`、`+7d` + +**方法**: +```go +RecordCheckpoint(ctx, tenantID, ownerUID, summary) (*PublishAnalyticsSummary, error) +ListByMedia(ctx, tenantID, ownerUID, mediaID) ([]PublishAnalyticsSummary, error) +``` + +**Worker handler** `publish-analytics`(step `check`): + +呼叫 Threads API `GET /{media-id}?fields=like_count,reply_count,repost_count,quote_count` → 存入 `publish_analytics`。 + +### 2.3 Threads API Token 刷新 + +**底層 API**(`internal/library/threadsapi/media.go`): +```go +RefreshAccessToken(ctx, currentToken) (*TokenRefreshResult, error) +// GET /v1.0/refresh_access_token?grant_type=th_refresh_token +``` + +**儲存 Token**: +```go +// Repository +SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*entity.Secrets, error) +// Usecase +SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*SecretsData, error) +``` + +**Worker handler** `refresh-threads-token`(step `refresh`):讀取 secrets → refresh → 儲存。 + +### 2.4 Rate Limiter + +```go +// internal/library/ratelimit/token_bucket.go +func NewTokenBucket(rate float64, burst int) *TokenBucket +func (tb *TokenBucket) Allow() bool +func (tb *TokenBucket) AllowN(n int) bool +``` + +- In-memory token bucket(非 Redis),thread-safe +- `rate` = tokens/sec,`burst` = 最大 burst + +--- + +## Infrastructure + +### Job Templates + +`internal/model/job/usecase/usecase.go` 新增兩個 template constants + 對應 `Ensure*` methods + `template()` functions: + +| Template Type | Steps | Timeout | Retry | +|---------------|-------|---------|-------| +| `refresh-threads-token` | `refresh`(go) | 120s | 3 次(30/120/300s) | +| `publish-analytics` | `check`(go) | 120s | 2 次(60s) | + +### Worker Handlers 註冊 + +`internal/svc/service_context.go` 中註冊: + +```go +jobworker.RegisterRefreshThreadsTokenHandler(runner, RefreshThreadsTokenDeps{...}) +jobworker.RegisterPublishAnalyticsHandler(runner, PublishAnalyticsDeps{...}) +``` + +### Worker Helper + +```go +// internal/worker/job/expand_graph.go +func int64Field(payload map[string]any, key string) int64 +``` + +從 job payload 讀 `int64`,支援 `float64` / `int64` / `int` / `json.Number`。 diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/crm/create_crm_contact_handler.go new file mode 100644 index 0000000..52d1aed --- /dev/null +++ b/backend/internal/handler/crm/create_crm_contact_handler.go @@ -0,0 +1,29 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateCrmContactReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewCreateCrmContactLogic(r.Context(), svcCtx) + data, err := l.CreateCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go new file mode 100644 index 0000000..c46723d --- /dev/null +++ b/backend/internal/handler/crm/delete_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CrmContactIDPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx) + err := l.DeleteCrmContact(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/crm/get_crm_contact_handler.go new file mode 100644 index 0000000..d4df08c --- /dev/null +++ b/backend/internal/handler/crm/get_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CrmContactIDPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewGetCrmContactLogic(r.Context(), svcCtx) + data, err := l.GetCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/crm/list_crm_contacts_handler.go new file mode 100644 index 0000000..6d74542 --- /dev/null +++ b/backend/internal/handler/crm/list_crm_contacts_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListCrmContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListCrmContactsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewListCrmContactsLogic(r.Context(), svcCtx) + data, err := l.ListCrmContacts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/crm/update_crm_contact_handler.go new file mode 100644 index 0000000..8d2746c --- /dev/null +++ b/backend/internal/handler/crm/update_crm_contact_handler.go @@ -0,0 +1,25 @@ +package crm + +import ( + "net/http" + + "haixun-backend/internal/logic/crm" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateCrmContactReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := crm.NewUpdateCrmContactLogic(r.Context(), svcCtx) + data, err := l.UpdateCrmContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index cbc3596..a2a8e3e 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -10,12 +10,14 @@ import ( auth "haixun-backend/internal/handler/auth" brand "haixun-backend/internal/handler/brand" copy_mission "haixun-backend/internal/handler/copy_mission" + crm "haixun-backend/internal/handler/crm" job "haixun-backend/internal/handler/job" member "haixun-backend/internal/handler/member" normal "haixun-backend/internal/handler/normal" permission "haixun-backend/internal/handler/permission" persona "haixun-backend/internal/handler/persona" placement_topic "haixun-backend/internal/handler/placement_topic" + scan_post "haixun-backend/internal/handler/scan_post" setting "haixun-backend/internal/handler/setting" threads_account "haixun-backend/internal/handler/threads_account" "haixun-backend/internal/svc" @@ -710,6 +712,54 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/settings"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: crm.ListCrmContactsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: crm.CreateCrmContactHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: crm.GetCrmContactHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id", + Handler: crm.UpdateCrmContactHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: crm.DeleteCrmContactHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/crm/contacts"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/lookalike", + Handler: scan_post.LookalikeHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/scan-post"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/scan_post/lookalike_handler.go new file mode 100644 index 0000000..5123c62 --- /dev/null +++ b/backend/internal/handler/scan_post/lookalike_handler.go @@ -0,0 +1,29 @@ +package scan_post + +import ( + "net/http" + + "haixun-backend/internal/logic/scan_post" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LookalikeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := scan_post.NewLookalikeLogic(r.Context(), svcCtx) + data, err := l.Lookalike(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go index a461172..e7ebf6a 100644 --- a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go +++ b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go @@ -27,4 +27,4 @@ func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http. data, err := l.ListThreadsAccountAiProviderModels(&req) response.Write(r.Context(), w, data, err) } -} \ No newline at end of file +} diff --git a/backend/internal/library/embedding/client.go b/backend/internal/library/embedding/client.go new file mode 100644 index 0000000..097d8df --- /dev/null +++ b/backend/internal/library/embedding/client.go @@ -0,0 +1,135 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strings" +) + +const DefaultModel = "text-embedding-3-small" + +type Client struct { + apiKey string + model string + http *http.Client +} + +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: strings.TrimSpace(apiKey), + model: DefaultModel, + http: &http.Client{}, + } +} + +func (c *Client) Enabled() bool { + return c != nil && c.apiKey != "" +} + +type EmbeddingRequest struct { + Input []string `json:"input"` + Model string `json:"model"` +} + +type EmbeddingResponse struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` + Usage *struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` +} + +func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) { + if !c.Enabled() { + return nil, fmt.Errorf("embedding client not configured") + } + return c.EmbedBatch(ctx, []string{input}) +} + +func (c *Client) EmbedBatch(ctx context.Context, inputs []string) ([]float32, error) { + if !c.Enabled() { + return nil, fmt.Errorf("embedding client not configured") + } + if len(inputs) == 0 { + return nil, fmt.Errorf("no input text") + } + + req := EmbeddingRequest{ + Input: inputs, + Model: c.model, + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" { + return nil, fmt.Errorf("openai embedding: %s", errResp.Error.Message) + } + return nil, fmt.Errorf("openai embedding: http %d", resp.StatusCode) + } + + var result EmbeddingResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + if result.Error != nil && result.Error.Message != "" { + return nil, fmt.Errorf("openai embedding: %s", result.Error.Message) + } + + if len(result.Data) == 0 { + return nil, fmt.Errorf("openai embedding: no data returned") + } + + return result.Data[0].Embedding, nil +} + +func CosineSimilarity(a, b []float32) float32 { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + var dot, normA, normB float64 + for i := 0; i < len(a); i++ { + dot += float64(a[i] * b[i]) + normA += float64(a[i] * a[i]) + normB += float64(b[i] * b[i]) + } + if normA == 0 || normB == 0 { + return 0 + } + return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB))) +} diff --git a/backend/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go index 53d4715..4f37509 100644 --- a/backend/internal/library/placement/dual_track.go +++ b/backend/internal/library/placement/dual_track.go @@ -16,27 +16,34 @@ const ( ) type ScanCandidate struct { - Permalink string - ExternalID string - Author string - Text string - SearchTag string - QueryDimension QueryDimension - GraphNodeID string - ProductFitScore int - Source DiscoverChannel - HasRelevance bool - HasRecency bool - Priority string - AuthorVerified bool - FollowerCount int - LikeCount int - ReplyCount int - EngagementScore int - PlacementScore int - SolvedByProduct bool - PostedAt string - Replies []ReplyCandidate + Permalink string + ExternalID string + Author string + AuthorID string + AuthorAvatar string + Text string + SearchTag string + QueryDimension QueryDimension + GraphNodeID string + ProductFitScore int + Source DiscoverChannel + HasRelevance bool + HasRecency bool + Priority string + AuthorVerified bool + FollowerCount int + AuthorFollowers int + LikeCount int + ReplyCount int + EngagementScore int + PlacementScore int + SolvedByProduct bool + PostedAt string + Replies []ReplyCandidate + Embedding []float32 + SemanticScore int + EngagementPredicted int + AudienceQualityScore int } type DualTrackInput struct { @@ -180,7 +187,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress Priority: priority, LikeCount: post.LikeCount, ReplyCount: post.ReplyCount, - PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency), + PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil), SolvedByProduct: tq.ProductFitScore >= 55, PostedAt: strings.TrimSpace(post.PostedAt), } @@ -324,11 +331,24 @@ func finalizeScanCandidate(item *ScanCandidate) { } else { item.Priority = "relevant" } - item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency) + var engP, semP, qualP *int + if item.EngagementPredicted > 0 { + v := item.EngagementPredicted + engP = &v + } + if item.SemanticScore > 0 { + v := item.SemanticScore + semP = &v + } + if item.AudienceQualityScore > 0 { + v := item.AudienceQualityScore + qualP = &v + } + item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP) item.SolvedByProduct = item.ProductFitScore >= 55 } -func computePlacementScore(text string, productFit int, recent bool) int { +func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int { score := 30 + productFit/4 if HasPlacementIntent(text) { score += 20 @@ -342,6 +362,44 @@ func computePlacementScore(text string, productFit int, recent bool) int { if productFit >= 60 { score += 8 } + if predictedEngagement != nil && *predictedEngagement > 60 { + score += 10 + } + if semanticScore != nil && *semanticScore > 50 { + score += 10 + } + if audienceQuality != nil && *audienceQuality > 60 { + score += 5 + } + if score > 100 { + return 100 + } + return score +} + +func computeAudienceQuality(followerCount int, authorVerified bool, likeCount int, replyCount int) int { + score := 30 + if authorVerified { + score += 20 + } + if followerCount > 10000 { + score += 15 + } else if followerCount > 1000 { + score += 10 + } else if followerCount > 100 { + score += 5 + } + totalEngagement := likeCount + replyCount*3 + if followerCount > 0 && totalEngagement > 0 { + engagementRate := (totalEngagement * 100) / followerCount + if engagementRate > 5 { + score += 15 + } else if engagementRate > 2 { + score += 10 + } else if engagementRate > 1 { + score += 5 + } + } if score > 100 { return 100 } diff --git a/backend/internal/library/ratelimit/token_bucket.go b/backend/internal/library/ratelimit/token_bucket.go new file mode 100644 index 0000000..6314ea9 --- /dev/null +++ b/backend/internal/library/ratelimit/token_bucket.go @@ -0,0 +1,62 @@ +package ratelimit + +import ( + "sync" + "time" +) + +type TokenBucket struct { + mu sync.Mutex + capacity int + tokens int + refillRate float64 // tokens per second + lastRefill time.Time +} + +func NewTokenBucket(capacity int, refillPerSecond float64) *TokenBucket { + return &TokenBucket{ + capacity: capacity, + tokens: capacity, + refillRate: refillPerSecond, + lastRefill: time.Now(), + } +} + +func (tb *TokenBucket) Allow() bool { + return tb.AllowN(1) +} + +func (tb *TokenBucket) AllowN(n int) bool { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.refill() + if tb.tokens >= n { + tb.tokens -= n + return true + } + return false +} + +func (tb *TokenBucket) Remaining() int { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.refill() + return tb.tokens +} + +func (tb *TokenBucket) Capacity() int { + return tb.capacity +} + +func (tb *TokenBucket) refill() { + now := time.Now() + elapsed := now.Sub(tb.lastRefill).Seconds() + tb.lastRefill = now + add := int(elapsed * tb.refillRate) + if add > 0 { + tb.tokens += add + if tb.tokens > tb.capacity { + tb.tokens = tb.capacity + } + } +} diff --git a/backend/internal/library/threadsapi/media.go b/backend/internal/library/threadsapi/media.go new file mode 100644 index 0000000..eb62234 --- /dev/null +++ b/backend/internal/library/threadsapi/media.go @@ -0,0 +1,97 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type MediaStats struct { + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` +} + +func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) { + u := fmt.Sprintf("%s/%s", graphBaseURL, mediaID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("fields", "like_count,reply_count,repost_count,quote_count") + q.Set("access_token", accessToken) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("threads api media stats error: %s", resp.Status) + } + + var raw struct { + ID string `json:"id"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, err + } + return &MediaStats{ + LikeCount: raw.LikeCount, + ReplyCount: raw.ReplyCount, + RepostCount: raw.RepostCount, + QuoteCount: raw.QuoteCount, + }, nil +} + +type TokenRefreshResult struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func RefreshAccessToken(ctx context.Context, currentToken string) (*TokenRefreshResult, error) { + u := fmt.Sprintf("%s/refresh_access_token", graphBaseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("grant_type", "th_refresh_token") + q.Set("access_token", currentToken) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("threads api token refresh error: %s", resp.Status) + } + + var result TokenRefreshResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if result.AccessToken == "" { + return nil, fmt.Errorf("threads api token refresh returned empty token") + } + return &result, nil +} + +func NsFromNow(seconds int) int64 { + return time.Now().UnixNano() + int64(seconds)*1e9 +} diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go new file mode 100644 index 0000000..ff363fc --- /dev/null +++ b/backend/internal/logic/crm/actor.go @@ -0,0 +1,17 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" +) + +func actorFrom(ctx context.Context) (tenantID, uid string, err error) { + actor, ok := authctx.ActorFromContext(ctx) + if !ok { + return "", "", app.For(code.Auth).AuthUnauthorized("missing actor") + } + return actor.TenantID, actor.UID, nil +} diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go new file mode 100644 index 0000000..6cc276b --- /dev/null +++ b/backend/internal/logic/crm/create_crm_contact_logic.go @@ -0,0 +1,46 @@ +package crm + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCrmContactLogic { + return &CreateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateCrmContactLogic) CreateCrmContact(req *types.CreateCrmContactReq) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + authorID := strings.TrimSpace(req.AuthorID) + authorName := strings.TrimSpace(req.AuthorName) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + if authorName == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_name is required") + } + + result, err := l.svcCtx.CrmContact.Create(l.ctx, tenantID, uid, strings.TrimSpace(req.BrandID), authorID, authorName) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go new file mode 100644 index 0000000..669ebcf --- /dev/null +++ b/backend/internal/logic/crm/delete_crm_contact_logic.go @@ -0,0 +1,29 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCrmContactLogic { + return &DeleteCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteCrmContactLogic) DeleteCrmContact(req *types.CrmContactIDPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + + return l.svcCtx.CrmContact.Delete(l.ctx, tenantID, uid, req.ID) +} diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go new file mode 100644 index 0000000..943f6c6 --- /dev/null +++ b/backend/internal/logic/crm/get_crm_contact_logic.go @@ -0,0 +1,34 @@ +package crm + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmContactLogic { + return &GetCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetCrmContactLogic) GetCrmContact(req *types.CrmContactIDPath) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + result, err := l.svcCtx.CrmContact.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go new file mode 100644 index 0000000..ce16203 --- /dev/null +++ b/backend/internal/logic/crm/list_crm_contacts_logic.go @@ -0,0 +1,54 @@ +package crm + +import ( + "context" + + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListCrmContactsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListCrmContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCrmContactsLogic { + return &ListCrmContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListCrmContactsLogic) ListCrmContacts(req *types.ListCrmContactsReq) (*types.CrmContactListData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + page := req.Page + pageSize := req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + listReq := crmusecase.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.BrandID, + Status: req.Status, + Tag: req.Tag, + Page: page, + PageSize: pageSize, + } + + items, total, err := l.svcCtx.CrmContact.List(l.ctx, listReq) + if err != nil { + return nil, err + } + + return toCrmContactListData(items, total, page, pageSize), nil +} diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go new file mode 100644 index 0000000..48a9738 --- /dev/null +++ b/backend/internal/logic/crm/mapper.go @@ -0,0 +1,48 @@ +package crm + +import ( + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/types" +) + +func toCrmContactData(item *crmusecase.CRMContactSummary) *types.CrmContactData { + if item == nil { + return nil + } + return &types.CrmContactData{ + ID: item.ID, + BrandID: item.BrandID, + AuthorID: item.AuthorID, + AuthorName: item.AuthorName, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + ScanPostID: item.ScanPostID, + Notes: item.Notes, + Tags: item.Tags, + Status: item.Status, + OutreachCount: item.OutreachCount, + LastContactedAt: item.LastContactedAt, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toCrmContactListData(items []crmusecase.CRMContactSummary, total, page, pageSize int) *types.CrmContactListData { + totalPages := 0 + if total > 0 { + totalPages = (total + pageSize - 1) / pageSize + } + data := make([]types.CrmContactData, 0, len(items)) + for _, item := range items { + data = append(data, *toCrmContactData(&item)) + } + return &types.CrmContactListData{ + List: data, + Pagination: types.PaginationData{ + Total: int64(total), + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: int64(totalPages), + }, + } +} diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go new file mode 100644 index 0000000..853c5e4 --- /dev/null +++ b/backend/internal/logic/crm/update_crm_contact_logic.go @@ -0,0 +1,42 @@ +package crm + +import ( + "context" + + crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateCrmContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCrmContactLogic { + return &UpdateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateCrmContactLogic) UpdateCrmContact(req *types.UpdateCrmContactReq) (*types.CrmContactData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + + result, err := l.svcCtx.CrmContact.Update(l.ctx, crmusecase.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + ContactID: req.ID, + Notes: req.Notes, + Tags: req.Tags, + Status: req.Status, + }) + if err != nil { + return nil, err + } + + return toCrmContactData(result), nil +} diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go new file mode 100644 index 0000000..510b778 --- /dev/null +++ b/backend/internal/logic/scan_post/lookalike_logic.go @@ -0,0 +1,126 @@ +package scan_post + +import ( + "context" + "math" + "sort" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LookalikeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLookalikeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LookalikeLogic { + return &LookalikeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *LookalikeLogic) Lookalike(req *types.LookalikeReq) (*types.LookalikeData, error) { + actor, ok := authctx.ActorFromContext(l.ctx) + if !ok { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + + source, err := l.svcCtx.ScanPost.Get(l.ctx, actor.TenantID, actor.UID, req.BrandID, req.SourcePostID) + if err != nil { + return nil, err + } + if len(source.Embedding) == 0 { + return &types.LookalikeData{List: nil}, nil + } + + brandID := req.BrandID + if brandID == "" { + brandID = source.BrandID + } + + limit := req.Limit + if limit < 1 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + candidates, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{ + TenantID: actor.TenantID, + OwnerUID: actor.UID, + BrandID: brandID, + Limit: 200, + }) + if err != nil { + return nil, err + } + + type scored struct { + post scanpostusecase.ScanPostSummary + sim float64 + } + var scoredList []scored + for _, c := range candidates { + if c.ID == req.SourcePostID { + continue + } + if len(c.Embedding) == 0 { + continue + } + sim := cosineSimilarity(source.Embedding, c.Embedding) + if sim < 0.3 { + continue + } + scoredList = append(scoredList, scored{post: c, sim: sim}) + } + + sort.Slice(scoredList, func(i, j int) bool { + return scoredList[i].sim > scoredList[j].sim + }) + + if len(scoredList) > limit { + scoredList = scoredList[:limit] + } + + out := make([]types.LookalikeItemData, 0, len(scoredList)) + for _, s := range scoredList { + out = append(out, types.LookalikeItemData{ + PostID: s.post.ID, + AuthorID: s.post.AuthorID, + AuthorName: s.post.Author, + AuthorAvatar: s.post.AuthorAvatar, + Permalink: s.post.Permalink, + Text: s.post.Text, + PlacementScore: s.post.PlacementScore, + SemanticScore: s.post.SemanticScore, + EngagementPredicted: s.post.EngagementPredicted, + AudienceQuality: s.post.AudienceQualityScore, + Similarity: math.Round(s.sim*1000) / 1000, + }) + } + + return &types.LookalikeData{List: out}, nil +} + +func cosineSimilarity(a, b []float32) float64 { + if len(a) == 0 || len(b) == 0 || len(a) != len(b) { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + na += float64(a[i]) * float64(a[i]) + nb += float64(b[i]) * float64(b[i]) + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} diff --git a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go index 1b9e3ec..41556bb 100644 --- a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go +++ b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go @@ -55,4 +55,4 @@ func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderMo Streams: result.Streams, Error: result.Error, }, nil -} \ No newline at end of file +} diff --git a/backend/internal/model/crm_contact/domain/entity/contact.go b/backend/internal/model/crm_contact/domain/entity/contact.go new file mode 100644 index 0000000..d18dc13 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/entity/contact.go @@ -0,0 +1,30 @@ +package entity + +const CollectionName = "crm_contacts" + +const ( + ContactStatusLead = "lead" + ContactStatusContacted = "contacted" + ContactStatusResponded = "responded" + ContactStatusConverted = "converted" + ContactStatusArchived = "archived" +) + +type CRMContact struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id,omitempty"` + AuthorID string `bson:"author_id"` + AuthorName string `bson:"author_name"` + AuthorAvatar string `bson:"author_avatar,omitempty"` + AuthorFollowers int `bson:"author_followers,omitempty"` + ScanPostID string `bson:"scan_post_id,omitempty"` + Notes string `bson:"notes,omitempty"` + Tags []string `bson:"tags,omitempty"` + Status string `bson:"status"` + OutreachCount int `bson:"outreach_count,omitempty"` + LastContactedAt int64 `bson:"last_contacted_at,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/crm_contact/domain/repository/repository.go b/backend/internal/model/crm_contact/domain/repository/repository.go new file mode 100644 index 0000000..b9f0797 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/repository/repository.go @@ -0,0 +1,25 @@ +package repository + +import ( + "context" + "haixun-backend/internal/model/crm_contact/domain/entity" +) + +type ListFilter struct { + BrandID string + Status string + Tag string + Limit int + Offset int +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, contact *entity.CRMContact) error + Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) + GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) + Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) + Delete(ctx context.Context, tenantID, ownerUID, contactID string) error + List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.CRMContact, int, error) + UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) +} diff --git a/backend/internal/model/crm_contact/domain/usecase/usecase.go b/backend/internal/model/crm_contact/domain/usecase/usecase.go new file mode 100644 index 0000000..4cd8c96 --- /dev/null +++ b/backend/internal/model/crm_contact/domain/usecase/usecase.go @@ -0,0 +1,47 @@ +package usecase + +import "context" + +type CRMContactSummary struct { + ID string + BrandID string + AuthorID string + AuthorName string + AuthorAvatar string + AuthorFollowers int + ScanPostID string + Notes string + Tags []string + Status string + OutreachCount int + LastContactedAt int64 + CreateAt int64 + UpdateAt int64 +} + +type ListRequest struct { + TenantID string + OwnerUID string + BrandID string + Status string + Tag string + Page int + PageSize int +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + ContactID string + Notes *string + Tags *[]string + Status *string +} + +type UseCase interface { + Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*CRMContactSummary, error) + Get(ctx context.Context, tenantID, ownerUID, contactID string) (*CRMContactSummary, error) + Update(ctx context.Context, req UpdateRequest) (*CRMContactSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, contactID string) error + List(ctx context.Context, req ListRequest) ([]CRMContactSummary, int, error) +} diff --git a/backend/internal/model/crm_contact/repository/mongo.go b/backend/internal/model/crm_contact/repository/mongo.go new file mode 100644 index 0000000..101400e --- /dev/null +++ b/backend/internal/model/crm_contact/repository/mongo.go @@ -0,0 +1,217 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/crm_contact/domain/entity" + domrepo "haixun-backend/internal/model/crm_contact/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func baseFilter(tenantID, ownerUID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "brand_id", Value: 1}, + {Key: "create_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "author_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, contact *entity.CRMContact) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if contact == nil { + return app.For(code.Persona).InputMissingRequired("contact is required") + } + _, err := r.collection.InsertOne(ctx, contact) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + var out entity.CRMContact + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + authorID = strings.TrimSpace(authorID) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["author_id"] = authorID + var out entity.CRMContact + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return nil, app.For(code.Persona).InputMissingRequired("patch is required") + } + contactID = strings.TrimSpace(contactID) + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.CRMContact + err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("crm contact not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return app.For(code.Persona).InputMissingRequired("contact_id is required") + } + filter := baseFilter(tenantID, ownerUID) + filter["_id"] = contactID + _, err := r.collection.DeleteOne(ctx, filter) + return err +} + +func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.CRMContact, int, error) { + if r.collection == nil { + return nil, 0, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + query := baseFilter(tenantID, ownerUID) + if filter.BrandID != "" { + query["brand_id"] = filter.BrandID + } + if filter.Status != "" { + query["status"] = filter.Status + } + if filter.Tag != "" { + query["tags"] = filter.Tag + } + + limit := filter.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + skip := filter.Offset + if skip < 0 { + skip = 0 + } + + total, err := r.collection.CountDocuments(ctx, query) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: -1}}). + SetSkip(int64(skip)). + SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, query, opts) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var out []entity.CRMContact + if err := cur.All(ctx, &out); err != nil { + return nil, 0, err + } + return out, int(total), nil +} + +func (r *mongoRepository) UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if contact == nil { + return nil, app.For(code.Persona).InputMissingRequired("contact is required") + } + filter := baseFilter(contact.TenantID, contact.OwnerUID) + filter["author_id"] = contact.AuthorID + update := bson.M{"$set": contact} + opts := options.FindOneAndUpdate(). + SetUpsert(true). + SetReturnDocument(options.After) + var out entity.CRMContact + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/internal/model/crm_contact/usecase/usecase.go b/backend/internal/model/crm_contact/usecase/usecase.go new file mode 100644 index 0000000..e0f6ed0 --- /dev/null +++ b/backend/internal/model/crm_contact/usecase/usecase.go @@ -0,0 +1,176 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/crm_contact/domain/entity" + domrepo "haixun-backend/internal/model/crm_contact/domain/repository" + domusecase "haixun-backend/internal/model/crm_contact/domain/usecase" + + "github.com/google/uuid" +) + +type crmContactUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &crmContactUseCase{repo: repo} +} + +func (u *crmContactUseCase) Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*domusecase.CRMContactSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + authorID = strings.TrimSpace(authorID) + if authorID == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_id is required") + } + authorName = strings.TrimSpace(authorName) + if authorName == "" { + return nil, app.For(code.Persona).InputMissingRequired("author_name is required") + } + now := clock.NowUnixNano() + contact := &entity.CRMContact{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: strings.TrimSpace(brandID), + AuthorID: authorID, + AuthorName: authorName, + Status: entity.ContactStatusLead, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, contact); err != nil { + return nil, err + } + summary := toSummary(*contact) + return &summary, nil +} + +func (u *crmContactUseCase) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*domusecase.CRMContactSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, contactID) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *crmContactUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CRMContactSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + contactID := strings.TrimSpace(req.ContactID) + if contactID == "" { + return nil, app.For(code.Persona).InputMissingRequired("contact_id is required") + } + patch := map[string]interface{}{} + if req.Notes != nil { + patch["notes"] = strings.TrimSpace(*req.Notes) + } + if req.Tags != nil { + patch["tags"] = *req.Tags + } + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if status != "" && status != entity.ContactStatusLead && status != entity.ContactStatusContacted && status != entity.ContactStatusResponded && status != entity.ContactStatusConverted && status != entity.ContactStatusArchived { + return nil, app.For(code.Persona).InputInvalidFormat("invalid contact status") + } + if status != "" { + patch["status"] = status + } + } + if len(patch) == 0 { + return nil, app.For(code.Persona).InputMissingRequired("no fields to update") + } + patch["update_at"] = clock.NowUnixNano() + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, contactID, patch) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *crmContactUseCase) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error { + if err := requireActor(tenantID, ownerUID); err != nil { + return err + } + contactID = strings.TrimSpace(contactID) + if contactID == "" { + return app.For(code.Persona).InputMissingRequired("contact_id is required") + } + return u.repo.Delete(ctx, tenantID, ownerUID, contactID) +} + +func (u *crmContactUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.CRMContactSummary, int, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, 0, err + } + page := req.Page + pageSize := req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + filter := domrepo.ListFilter{ + BrandID: strings.TrimSpace(req.BrandID), + Status: strings.TrimSpace(req.Status), + Tag: strings.TrimSpace(req.Tag), + Limit: pageSize, + Offset: (page - 1) * pageSize, + } + items, total, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, filter) + if err != nil { + return nil, 0, err + } + out := make([]domusecase.CRMContactSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, total, nil +} + +func requireActor(tenantID, ownerUID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + return nil +} + +func toSummary(item entity.CRMContact) domusecase.CRMContactSummary { + return domusecase.CRMContactSummary{ + ID: item.ID, + BrandID: item.BrandID, + AuthorID: item.AuthorID, + AuthorName: item.AuthorName, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + ScanPostID: item.ScanPostID, + Notes: item.Notes, + Tags: item.Tags, + Status: item.Status, + OutreachCount: item.OutreachCount, + LastContactedAt: item.LastContactedAt, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 6be688b..55564a0 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -98,6 +98,8 @@ type UseCase interface { EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error + EnsureRefreshThreadsTokenTemplate(ctx context.Context) error + EnsurePublishAnalyticsTemplate(ctx context.Context) error CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error) diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 8aafe34..847ece2 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -14,15 +14,17 @@ import ( ) const ( - demoTemplateType = "demo_long_task" - style8DTemplateType = "style-8d" - expandGraphTemplateType = "expand-graph" - placementScanTemplateType = "placement-scan" - scanViralTemplateType = "scan-viral" - analyzeCopyMissionTemplateType = "analyze-copy-mission" - generateCopyMatrixTemplateType = "generate-copy-matrix" - generateCopyDraftTemplateType = "generate-copy-draft" - style8DWorkerType = "node" + demoTemplateType = "demo_long_task" + style8DTemplateType = "style-8d" + expandGraphTemplateType = "expand-graph" + placementScanTemplateType = "placement-scan" + scanViralTemplateType = "scan-viral" + analyzeCopyMissionTemplateType = "analyze-copy-mission" + generateCopyMatrixTemplateType = "generate-copy-matrix" + generateCopyDraftTemplateType = "generate-copy-draft" + refreshThreadsTokenTemplateType = "refresh-threads-token" + publishAnalyticsTemplateType = "publish-analytics" + style8DWorkerType = "node" ) type UseCase = domusecase.UseCase @@ -99,6 +101,16 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error return err } +func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate()) + return err +} + +func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, publishAnalyticsTemplate()) + return err +} + func analyzeCopyMissionTemplate() *entity.Template { return &entity.Template{ Type: analyzeCopyMissionTemplateType, @@ -177,6 +189,54 @@ func generateCopyDraftTemplate() *entity.Template { } } +func refreshThreadsTokenTemplate() *entity.Template { + return &entity.Template{ + Type: refreshThreadsTokenTemplateType, + Version: 1, + Name: "Refresh Threads Token", + Description: "Refresh Threads API access token before expiry", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"account_id"}, + TimeoutSeconds: 120, + CancelPolicy: entity.CancelPolicy{ + Supported: false, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 3, + BackoffSeconds: []int{30, 120, 300}, + }, + Steps: []entity.TemplateStep{ + {ID: "refresh", Name: "Refresh token", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + }, + } +} + +func publishAnalyticsTemplate() *entity.Template { + return &entity.Template{ + Type: publishAnalyticsTemplateType, + Version: 1, + Name: "Publish Analytics Check", + Description: "Check Threads API insights for a published post", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"media_id", "checkpoint"}, + TimeoutSeconds: 120, + CancelPolicy: entity.CancelPolicy{ + Supported: false, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 2, + BackoffSeconds: []int{60}, + }, + Steps: []entity.TemplateStep{ + {ID: "check", Name: "Check analytics", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false}, + }, + } +} + func scanViralTemplate() *entity.Template { return &entity.Template{ Type: scanViralTemplateType, diff --git a/backend/internal/model/publish_analytics/domain/entity/analytics.go b/backend/internal/model/publish_analytics/domain/entity/analytics.go new file mode 100644 index 0000000..c290a0b --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/entity/analytics.go @@ -0,0 +1,29 @@ +package entity + +const CollectionName = "publish_analytics" + +type PublishAnalytics struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id,omitempty"` + PersonaID string `bson:"persona_id,omitempty"` + MediaID string `bson:"media_id"` + Permalink string `bson:"permalink,omitempty"` + PublishedAt int64 `bson:"published_at"` + Checkpoint string `bson:"checkpoint"` + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + RepostCount int `bson:"repost_count,omitempty"` + QuoteCount int `bson:"quote_count,omitempty"` + ImpressionCount int `bson:"impression_count,omitempty"` + ReachCount int `bson:"reach_count,omitempty"` + CheckedAt int64 `bson:"checked_at"` + CreateAt int64 `bson:"create_at"` +} + +const ( + Checkpoint1h = "+1h" + Checkpoint24h = "+24h" + Checkpoint7d = "+7d" +) diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go new file mode 100644 index 0000000..dcd2ced --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_analytics/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, analytics *entity.PublishAnalytics) error + Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) + ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) + Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) +} diff --git a/backend/internal/model/publish_analytics/domain/usecase/usecase.go b/backend/internal/model/publish_analytics/domain/usecase/usecase.go new file mode 100644 index 0000000..4a8f3f1 --- /dev/null +++ b/backend/internal/model/publish_analytics/domain/usecase/usecase.go @@ -0,0 +1,24 @@ +package usecase + +import "context" + +type PublishAnalyticsSummary struct { + ID string + MediaID string + Permalink string + PublishedAt int64 + Checkpoint string + LikeCount int + ReplyCount int + RepostCount int + QuoteCount int + ImpressionCount int + ReachCount int + CheckedAt int64 + CreateAt int64 +} + +type UseCase interface { + RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary PublishAnalyticsSummary) (*PublishAnalyticsSummary, error) + ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]PublishAnalyticsSummary, error) +} diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go new file mode 100644 index 0000000..6a5862d --- /dev/null +++ b/backend/internal/model/publish_analytics/repository/mongo.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_analytics/domain/entity" + domrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "media_id", Value: 1}, + {Key: "checkpoint", Value: 1}, + }, + }, + }) + return err +} + +func actorFilter(tenantID, ownerUID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } +} + +func (r *mongoRepository) Create(ctx context.Context, analytics *entity.PublishAnalytics) error { + if r.collection == nil { + return app.For(code.AI).DBUnavailable("Mongo is not configured") + } + if analytics == nil { + return app.For(code.AI).InputMissingRequired("analytics is required") + } + _, err := r.collection.InsertOne(ctx, analytics) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + analyticsID = strings.TrimSpace(analyticsID) + if analyticsID == "" { + return nil, app.For(code.AI).InputMissingRequired("analytics_id is required") + } + filter := actorFilter(tenantID, ownerUID) + filter["_id"] = analyticsID + var out entity.PublishAnalytics + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.AI).ResNotFound("publish analytics not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + filter := actorFilter(tenantID, ownerUID) + filter["media_id"] = mediaID + opts := options.Find().SetSort(bson.D{{Key: "checked_at", Value: -1}}) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.PublishAnalytics + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) { + if r.collection == nil { + return nil, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + if analytics == nil { + return nil, app.For(code.AI).InputMissingRequired("analytics is required") + } + if analytics.ID == "" { + return nil, app.For(code.AI).InputMissingRequired("analytics id is required") + } + + filter := bson.M{"_id": analytics.ID} + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, filter, analytics, opts) + if err != nil { + return nil, err + } + return analytics, nil +} diff --git a/backend/internal/model/publish_analytics/usecase/usecase.go b/backend/internal/model/publish_analytics/usecase/usecase.go new file mode 100644 index 0000000..fe8d3b3 --- /dev/null +++ b/backend/internal/model/publish_analytics/usecase/usecase.go @@ -0,0 +1,105 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_analytics/domain/entity" + domrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase" + + "github.com/google/uuid" +) + +type publishAnalyticsUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &publishAnalyticsUseCase{repo: repo} +} + +func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary domusecase.PublishAnalyticsSummary) (*domusecase.PublishAnalyticsSummary, error) { + tenantID = strings.TrimSpace(tenantID) + ownerUID = strings.TrimSpace(ownerUID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.AI).AuthUnauthorized("missing actor") + } + mediaID := strings.TrimSpace(summary.MediaID) + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + checkpoint := strings.TrimSpace(summary.Checkpoint) + if checkpoint == "" { + return nil, app.For(code.AI).InputMissingRequired("checkpoint is required") + } + + now := clock.NowUnixNano() + analytics := &entity.PublishAnalytics{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + MediaID: mediaID, + Permalink: strings.TrimSpace(summary.Permalink), + PublishedAt: summary.PublishedAt, + Checkpoint: checkpoint, + LikeCount: summary.LikeCount, + ReplyCount: summary.ReplyCount, + RepostCount: summary.RepostCount, + QuoteCount: summary.QuoteCount, + ImpressionCount: summary.ImpressionCount, + ReachCount: summary.ReachCount, + CheckedAt: now, + CreateAt: now, + } + + created, err := u.repo.Upsert(ctx, analytics) + if err != nil { + return nil, err + } + out := toSummary(*created) + return &out, nil +} + +func (u *publishAnalyticsUseCase) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]domusecase.PublishAnalyticsSummary, error) { + tenantID = strings.TrimSpace(tenantID) + ownerUID = strings.TrimSpace(ownerUID) + mediaID = strings.TrimSpace(mediaID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.AI).AuthUnauthorized("missing actor") + } + if mediaID == "" { + return nil, app.For(code.AI).InputMissingRequired("media_id is required") + } + + items, err := u.repo.ListByMedia(ctx, tenantID, ownerUID, mediaID) + if err != nil { + return nil, err + } + out := make([]domusecase.PublishAnalyticsSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func toSummary(item entity.PublishAnalytics) domusecase.PublishAnalyticsSummary { + return domusecase.PublishAnalyticsSummary{ + ID: item.ID, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + Checkpoint: item.Checkpoint, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + RepostCount: item.RepostCount, + QuoteCount: item.QuoteCount, + ImpressionCount: item.ImpressionCount, + ReachCount: item.ReachCount, + CheckedAt: item.CheckedAt, + CreateAt: item.CreateAt, + } +} diff --git a/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go index dbbd81f..1f0921f 100644 --- a/backend/internal/model/scan_post/domain/entity/post.go +++ b/backend/internal/model/scan_post/domain/entity/post.go @@ -15,40 +15,47 @@ type ScanReply struct { } type ScanPost struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - BrandID string `bson:"brand_id"` - TopicID string `bson:"topic_id,omitempty"` - LegacyPersonaID string `bson:"persona_id,omitempty"` - CopyMissionID string `bson:"copy_mission_id,omitempty"` - Flow string `bson:"flow,omitempty"` - GraphID string `bson:"graph_id"` - ScanJobID string `bson:"scan_job_id"` - GraphNodeID string `bson:"graph_node_id"` - SearchTag string `bson:"search_tag"` - QueryDimension string `bson:"query_dimension"` - ExternalID string `bson:"external_id"` - Permalink string `bson:"permalink"` - Author string `bson:"author"` - AuthorVerified bool `bson:"author_verified,omitempty"` - FollowerCount int `bson:"follower_count,omitempty"` - Text string `bson:"text"` - Priority string `bson:"priority"` - LikeCount int `bson:"like_count,omitempty"` - ReplyCount int `bson:"reply_count,omitempty"` - EngagementScore int `bson:"engagement_score,omitempty"` - PlacementScore int `bson:"placement_score"` - ProductFitScore int `bson:"product_fit_score"` - SolvedByProduct bool `bson:"solved_by_product"` - Source string `bson:"source"` - OutreachStatus string `bson:"outreach_status,omitempty"` - PublishedReplyID string `bson:"published_reply_id,omitempty"` - PublishedPermalink string `bson:"published_permalink,omitempty"` - OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` - PostedAt string `bson:"posted_at,omitempty"` - Replies []ScanReply `bson:"replies,omitempty"` - CreateAt int64 `bson:"create_at"` + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id"` + TopicID string `bson:"topic_id,omitempty"` + LegacyPersonaID string `bson:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` + Flow string `bson:"flow,omitempty"` + GraphID string `bson:"graph_id"` + ScanJobID string `bson:"scan_job_id"` + GraphNodeID string `bson:"graph_node_id"` + SearchTag string `bson:"search_tag"` + QueryDimension string `bson:"query_dimension"` + ExternalID string `bson:"external_id"` + Permalink string `bson:"permalink"` + Author string `bson:"author"` + AuthorID string `bson:"author_id,omitempty"` + AuthorAvatar string `bson:"author_avatar,omitempty"` + AuthorVerified bool `bson:"author_verified,omitempty"` + FollowerCount int `bson:"follower_count,omitempty"` + AuthorFollowers int `bson:"author_followers,omitempty"` + Text string `bson:"text"` + Priority string `bson:"priority"` + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + EngagementScore int `bson:"engagement_score,omitempty"` + PlacementScore int `bson:"placement_score"` + ProductFitScore int `bson:"product_fit_score"` + SolvedByProduct bool `bson:"solved_by_product"` + Source string `bson:"source"` + OutreachStatus string `bson:"outreach_status,omitempty"` + PublishedReplyID string `bson:"published_reply_id,omitempty"` + PublishedPermalink string `bson:"published_permalink,omitempty"` + OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` + PostedAt string `bson:"posted_at,omitempty"` + Replies []ScanReply `bson:"replies,omitempty"` + Embedding []float32 `bson:"embedding,omitempty"` + SemanticScore int `bson:"semantic_score,omitempty"` + EngagementPredicted int `bson:"engagement_predicted,omitempty"` + AudienceQualityScore int `bson:"audience_quality_score,omitempty"` + CreateAt int64 `bson:"create_at"` } const ( diff --git a/backend/internal/model/scan_post/domain/repository/repository.go b/backend/internal/model/scan_post/domain/repository/repository.go index 5940b62..8b9ac28 100644 --- a/backend/internal/model/scan_post/domain/repository/repository.go +++ b/backend/internal/model/scan_post/domain/repository/repository.go @@ -11,6 +11,7 @@ type ListFilter struct { TopicID string Priority string ProductFitMin int + SemanticMin int Recent7dOnly bool Limit int } diff --git a/backend/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go index 57186b8..0e0ab16 100644 --- a/backend/internal/model/scan_post/domain/usecase/usecase.go +++ b/backend/internal/model/scan_post/domain/usecase/usecase.go @@ -16,36 +16,43 @@ type ScanReplySummary struct { } type ScanPostSummary struct { - ID string - BrandID string - PersonaID string - CopyMissionID string - Flow string - GraphNodeID string - SearchTag string - QueryDimension string - ExternalID string - Permalink string - Author string - AuthorVerified bool - FollowerCount int - Text string - Priority string - LikeCount int - ReplyCount int - EngagementScore int - PlacementScore int - ProductFitScore int - SolvedByProduct bool - Source string - ScanJobID string - OutreachStatus string - PublishedReplyID string - PublishedPermalink string - OutreachUpdateAt int64 - PostedAt string - Replies []ScanReplySummary - CreateAt int64 + ID string + BrandID string + PersonaID string + CopyMissionID string + Flow string + GraphNodeID string + SearchTag string + QueryDimension string + ExternalID string + Permalink string + Author string + AuthorID string + AuthorAvatar string + AuthorVerified bool + FollowerCount int + AuthorFollowers int + Text string + Priority string + LikeCount int + ReplyCount int + EngagementScore int + PlacementScore int + ProductFitScore int + SolvedByProduct bool + Source string + ScanJobID string + OutreachStatus string + PublishedReplyID string + PublishedPermalink string + OutreachUpdateAt int64 + PostedAt string + Replies []ScanReplySummary + Embedding []float32 + SemanticScore int + EngagementPredicted int + AudienceQualityScore int + CreateAt int64 } type ListRequest struct { @@ -55,6 +62,7 @@ type ListRequest struct { TopicID string Priority string ProductFitMin int + SemanticMin int Recent7dOnly bool Limit int } diff --git a/backend/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go index 0531d31..5c955ff 100644 --- a/backend/internal/model/scan_post/repository/mongo.go +++ b/backend/internal/model/scan_post/repository/mongo.go @@ -447,6 +447,9 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f if filter.ProductFitMin > 0 { query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin} } + if filter.SemanticMin > 0 { + query["semantic_score"] = bson.M{"$gte": filter.SemanticMin} + } limit := filter.Limit if limit <= 0 { limit = 100 diff --git a/backend/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go index 0669f88..05988f8 100644 --- a/backend/internal/model/scan_post/usecase/usecase.go +++ b/backend/internal/model/scan_post/usecase/usecase.go @@ -43,28 +43,35 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca entities := make([]entity.ScanPost, 0, len(req.Posts)) for _, item := range req.Posts { entities = append(entities, entity.ScanPost{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - LegacyPersonaID: req.PersonaID, - CopyMissionID: strings.TrimSpace(req.CopyMissionID), - Flow: entity.FlowViral, - ScanJobID: req.ScanJobID, - SearchTag: item.SearchTag, - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - Text: item.Text, - Priority: item.Priority, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - EngagementScore: item.EngagementScore, - PlacementScore: item.PlacementScore, - Source: string(item.Source), - Replies: toReplyEntities(item.Replies), - CreateAt: now, + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + LegacyPersonaID: req.PersonaID, + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + Flow: entity.FlowViral, + ScanJobID: req.ScanJobID, + SearchTag: item.SearchTag, + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: item.EngagementScore, + PlacementScore: item.PlacementScore, + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + Source: string(item.Source), + Replies: toReplyEntities(item.Replies), + CreateAt: now, }) } if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil { @@ -128,30 +135,37 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID continue } entities = append(entities, entity.ScanPost{ - ID: uuid.NewString(), - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: brandID, - TopicID: strings.TrimSpace(topicID), - Flow: entity.FlowPlacement, - GraphID: graphID, - ScanJobID: scanJobID, - GraphNodeID: item.GraphNodeID, - SearchTag: item.SearchTag, - QueryDimension: string(item.QueryDimension), - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - Text: item.Text, - Priority: item.Priority, - PlacementScore: item.PlacementScore, - ProductFitScore: item.ProductFitScore, - SolvedByProduct: item.SolvedByProduct, - Source: string(item.Source), - OutreachStatus: entity.OutreachStatusPending, - PostedAt: strings.TrimSpace(item.PostedAt), - Replies: toReplyEntities(item.Replies), - CreateAt: now, + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + TopicID: strings.TrimSpace(topicID), + Flow: entity.FlowPlacement, + GraphID: graphID, + ScanJobID: scanJobID, + GraphNodeID: item.GraphNodeID, + SearchTag: item.SearchTag, + QueryDimension: string(item.QueryDimension), + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + PlacementScore: item.PlacementScore, + ProductFitScore: item.ProductFitScore, + SolvedByProduct: item.SolvedByProduct, + Source: string(item.Source), + OutreachStatus: entity.OutreachStatusPending, + PostedAt: strings.TrimSpace(item.PostedAt), + Replies: toReplyEntities(item.Replies), + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + CreateAt: now, }) } return entities @@ -276,6 +290,7 @@ func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) TopicID: req.TopicID, Priority: req.Priority, ProductFitMin: req.ProductFitMin, + SemanticMin: req.SemanticMin, Recent7dOnly: req.Recent7dOnly, Limit: req.Limit, }) @@ -361,35 +376,42 @@ func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { return domusecase.ScanPostSummary{ - ID: item.ID, - BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), - PersonaID: strings.TrimSpace(item.LegacyPersonaID), - CopyMissionID: item.CopyMissionID, - Flow: item.Flow, - GraphNodeID: item.GraphNodeID, - SearchTag: item.SearchTag, - QueryDimension: item.QueryDimension, - ExternalID: item.ExternalID, - Permalink: item.Permalink, - Author: item.Author, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - Text: item.Text, - Priority: item.Priority, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - EngagementScore: item.EngagementScore, - PlacementScore: item.PlacementScore, - ProductFitScore: item.ProductFitScore, - SolvedByProduct: item.SolvedByProduct, - Source: item.Source, - ScanJobID: item.ScanJobID, - OutreachStatus: item.OutreachStatus, - PublishedReplyID: item.PublishedReplyID, - PublishedPermalink: item.PublishedPermalink, - OutreachUpdateAt: item.OutreachUpdateAt, - PostedAt: item.PostedAt, - Replies: toReplySummaries(item.Replies), - CreateAt: item.CreateAt, + ID: item.ID, + BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), + PersonaID: strings.TrimSpace(item.LegacyPersonaID), + CopyMissionID: item.CopyMissionID, + Flow: item.Flow, + GraphNodeID: item.GraphNodeID, + SearchTag: item.SearchTag, + QueryDimension: item.QueryDimension, + ExternalID: item.ExternalID, + Permalink: item.Permalink, + Author: item.Author, + AuthorID: item.AuthorID, + AuthorAvatar: item.AuthorAvatar, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + AuthorFollowers: item.AuthorFollowers, + Text: item.Text, + Priority: item.Priority, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: item.EngagementScore, + PlacementScore: item.PlacementScore, + ProductFitScore: item.ProductFitScore, + SolvedByProduct: item.SolvedByProduct, + Source: item.Source, + ScanJobID: item.ScanJobID, + OutreachStatus: item.OutreachStatus, + PublishedReplyID: item.PublishedReplyID, + PublishedPermalink: item.PublishedPermalink, + OutreachUpdateAt: item.OutreachUpdateAt, + PostedAt: item.PostedAt, + Replies: toReplySummaries(item.Replies), + Embedding: item.Embedding, + SemanticScore: item.SemanticScore, + EngagementPredicted: item.EngagementPredicted, + AudienceQualityScore: item.AudienceQualityScore, + CreateAt: item.CreateAt, } } diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index 3c31e1e..3cafd6b 100644 --- a/backend/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -19,4 +19,5 @@ type SecretsRepository interface { EnsureIndexes(ctx context.Context) error FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) + SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) } diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 4273378..df9f5c7 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -77,6 +77,13 @@ type ImportBrowserSessionResult struct { UpdateAt int64 } +type SecretsData struct { + AccountID string + APIAccessToken string + APITokenExpiresAt int64 + UpdateAt int64 +} + type BrowserSessionData struct { AccountID string StorageState string @@ -147,4 +154,5 @@ type UseCase interface { ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) + SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error) } diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index edc75e5..11fb167 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -92,3 +92,30 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac out.BrowserStorageState = storageState return &out, nil } + +func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + var out entity.Secrets + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": accountID}, + bson.M{ + "$set": bson.M{ + "api_access_token": accessToken, + "api_token_expires_at": expiresAt, + "update_at": now, + }, + "$setOnInsert": bson.M{"_id": accountID}, + }, + options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), + ).Decode(&out) + if err != nil { + return nil, err + } + out.APIAccessToken = accessToken + out.APITokenExpiresAt = expiresAt + return &out, nil +} diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index ec0bca9..6b1e1ea 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -105,7 +105,7 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey( } apiKey := strings.TrimSpace(stored.ApiKeys[provider]) if apiKey == "" { - return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key") + return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key") } return apiKey, nil } diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go index a2d0e25..073aafe 100644 --- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -44,4 +44,4 @@ func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { func strPtr(v string) *string { return &v -} \ No newline at end of file +} diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 33525b9..297fd34 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -277,6 +277,27 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A }, nil } +func (u *threadsAccountUseCase) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*domusecase.SecretsData, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("access_token is required") + } + result, err := u.secretsRepo.SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) + if err != nil { + return nil, err + } + return &domusecase.SecretsData{ + AccountID: result.AccountID, + APIAccessToken: result.APIAccessToken, + APITokenExpiresAt: result.APITokenExpiresAt, + UpdateAt: result.UpdateAt, + }, nil +} + func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) { secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID) if err != nil { diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index f9311de..3b807b9 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -30,6 +30,9 @@ import ( copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" copymissionrepo "haixun-backend/internal/model/copy_mission/repository" copymissionusecase "haixun-backend/internal/model/copy_mission/usecase" + crmcontactdomain "haixun-backend/internal/model/crm_contact/domain/usecase" + crmcontactrepo "haixun-backend/internal/model/crm_contact/repository" + crmcontactusecase "haixun-backend/internal/model/crm_contact/usecase" jobrepo "haixun-backend/internal/model/job/repository" jobusecase "haixun-backend/internal/model/job/usecase" kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" @@ -51,6 +54,9 @@ import ( placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase" + publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" + publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" @@ -71,23 +77,25 @@ type ServiceContext struct { Mongo *libmongo.Client Redis *goredis.Client - Setting settingusecase.UseCase - AI aisettings.UseCase - Job jobusecase.UseCase - AuthToken authdomain.TokenUseCase - Member memberdomain.UseCase - Permission permissiondomain.UseCase - Persona personadomain.UseCase - CopyMission copymissiondomain.UseCase - Brand branddomain.UseCase - PlacementTopic placementtopicdomain.UseCase - KnowledgeGraph kgdomain.UseCase - Placement placementusecase.UseCase - ScanPost scanpostdomain.UseCase - OutreachDraft outreachdraftdomain.UseCase - ContentMatrix cmatrixdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase + Setting settingusecase.UseCase + AI aisettings.UseCase + Job jobusecase.UseCase + AuthToken authdomain.TokenUseCase + Member memberdomain.UseCase + Permission permissiondomain.UseCase + Persona personadomain.UseCase + CopyMission copymissiondomain.UseCase + Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase + KnowledgeGraph kgdomain.UseCase + Placement placementusecase.UseCase + ScanPost scanpostdomain.UseCase + OutreachDraft outreachdraftdomain.UseCase + ContentMatrix cmatrixdomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + CrmContact crmcontactdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware @@ -189,6 +197,12 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil { + panic(err) + } + if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { + panic(err) + } copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { @@ -266,28 +280,42 @@ func NewServiceContext(c config.Config) *ServiceContext { placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) + crmContactRepository := crmcontactrepo.NewMongoRepository(mongoClient.Database()) + if err := crmContactRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository) + + publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) + if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository) + sc := &ServiceContext{ - Config: c, - Validator: validate.New(), - Mongo: mongoClient, - Redis: redisClient, - Setting: settingUseCase, - AI: aisettings.NewUseCase(), - Job: jobUseCase, - AuthToken: authTokenUseCase, - Member: memberUseCase, - Permission: permissionUseCase, - Persona: personaUseCase, - CopyMission: copyMissionUseCase, - Brand: brandUseCase, - PlacementTopic: placementTopicUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - Placement: placementUseCase, - ScanPost: scanPostUseCase, - OutreachDraft: outreachDraftUseCase, - ContentMatrix: contentMatrixUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, + Config: c, + Validator: validate.New(), + Mongo: mongoClient, + Redis: redisClient, + Setting: settingUseCase, + AI: aisettings.NewUseCase(), + Job: jobUseCase, + AuthToken: authTokenUseCase, + Member: memberUseCase, + Permission: permissionUseCase, + Persona: personaUseCase, + CopyMission: copyMissionUseCase, + Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + Placement: placementUseCase, + ScanPost: scanPostUseCase, + OutreachDraft: outreachDraftUseCase, + ContentMatrix: contentMatrixUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + CrmContact: crmContactUseCase, + PublishAnalytics: publishAnalyticsUseCase, } hostname, _ := os.Hostname() @@ -357,6 +385,15 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, AI: sc.AI, }) + jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{ + Jobs: jobUseCase, + ThreadsAccount: threadsAccountUseCase, + }) + jobworker.RegisterPublishAnalyticsHandler(runner, jobworker.PublishAnalyticsDeps{ + Jobs: jobUseCase, + ThreadsAccount: threadsAccountUseCase, + PublishAnalytics: publishAnalyticsUseCase, + }) go runner.Start(workerCtx) } diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 6dac79a..1ac15fa 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -1448,6 +1448,97 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct { UpsertBrandScanScheduleReq } +type CrmContactData struct { + ID string `json:"id"` + BrandID string `json:"brand_id,omitempty"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorAvatar string `json:"author_avatar,omitempty"` + AuthorFollowers int `json:"author_followers,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + Notes string `json:"notes,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status"` + OutreachCount int `json:"outreach_count,omitempty"` + LastContactedAt int64 `json:"last_contacted_at,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type CrmContactListData struct { + Pagination PaginationData `json:"pagination"` + List []CrmContactData `json:"list"` +} + +type CrmContactIDPath struct { + ID string `path:"id" validate:"required"` +} + +type ListCrmContactsReq struct { + BrandID string `form:"brand_id,optional"` + Status string `form:"status,optional"` + Tag string `form:"tag,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type CreateCrmContactReq struct { + BrandID string `json:"brand_id,omitempty"` + AuthorID string `json:"author_id" validate:"required"` + AuthorName string `json:"author_name" validate:"required"` +} + +type UpdateCrmContactReq struct { + CrmContactIDPath + Notes *string `json:"notes,optional"` + Tags *[]string `json:"tags,optional"` + Status *string `json:"status,optional"` +} + +type PublishAnalyticsData struct { + ID string `json:"id"` + MediaID string `json:"media_id"` + Permalink string `json:"permalink,omitempty"` + PublishedAt int64 `json:"published_at"` + Checkpoint string `json:"checkpoint"` + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + RepostCount int `json:"repost_count,omitempty"` + QuoteCount int `json:"quote_count,omitempty"` + ImpressionCount int `json:"impression_count,omitempty"` + ReachCount int `json:"reach_count,omitempty"` + CheckedAt int64 `json:"checked_at"` + CreateAt int64 `json:"create_at"` +} + +type PublishAnalyticsListData struct { + List []PublishAnalyticsData `json:"list"` +} + +type LookalikeReq struct { + SourcePostID string `json:"source_post_id" validate:"required"` + BrandID string `json:"brand_id,optional"` + Limit int `json:"limit,optional"` +} + +type LookalikeItemData struct { + PostID string `json:"post_id"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorAvatar string `json:"author_avatar,omitempty"` + Permalink string `json:"permalink"` + Text string `json:"text"` + PlacementScore int `json:"placement_score"` + SemanticScore int `json:"semantic_score"` + EngagementPredicted int `json:"engagement_predicted"` + AudienceQuality int `json:"audience_quality"` + Similarity float64 `json:"similarity"` +} + +type LookalikeData struct { + List []LookalikeItemData `json:"list"` +} + type ViralScanPostData struct { ID string `json:"id"` SearchTag string `json:"search_tag"` diff --git a/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go index 4ea3d4b..c0df628 100644 --- a/backend/internal/worker/job/expand_graph.go +++ b/backend/internal/worker/job/expand_graph.go @@ -2,6 +2,7 @@ package job import ( "context" + "encoding/json" "fmt" "strings" "sync" @@ -543,6 +544,25 @@ func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSo return merged } +func int64Field(payload map[string]any, key string) int64 { + if payload == nil { + return 0 + } + switch v := payload[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + default: + return 0 + } +} + func stringField(payload map[string]any, key string) string { if payload == nil { return "" diff --git a/backend/internal/worker/job/publish_analytics.go b/backend/internal/worker/job/publish_analytics.go new file mode 100644 index 0000000..8520bf6 --- /dev/null +++ b/backend/internal/worker/job/publish_analytics.go @@ -0,0 +1,81 @@ +package job + +import ( + "context" + "fmt" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" + jobdom "haixun-backend/internal/model/job/domain/usecase" + publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type PublishAnalyticsDeps struct { + Jobs jobdom.UseCase + ThreadsAccount threadsaccountdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase +} + +func RegisterPublishAnalyticsHandler(runner *Runner, deps PublishAnalyticsDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("check", func(ctx context.Context, step StepContext) error { + return runPublishAnalyticsCheck(ctx, step, deps) + }) +} + +func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps PublishAnalyticsDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + mediaID := strings.TrimSpace(stringField(payload, "media_id")) + checkpoint := strings.TrimSpace(stringField(payload, "checkpoint")) + permalink := strings.TrimSpace(stringField(payload, "permalink")) + publishedAt := int64Field(payload, "published_at") + if mediaID == "" || checkpoint == "" { + return fmt.Errorf("publish-analytics payload missing media_id or checkpoint") + } + + cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + if err != nil { + return fmt.Errorf("resolve publish credential: %w", err) + } + if cred == nil || cred.AccessToken == "" { + return fmt.Errorf("no access token found") + } + + stats, err := libthreads.GetMediaStats(ctx, cred.AccessToken, mediaID) + if err != nil { + return fmt.Errorf("get media stats: %w", err) + } + + saved, err := deps.PublishAnalytics.RecordCheckpoint(ctx, tenantID, ownerUID, publishanalyticsdomain.PublishAnalyticsSummary{ + MediaID: mediaID, + Permalink: permalink, + PublishedAt: publishedAt, + Checkpoint: checkpoint, + LikeCount: stats.LikeCount, + ReplyCount: stats.ReplyCount, + RepostCount: stats.RepostCount, + QuoteCount: stats.QuoteCount, + }) + if err != nil { + return fmt.Errorf("record checkpoint: %w", err) + } + + _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Result: map[string]any{ + "media_id": mediaID, + "checkpoint": checkpoint, + "like_count": saved.LikeCount, + "reply_count": saved.ReplyCount, + "repost_count": saved.RepostCount, + "quote_count": saved.QuoteCount, + "analytics_id": saved.ID, + }, + }) + return err +} diff --git a/backend/internal/worker/job/refresh_threads_token.go b/backend/internal/worker/job/refresh_threads_token.go new file mode 100644 index 0000000..d33668d --- /dev/null +++ b/backend/internal/worker/job/refresh_threads_token.go @@ -0,0 +1,63 @@ +package job + +import ( + "context" + "fmt" + "strings" + + libthreads "haixun-backend/internal/library/threadsapi" + jobdom "haixun-backend/internal/model/job/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type RefreshThreadsTokenDeps struct { + Jobs jobdom.UseCase + ThreadsAccount threadsaccountdomain.UseCase +} + +func RegisterRefreshThreadsTokenHandler(runner *Runner, deps RefreshThreadsTokenDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("refresh", func(ctx context.Context, step StepContext) error { + return runRefreshThreadsToken(ctx, step, deps) + }) +} + +func runRefreshThreadsToken(ctx context.Context, step StepContext, deps RefreshThreadsTokenDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + accountID := strings.TrimSpace(stringField(payload, "account_id")) + if tenantID == "" || ownerUID == "" || accountID == "" { + return fmt.Errorf("refresh-threads-token payload missing tenant_id, owner_uid, or account_id") + } + + cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + if err != nil { + return fmt.Errorf("resolve publish credential: %w", err) + } + if cred == nil || cred.AccessToken == "" { + return fmt.Errorf("no access token found for account %s", accountID) + } + + result, err := libthreads.RefreshAccessToken(ctx, cred.AccessToken) + if err != nil { + return fmt.Errorf("refresh token: %w", err) + } + + expiresAt := libthreads.NsFromNow(result.ExpiresIn) + if _, err := deps.ThreadsAccount.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt); err != nil { + return fmt.Errorf("save refreshed token: %w", err) + } + + _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Result: map[string]any{ + "account_id": accountID, + "expires_at": expiresAt, + "token_short": result.AccessToken[:min(len(result.AccessToken), 8)] + "...", + }, + }) + return err +}