add search and post

This commit is contained in:
王性驊 2026-06-27 16:03:28 +00:00
parent ae89e0451e
commit 9ab5b6671d
50 changed files with 2592 additions and 215 deletions

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

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
ExternalID string
Author string
AuthorID string
AuthorAvatar string
Text string
SearchTag string
QueryDimension QueryDimension
@ -30,6 +32,7 @@ type ScanCandidate struct {
Priority string
AuthorVerified bool
FollowerCount int
AuthorFollowers int
LikeCount int
ReplyCount int
EngagementScore int
@ -37,6 +40,10 @@ type ScanCandidate struct {
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
}

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

View File

@ -22,6 +22,8 @@ const (
analyzeCopyMissionTemplateType = "analyze-copy-mission"
generateCopyMatrixTemplateType = "generate-copy-matrix"
generateCopyDraftTemplateType = "generate-copy-draft"
refreshThreadsTokenTemplateType = "refresh-threads-token"
publishAnalyticsTemplateType = "publish-analytics"
style8DWorkerType = "node"
)
@ -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,

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"`
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"`
@ -48,6 +51,10 @@ type ScanPost struct {
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"`
}

View File

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

View File

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

View File

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

View File

@ -54,14 +54,21 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
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,
@ -142,6 +149,9 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
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,
@ -151,6 +161,10 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
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,
})
}
@ -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,
})
@ -372,8 +387,11 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
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,
@ -390,6 +408,10 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
@ -88,6 +94,8 @@ type ServiceContext struct {
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,6 +280,18 @@ 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(),
@ -288,6 +314,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
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)
}

View File

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

View File

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

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
}