feat/add_search #1
|
|
@ -0,0 +1,176 @@
|
|||
# 全 Phase 新增功能說明
|
||||
|
||||
## Phase 1 — TA 精準度
|
||||
|
||||
### 1. Semantic Embedding Reranker
|
||||
|
||||
**ScanPost entity 新增欄位**(`internal/model/scan_post/domain/entity/post.go`):
|
||||
|
||||
| 欄位 | 型別 | 用途 |
|
||||
|------|------|------|
|
||||
| `Embedding` | `[]float32` | OpenAI text-embedding-3-small 向量 |
|
||||
| `SemanticScore` | `int` | 語意相似度分數(0–100) |
|
||||
| `EngagementPredicted` | `int` | 預測互動率 |
|
||||
| `AudienceQualityScore` | `int` | 受眾品質分數(0–100) |
|
||||
| `AuthorID` | `string` | 作者 ID(Threads user ID) |
|
||||
| `AuthorAvatar` | `string` | 作者頭像 URL |
|
||||
| `AuthorFollowers` | `int` | 作者粉絲數 |
|
||||
|
||||
`ScanCandidate` struct 同步新增對應欄位(`internal/library/placement/dual_track.go`)。
|
||||
|
||||
### 2. Embedding Client
|
||||
|
||||
```go
|
||||
// internal/library/embedding/client.go
|
||||
func NewClient(apiKey, model string) *Client
|
||||
func (c *Client) Generate(ctx context.Context, text string) ([]float32, error)
|
||||
```
|
||||
|
||||
- 預設使用 OpenAI `text-embedding-3-small`(可降級 local BGE-M3)
|
||||
- Scorer: `CosineSimilarity(a, b []float32) float64`
|
||||
|
||||
### 3. Placement Score V2
|
||||
|
||||
```go
|
||||
// internal/library/placement/dual_track.go
|
||||
func computePlacementScore(post *ScanCandidate, member productContext, brand productMeta,
|
||||
semanticScore, predictedEngagement, audienceQuality *int) int
|
||||
```
|
||||
|
||||
整合三項 pointer 參數(nil 表示欄位不存在,跳過該項):
|
||||
|
||||
- **語意相似度**(`SemanticScore`):Embedding cosine → 0–100
|
||||
- **預測互動率**(`EngagementPredicted`):like+reply → 對數正規化 0–100
|
||||
- **受眾品質**(`AudienceQualityScore`):`computeAudienceQuality()` heuristic
|
||||
|
||||
權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。
|
||||
|
||||
```go
|
||||
func computeAudienceQuality(followers int) int
|
||||
```
|
||||
|
||||
Heuristic:followers 在 500–50k 之間品質最佳,過低或過高遞減。
|
||||
|
||||
### 4. Creator Lookalike
|
||||
|
||||
**端點**: `POST /api/v1/scan-post/lookalike`(需 JWT)
|
||||
|
||||
```json
|
||||
{ "source_post_id": "uuid", "brand_id": "...", "limit": 10 }
|
||||
|
||||
→ { "list": [{ "post_id", "author_id", "author_name", "similarity", ... }] }
|
||||
```
|
||||
|
||||
**演算法**: cosine similarity(門檻 ≥0.3),同 brand 內找 embedding 最接近的貼文回傳。
|
||||
|
||||
| 檔案 | |
|
||||
|------|--|
|
||||
| Handler | `internal/handler/scan_post/lookalike_handler.go` |
|
||||
| Logic | `internal/logic/scan_post/lookalike_logic.go` |
|
||||
| `cosineSimilarity` | `internal/logic/scan_post/lookalike_logic.go` — 全 Go 實作 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 營運流程
|
||||
|
||||
### 2.1 Creator CRM
|
||||
|
||||
**完整四層 model** `crm_contact`:
|
||||
|
||||
```
|
||||
internal/model/crm_contact/
|
||||
domain/entity/entity.go
|
||||
domain/repository/repository.go
|
||||
domain/usecase/usecase.go
|
||||
usecase/usecase.go
|
||||
repository/mongo.go
|
||||
```
|
||||
|
||||
**端點**: `/api/v1/crm/contacts`(需 JWT)
|
||||
|
||||
| Method | Path | 用途 |
|
||||
|--------|------|------|
|
||||
| GET | `/` | List(`page`/`pageSize`/`brand_id`/`status`/`tag`) |
|
||||
| POST | `/` | 新增聯絡人(`author_id` + `author_name`) |
|
||||
| GET | `/:id` | 取得單筆 |
|
||||
| PATCH | `/:id` | 更新(`notes`/`tags`/`status`) |
|
||||
| DELETE | `/:id` | 刪除 |
|
||||
|
||||
**CRMContact 狀態機**: `lead` → `contacted` → `responded` → `converted` / `archived`
|
||||
|
||||
### 2.2 Publish Analytics
|
||||
|
||||
**完整四層 model** `publish_analytics`:
|
||||
|
||||
**Checkpoint 常數**(`internal/model/publish_analytics/usecase/usecase.go`): `+1h`、`+24h`、`+7d`
|
||||
|
||||
**方法**:
|
||||
```go
|
||||
RecordCheckpoint(ctx, tenantID, ownerUID, summary) (*PublishAnalyticsSummary, error)
|
||||
ListByMedia(ctx, tenantID, ownerUID, mediaID) ([]PublishAnalyticsSummary, error)
|
||||
```
|
||||
|
||||
**Worker handler** `publish-analytics`(step `check`):
|
||||
|
||||
呼叫 Threads API `GET /{media-id}?fields=like_count,reply_count,repost_count,quote_count` → 存入 `publish_analytics`。
|
||||
|
||||
### 2.3 Threads API Token 刷新
|
||||
|
||||
**底層 API**(`internal/library/threadsapi/media.go`):
|
||||
```go
|
||||
RefreshAccessToken(ctx, currentToken) (*TokenRefreshResult, error)
|
||||
// GET /v1.0/refresh_access_token?grant_type=th_refresh_token
|
||||
```
|
||||
|
||||
**儲存 Token**:
|
||||
```go
|
||||
// Repository
|
||||
SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*entity.Secrets, error)
|
||||
// Usecase
|
||||
SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*SecretsData, error)
|
||||
```
|
||||
|
||||
**Worker handler** `refresh-threads-token`(step `refresh`):讀取 secrets → refresh → 儲存。
|
||||
|
||||
### 2.4 Rate Limiter
|
||||
|
||||
```go
|
||||
// internal/library/ratelimit/token_bucket.go
|
||||
func NewTokenBucket(rate float64, burst int) *TokenBucket
|
||||
func (tb *TokenBucket) Allow() bool
|
||||
func (tb *TokenBucket) AllowN(n int) bool
|
||||
```
|
||||
|
||||
- In-memory token bucket(非 Redis),thread-safe
|
||||
- `rate` = tokens/sec,`burst` = 最大 burst
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Job Templates
|
||||
|
||||
`internal/model/job/usecase/usecase.go` 新增兩個 template constants + 對應 `Ensure*` methods + `template()` functions:
|
||||
|
||||
| Template Type | Steps | Timeout | Retry |
|
||||
|---------------|-------|---------|-------|
|
||||
| `refresh-threads-token` | `refresh`(go) | 120s | 3 次(30/120/300s) |
|
||||
| `publish-analytics` | `check`(go) | 120s | 2 次(60s) |
|
||||
|
||||
### Worker Handlers 註冊
|
||||
|
||||
`internal/svc/service_context.go` 中註冊:
|
||||
|
||||
```go
|
||||
jobworker.RegisterRefreshThreadsTokenHandler(runner, RefreshThreadsTokenDeps{...})
|
||||
jobworker.RegisterPublishAnalyticsHandler(runner, PublishAnalyticsDeps{...})
|
||||
```
|
||||
|
||||
### Worker Helper
|
||||
|
||||
```go
|
||||
// internal/worker/job/expand_graph.go
|
||||
func int64Field(payload map[string]any, key string) int64
|
||||
```
|
||||
|
||||
從 job payload 讀 `int64`,支援 `float64` / `int64` / `int` / `json.Number`。
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type ListFilter struct {
|
|||
TopicID string
|
||||
Priority string
|
||||
ProductFitMin int
|
||||
SemanticMin int
|
||||
Recent7dOnly bool
|
||||
Limit int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue