feat/add_search #1

Merged
daniel.w merged 8 commits from feat/add_search into main 2026-06-29 16:11:12 +00:00
50 changed files with 2592 additions and 215 deletions
Showing only changes of commit 9ab5b6671d - Show all commits

View File

@ -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` | 語意相似度分數0100 |
| `EngagementPredicted` | `int` | 預測互動率 |
| `AudienceQualityScore` | `int` | 受眾品質分數0100 |
| `AuthorID` | `string` | 作者 IDThreads 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 → 0100
- **預測互動率**`EngagementPredicted`like+reply → 對數正規化 0100
- **受眾品質**`AudienceQualityScore``computeAudienceQuality()` heuristic
權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。
```go
func computeAudienceQuality(followers int) int
```
Heuristicfollowers 在 50050k 之間品質最佳,過低或過高遞減。
### 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非 Redisthread-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`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,12 +10,14 @@ import (
auth "haixun-backend/internal/handler/auth" auth "haixun-backend/internal/handler/auth"
brand "haixun-backend/internal/handler/brand" brand "haixun-backend/internal/handler/brand"
copy_mission "haixun-backend/internal/handler/copy_mission" copy_mission "haixun-backend/internal/handler/copy_mission"
crm "haixun-backend/internal/handler/crm"
job "haixun-backend/internal/handler/job" job "haixun-backend/internal/handler/job"
member "haixun-backend/internal/handler/member" member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal" normal "haixun-backend/internal/handler/normal"
permission "haixun-backend/internal/handler/permission" permission "haixun-backend/internal/handler/permission"
persona "haixun-backend/internal/handler/persona" persona "haixun-backend/internal/handler/persona"
placement_topic "haixun-backend/internal/handler/placement_topic" placement_topic "haixun-backend/internal/handler/placement_topic"
scan_post "haixun-backend/internal/handler/scan_post"
setting "haixun-backend/internal/handler/setting" setting "haixun-backend/internal/handler/setting"
threads_account "haixun-backend/internal/handler/threads_account" threads_account "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc" "haixun-backend/internal/svc"
@ -710,6 +712,54 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/settings"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},

View File

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

View File

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

View File

@ -19,6 +19,8 @@ type ScanCandidate struct {
Permalink string Permalink string
ExternalID string ExternalID string
Author string Author string
AuthorID string
AuthorAvatar string
Text string Text string
SearchTag string SearchTag string
QueryDimension QueryDimension QueryDimension QueryDimension
@ -30,6 +32,7 @@ type ScanCandidate struct {
Priority string Priority string
AuthorVerified bool AuthorVerified bool
FollowerCount int FollowerCount int
AuthorFollowers int
LikeCount int LikeCount int
ReplyCount int ReplyCount int
EngagementScore int EngagementScore int
@ -37,6 +40,10 @@ type ScanCandidate struct {
SolvedByProduct bool SolvedByProduct bool
PostedAt string PostedAt string
Replies []ReplyCandidate Replies []ReplyCandidate
Embedding []float32
SemanticScore int
EngagementPredicted int
AudienceQualityScore int
} }
type DualTrackInput struct { type DualTrackInput struct {
@ -180,7 +187,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
Priority: priority, Priority: priority,
LikeCount: post.LikeCount, LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount, 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, SolvedByProduct: tq.ProductFitScore >= 55,
PostedAt: strings.TrimSpace(post.PostedAt), PostedAt: strings.TrimSpace(post.PostedAt),
} }
@ -324,11 +331,24 @@ func finalizeScanCandidate(item *ScanCandidate) {
} else { } else {
item.Priority = "relevant" 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 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 score := 30 + productFit/4
if HasPlacementIntent(text) { if HasPlacementIntent(text) {
score += 20 score += 20
@ -342,6 +362,44 @@ func computePlacementScore(text string, productFit int, recent bool) int {
if productFit >= 60 { if productFit >= 60 {
score += 8 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 { if score > 100 {
return 100 return 100
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,6 +98,8 @@ type UseCase interface {
EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error
EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error
EnsureGenerateCopyDraftTemplate(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) CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
GetRun(ctx context.Context, jobID string) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error)

View File

@ -22,6 +22,8 @@ const (
analyzeCopyMissionTemplateType = "analyze-copy-mission" analyzeCopyMissionTemplateType = "analyze-copy-mission"
generateCopyMatrixTemplateType = "generate-copy-matrix" generateCopyMatrixTemplateType = "generate-copy-matrix"
generateCopyDraftTemplateType = "generate-copy-draft" generateCopyDraftTemplateType = "generate-copy-draft"
refreshThreadsTokenTemplateType = "refresh-threads-token"
publishAnalyticsTemplateType = "publish-analytics"
style8DWorkerType = "node" style8DWorkerType = "node"
) )
@ -99,6 +101,16 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error
return err 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 { func analyzeCopyMissionTemplate() *entity.Template {
return &entity.Template{ return &entity.Template{
Type: analyzeCopyMissionTemplateType, 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 { func scanViralTemplate() *entity.Template {
return &entity.Template{ return &entity.Template{
Type: scanViralTemplateType, Type: scanViralTemplateType,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,8 +31,11 @@ type ScanPost struct {
ExternalID string `bson:"external_id"` ExternalID string `bson:"external_id"`
Permalink string `bson:"permalink"` Permalink string `bson:"permalink"`
Author string `bson:"author"` Author string `bson:"author"`
AuthorID string `bson:"author_id,omitempty"`
AuthorAvatar string `bson:"author_avatar,omitempty"`
AuthorVerified bool `bson:"author_verified,omitempty"` AuthorVerified bool `bson:"author_verified,omitempty"`
FollowerCount int `bson:"follower_count,omitempty"` FollowerCount int `bson:"follower_count,omitempty"`
AuthorFollowers int `bson:"author_followers,omitempty"`
Text string `bson:"text"` Text string `bson:"text"`
Priority string `bson:"priority"` Priority string `bson:"priority"`
LikeCount int `bson:"like_count,omitempty"` LikeCount int `bson:"like_count,omitempty"`
@ -48,6 +51,10 @@ type ScanPost struct {
OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"`
PostedAt string `bson:"posted_at,omitempty"` PostedAt string `bson:"posted_at,omitempty"`
Replies []ScanReply `bson:"replies,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"` CreateAt int64 `bson:"create_at"`
} }

View File

@ -11,6 +11,7 @@ type ListFilter struct {
TopicID string TopicID string
Priority string Priority string
ProductFitMin int ProductFitMin int
SemanticMin int
Recent7dOnly bool Recent7dOnly bool
Limit int Limit int
} }

View File

@ -27,8 +27,11 @@ type ScanPostSummary struct {
ExternalID string ExternalID string
Permalink string Permalink string
Author string Author string
AuthorID string
AuthorAvatar string
AuthorVerified bool AuthorVerified bool
FollowerCount int FollowerCount int
AuthorFollowers int
Text string Text string
Priority string Priority string
LikeCount int LikeCount int
@ -45,6 +48,10 @@ type ScanPostSummary struct {
OutreachUpdateAt int64 OutreachUpdateAt int64
PostedAt string PostedAt string
Replies []ScanReplySummary Replies []ScanReplySummary
Embedding []float32
SemanticScore int
EngagementPredicted int
AudienceQualityScore int
CreateAt int64 CreateAt int64
} }
@ -55,6 +62,7 @@ type ListRequest struct {
TopicID string TopicID string
Priority string Priority string
ProductFitMin int ProductFitMin int
SemanticMin int
Recent7dOnly bool Recent7dOnly bool
Limit int Limit int
} }

View File

@ -447,6 +447,9 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f
if filter.ProductFitMin > 0 { if filter.ProductFitMin > 0 {
query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin} query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin}
} }
if filter.SemanticMin > 0 {
query["semantic_score"] = bson.M{"$gte": filter.SemanticMin}
}
limit := filter.Limit limit := filter.Limit
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100

View File

@ -54,14 +54,21 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
ExternalID: item.ExternalID, ExternalID: item.ExternalID,
Permalink: item.Permalink, Permalink: item.Permalink,
Author: item.Author, Author: item.Author,
AuthorID: item.AuthorID,
AuthorAvatar: item.AuthorAvatar,
AuthorVerified: item.AuthorVerified, AuthorVerified: item.AuthorVerified,
FollowerCount: item.FollowerCount, FollowerCount: item.FollowerCount,
AuthorFollowers: item.AuthorFollowers,
Text: item.Text, Text: item.Text,
Priority: item.Priority, Priority: item.Priority,
LikeCount: item.LikeCount, LikeCount: item.LikeCount,
ReplyCount: item.ReplyCount, ReplyCount: item.ReplyCount,
EngagementScore: item.EngagementScore, EngagementScore: item.EngagementScore,
PlacementScore: item.PlacementScore, PlacementScore: item.PlacementScore,
Embedding: item.Embedding,
SemanticScore: item.SemanticScore,
EngagementPredicted: item.EngagementPredicted,
AudienceQualityScore: item.AudienceQualityScore,
Source: string(item.Source), Source: string(item.Source),
Replies: toReplyEntities(item.Replies), Replies: toReplyEntities(item.Replies),
CreateAt: now, CreateAt: now,
@ -142,6 +149,9 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
ExternalID: item.ExternalID, ExternalID: item.ExternalID,
Permalink: item.Permalink, Permalink: item.Permalink,
Author: item.Author, Author: item.Author,
AuthorID: item.AuthorID,
AuthorAvatar: item.AuthorAvatar,
AuthorFollowers: item.AuthorFollowers,
Text: item.Text, Text: item.Text,
Priority: item.Priority, Priority: item.Priority,
PlacementScore: item.PlacementScore, PlacementScore: item.PlacementScore,
@ -151,6 +161,10 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
OutreachStatus: entity.OutreachStatusPending, OutreachStatus: entity.OutreachStatusPending,
PostedAt: strings.TrimSpace(item.PostedAt), PostedAt: strings.TrimSpace(item.PostedAt),
Replies: toReplyEntities(item.Replies), Replies: toReplyEntities(item.Replies),
Embedding: item.Embedding,
SemanticScore: item.SemanticScore,
EngagementPredicted: item.EngagementPredicted,
AudienceQualityScore: item.AudienceQualityScore,
CreateAt: now, CreateAt: now,
}) })
} }
@ -276,6 +290,7 @@ func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest)
TopicID: req.TopicID, TopicID: req.TopicID,
Priority: req.Priority, Priority: req.Priority,
ProductFitMin: req.ProductFitMin, ProductFitMin: req.ProductFitMin,
SemanticMin: req.SemanticMin,
Recent7dOnly: req.Recent7dOnly, Recent7dOnly: req.Recent7dOnly,
Limit: req.Limit, Limit: req.Limit,
}) })
@ -372,8 +387,11 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
ExternalID: item.ExternalID, ExternalID: item.ExternalID,
Permalink: item.Permalink, Permalink: item.Permalink,
Author: item.Author, Author: item.Author,
AuthorID: item.AuthorID,
AuthorAvatar: item.AuthorAvatar,
AuthorVerified: item.AuthorVerified, AuthorVerified: item.AuthorVerified,
FollowerCount: item.FollowerCount, FollowerCount: item.FollowerCount,
AuthorFollowers: item.AuthorFollowers,
Text: item.Text, Text: item.Text,
Priority: item.Priority, Priority: item.Priority,
LikeCount: item.LikeCount, LikeCount: item.LikeCount,
@ -390,6 +408,10 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
OutreachUpdateAt: item.OutreachUpdateAt, OutreachUpdateAt: item.OutreachUpdateAt,
PostedAt: item.PostedAt, PostedAt: item.PostedAt,
Replies: toReplySummaries(item.Replies), Replies: toReplySummaries(item.Replies),
Embedding: item.Embedding,
SemanticScore: item.SemanticScore,
EngagementPredicted: item.EngagementPredicted,
AudienceQualityScore: item.AudienceQualityScore,
CreateAt: item.CreateAt, CreateAt: item.CreateAt,
} }
} }

View File

@ -19,4 +19,5 @@ type SecretsRepository interface {
EnsureIndexes(ctx context.Context) error EnsureIndexes(ctx context.Context) error
FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
SaveBrowserStorageState(ctx context.Context, accountID, storageState 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)
} }

View File

@ -77,6 +77,13 @@ type ImportBrowserSessionResult struct {
UpdateAt int64 UpdateAt int64
} }
type SecretsData struct {
AccountID string
APIAccessToken string
APITokenExpiresAt int64
UpdateAt int64
}
type BrowserSessionData struct { type BrowserSessionData struct {
AccountID string AccountID string
StorageState string StorageState string
@ -147,4 +154,5 @@ type UseCase interface {
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error)
} }

View File

@ -92,3 +92,30 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
out.BrowserStorageState = storageState out.BrowserStorageState = storageState
return &out, nil 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
}

View File

@ -105,7 +105,7 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
} }
apiKey := strings.TrimSpace(stored.ApiKeys[provider]) apiKey := strings.TrimSpace(stored.ApiKeys[provider])
if apiKey == "" { if apiKey == "" {
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key") return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key")
} }
return apiKey, nil return apiKey, nil
} }

View File

@ -277,6 +277,27 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A
}, nil }, 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) { func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) {
secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID) secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID)
if err != nil { if err != nil {

View File

@ -30,6 +30,9 @@ import (
copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
copymissionrepo "haixun-backend/internal/model/copy_mission/repository" copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
copymissionusecase "haixun-backend/internal/model/copy_mission/usecase" 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" jobrepo "haixun-backend/internal/model/job/repository"
jobusecase "haixun-backend/internal/model/job/usecase" jobusecase "haixun-backend/internal/model/job/usecase"
kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
@ -51,6 +54,9 @@ import (
placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase" 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" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostrepo "haixun-backend/internal/model/scan_post/repository"
scanpostusecase "haixun-backend/internal/model/scan_post/usecase" scanpostusecase "haixun-backend/internal/model/scan_post/usecase"
@ -88,6 +94,8 @@ type ServiceContext struct {
ContentMatrix cmatrixdomain.UseCase ContentMatrix cmatrixdomain.UseCase
CopyDraft copydraftdomain.UseCase CopyDraft copydraftdomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase
CrmContact crmcontactdomain.UseCase
PublishAnalytics publishanalyticsdomain.UseCase
// Middlewares mounted per route group via generate/api `middleware:` directive. // Middlewares mounted per route group via generate/api `middleware:` directive.
AuthJWT rest.Middleware AuthJWT rest.Middleware
@ -189,6 +197,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil {
panic(err) 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()) copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database())
if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { if err := copyMissionRepository.EnsureIndexes(ctx); err != nil {
@ -266,6 +280,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) 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{ sc := &ServiceContext{
Config: c, Config: c,
Validator: validate.New(), Validator: validate.New(),
@ -288,6 +314,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
ContentMatrix: contentMatrixUseCase, ContentMatrix: contentMatrixUseCase,
CopyDraft: copyDraftUseCase, CopyDraft: copyDraftUseCase,
ThreadsAccount: threadsAccountUseCase, ThreadsAccount: threadsAccountUseCase,
CrmContact: crmContactUseCase,
PublishAnalytics: publishAnalyticsUseCase,
} }
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
@ -357,6 +385,15 @@ func NewServiceContext(c config.Config) *ServiceContext {
ThreadsAccount: threadsAccountUseCase, ThreadsAccount: threadsAccountUseCase,
AI: sc.AI, 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) go runner.Start(workerCtx)
} }

View File

@ -1448,6 +1448,97 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct {
UpsertBrandScanScheduleReq 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 { type ViralScanPostData struct {
ID string `json:"id"` ID string `json:"id"`
SearchTag string `json:"search_tag"` SearchTag string `json:"search_tag"`

View File

@ -2,6 +2,7 @@ package job
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@ -543,6 +544,25 @@ func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSo
return merged 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 { func stringField(payload map[string]any, key string) string {
if payload == nil { if payload == nil {
return "" return ""

View File

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

View File

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