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"
|
auth "haixun-backend/internal/handler/auth"
|
||||||
brand "haixun-backend/internal/handler/brand"
|
brand "haixun-backend/internal/handler/brand"
|
||||||
copy_mission "haixun-backend/internal/handler/copy_mission"
|
copy_mission "haixun-backend/internal/handler/copy_mission"
|
||||||
|
crm "haixun-backend/internal/handler/crm"
|
||||||
job "haixun-backend/internal/handler/job"
|
job "haixun-backend/internal/handler/job"
|
||||||
member "haixun-backend/internal/handler/member"
|
member "haixun-backend/internal/handler/member"
|
||||||
normal "haixun-backend/internal/handler/normal"
|
normal "haixun-backend/internal/handler/normal"
|
||||||
permission "haixun-backend/internal/handler/permission"
|
permission "haixun-backend/internal/handler/permission"
|
||||||
persona "haixun-backend/internal/handler/persona"
|
persona "haixun-backend/internal/handler/persona"
|
||||||
placement_topic "haixun-backend/internal/handler/placement_topic"
|
placement_topic "haixun-backend/internal/handler/placement_topic"
|
||||||
|
scan_post "haixun-backend/internal/handler/scan_post"
|
||||||
setting "haixun-backend/internal/handler/setting"
|
setting "haixun-backend/internal/handler/setting"
|
||||||
threads_account "haixun-backend/internal/handler/threads_account"
|
threads_account "haixun-backend/internal/handler/threads_account"
|
||||||
"haixun-backend/internal/svc"
|
"haixun-backend/internal/svc"
|
||||||
|
|
@ -710,6 +712,54 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
rest.WithPrefix("/api/v1/settings"),
|
rest.WithPrefix("/api/v1/settings"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
rest.WithMiddlewares(
|
||||||
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/",
|
||||||
|
Handler: crm.ListCrmContactsHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/",
|
||||||
|
Handler: crm.CreateCrmContactHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: crm.GetCrmContactHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: crm.UpdateCrmContactHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/:id",
|
||||||
|
Handler: crm.DeleteCrmContactHandler(serverCtx),
|
||||||
|
},
|
||||||
|
}...,
|
||||||
|
),
|
||||||
|
rest.WithPrefix("/api/v1/crm/contacts"),
|
||||||
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
rest.WithMiddlewares(
|
||||||
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/lookalike",
|
||||||
|
Handler: scan_post.LookalikeHandler(serverCtx),
|
||||||
|
},
|
||||||
|
}...,
|
||||||
|
),
|
||||||
|
rest.WithPrefix("/api/v1/scan-post"),
|
||||||
|
)
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
rest.WithMiddlewares(
|
rest.WithMiddlewares(
|
||||||
[]rest.Middleware{serverCtx.AuthJWT},
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
|
@ -16,27 +16,34 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ScanCandidate struct {
|
type ScanCandidate struct {
|
||||||
Permalink string
|
Permalink string
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Author string
|
Author string
|
||||||
Text string
|
AuthorID string
|
||||||
SearchTag string
|
AuthorAvatar string
|
||||||
QueryDimension QueryDimension
|
Text string
|
||||||
GraphNodeID string
|
SearchTag string
|
||||||
ProductFitScore int
|
QueryDimension QueryDimension
|
||||||
Source DiscoverChannel
|
GraphNodeID string
|
||||||
HasRelevance bool
|
ProductFitScore int
|
||||||
HasRecency bool
|
Source DiscoverChannel
|
||||||
Priority string
|
HasRelevance bool
|
||||||
AuthorVerified bool
|
HasRecency bool
|
||||||
FollowerCount int
|
Priority string
|
||||||
LikeCount int
|
AuthorVerified bool
|
||||||
ReplyCount int
|
FollowerCount int
|
||||||
EngagementScore int
|
AuthorFollowers int
|
||||||
PlacementScore int
|
LikeCount int
|
||||||
SolvedByProduct bool
|
ReplyCount int
|
||||||
PostedAt string
|
EngagementScore int
|
||||||
Replies []ReplyCandidate
|
PlacementScore int
|
||||||
|
SolvedByProduct bool
|
||||||
|
PostedAt string
|
||||||
|
Replies []ReplyCandidate
|
||||||
|
Embedding []float32
|
||||||
|
SemanticScore int
|
||||||
|
EngagementPredicted int
|
||||||
|
AudienceQualityScore int
|
||||||
}
|
}
|
||||||
|
|
||||||
type DualTrackInput struct {
|
type DualTrackInput struct {
|
||||||
|
|
@ -180,7 +187,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
LikeCount: post.LikeCount,
|
LikeCount: post.LikeCount,
|
||||||
ReplyCount: post.ReplyCount,
|
ReplyCount: post.ReplyCount,
|
||||||
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
|
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil),
|
||||||
SolvedByProduct: tq.ProductFitScore >= 55,
|
SolvedByProduct: tq.ProductFitScore >= 55,
|
||||||
PostedAt: strings.TrimSpace(post.PostedAt),
|
PostedAt: strings.TrimSpace(post.PostedAt),
|
||||||
}
|
}
|
||||||
|
|
@ -324,11 +331,24 @@ func finalizeScanCandidate(item *ScanCandidate) {
|
||||||
} else {
|
} else {
|
||||||
item.Priority = "relevant"
|
item.Priority = "relevant"
|
||||||
}
|
}
|
||||||
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency)
|
var engP, semP, qualP *int
|
||||||
|
if item.EngagementPredicted > 0 {
|
||||||
|
v := item.EngagementPredicted
|
||||||
|
engP = &v
|
||||||
|
}
|
||||||
|
if item.SemanticScore > 0 {
|
||||||
|
v := item.SemanticScore
|
||||||
|
semP = &v
|
||||||
|
}
|
||||||
|
if item.AudienceQualityScore > 0 {
|
||||||
|
v := item.AudienceQualityScore
|
||||||
|
qualP = &v
|
||||||
|
}
|
||||||
|
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
|
||||||
item.SolvedByProduct = item.ProductFitScore >= 55
|
item.SolvedByProduct = item.ProductFitScore >= 55
|
||||||
}
|
}
|
||||||
|
|
||||||
func computePlacementScore(text string, productFit int, recent bool) int {
|
func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
|
||||||
score := 30 + productFit/4
|
score := 30 + productFit/4
|
||||||
if HasPlacementIntent(text) {
|
if HasPlacementIntent(text) {
|
||||||
score += 20
|
score += 20
|
||||||
|
|
@ -342,6 +362,44 @@ func computePlacementScore(text string, productFit int, recent bool) int {
|
||||||
if productFit >= 60 {
|
if productFit >= 60 {
|
||||||
score += 8
|
score += 8
|
||||||
}
|
}
|
||||||
|
if predictedEngagement != nil && *predictedEngagement > 60 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if semanticScore != nil && *semanticScore > 50 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if audienceQuality != nil && *audienceQuality > 60 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
if score > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeAudienceQuality(followerCount int, authorVerified bool, likeCount int, replyCount int) int {
|
||||||
|
score := 30
|
||||||
|
if authorVerified {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if followerCount > 10000 {
|
||||||
|
score += 15
|
||||||
|
} else if followerCount > 1000 {
|
||||||
|
score += 10
|
||||||
|
} else if followerCount > 100 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
totalEngagement := likeCount + replyCount*3
|
||||||
|
if followerCount > 0 && totalEngagement > 0 {
|
||||||
|
engagementRate := (totalEngagement * 100) / followerCount
|
||||||
|
if engagementRate > 5 {
|
||||||
|
score += 15
|
||||||
|
} else if engagementRate > 2 {
|
||||||
|
score += 10
|
||||||
|
} else if engagementRate > 1 {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error
|
||||||
EnsureGenerateCopyMatrixTemplate(ctx context.Context) error
|
EnsureGenerateCopyMatrixTemplate(ctx context.Context) error
|
||||||
EnsureGenerateCopyDraftTemplate(ctx context.Context) error
|
EnsureGenerateCopyDraftTemplate(ctx context.Context) error
|
||||||
|
EnsureRefreshThreadsTokenTemplate(ctx context.Context) error
|
||||||
|
EnsurePublishAnalyticsTemplate(ctx context.Context) error
|
||||||
|
|
||||||
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
|
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
|
||||||
GetRun(ctx context.Context, jobID string) (*entity.Run, error)
|
GetRun(ctx context.Context, jobID string) (*entity.Run, error)
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
demoTemplateType = "demo_long_task"
|
demoTemplateType = "demo_long_task"
|
||||||
style8DTemplateType = "style-8d"
|
style8DTemplateType = "style-8d"
|
||||||
expandGraphTemplateType = "expand-graph"
|
expandGraphTemplateType = "expand-graph"
|
||||||
placementScanTemplateType = "placement-scan"
|
placementScanTemplateType = "placement-scan"
|
||||||
scanViralTemplateType = "scan-viral"
|
scanViralTemplateType = "scan-viral"
|
||||||
analyzeCopyMissionTemplateType = "analyze-copy-mission"
|
analyzeCopyMissionTemplateType = "analyze-copy-mission"
|
||||||
generateCopyMatrixTemplateType = "generate-copy-matrix"
|
generateCopyMatrixTemplateType = "generate-copy-matrix"
|
||||||
generateCopyDraftTemplateType = "generate-copy-draft"
|
generateCopyDraftTemplateType = "generate-copy-draft"
|
||||||
style8DWorkerType = "node"
|
refreshThreadsTokenTemplateType = "refresh-threads-token"
|
||||||
|
publishAnalyticsTemplateType = "publish-analytics"
|
||||||
|
style8DWorkerType = "node"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UseCase = domusecase.UseCase
|
type UseCase = domusecase.UseCase
|
||||||
|
|
@ -99,6 +101,16 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error {
|
||||||
|
_, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error {
|
||||||
|
_, err := u.templates.Upsert(ctx, publishAnalyticsTemplate())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func analyzeCopyMissionTemplate() *entity.Template {
|
func analyzeCopyMissionTemplate() *entity.Template {
|
||||||
return &entity.Template{
|
return &entity.Template{
|
||||||
Type: analyzeCopyMissionTemplateType,
|
Type: analyzeCopyMissionTemplateType,
|
||||||
|
|
@ -177,6 +189,54 @@ func generateCopyDraftTemplate() *entity.Template {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshThreadsTokenTemplate() *entity.Template {
|
||||||
|
return &entity.Template{
|
||||||
|
Type: refreshThreadsTokenTemplateType,
|
||||||
|
Version: 1,
|
||||||
|
Name: "Refresh Threads Token",
|
||||||
|
Description: "Refresh Threads API access token before expiry",
|
||||||
|
Enabled: true,
|
||||||
|
Repeatable: true,
|
||||||
|
ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
|
||||||
|
DedupeKeys: []string{"account_id"},
|
||||||
|
TimeoutSeconds: 120,
|
||||||
|
CancelPolicy: entity.CancelPolicy{
|
||||||
|
Supported: false,
|
||||||
|
},
|
||||||
|
RetryPolicy: entity.RetryPolicy{
|
||||||
|
MaxAttempts: 3,
|
||||||
|
BackoffSeconds: []int{30, 120, 300},
|
||||||
|
},
|
||||||
|
Steps: []entity.TemplateStep{
|
||||||
|
{ID: "refresh", Name: "Refresh token", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishAnalyticsTemplate() *entity.Template {
|
||||||
|
return &entity.Template{
|
||||||
|
Type: publishAnalyticsTemplateType,
|
||||||
|
Version: 1,
|
||||||
|
Name: "Publish Analytics Check",
|
||||||
|
Description: "Check Threads API insights for a published post",
|
||||||
|
Enabled: true,
|
||||||
|
Repeatable: true,
|
||||||
|
ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
|
||||||
|
DedupeKeys: []string{"media_id", "checkpoint"},
|
||||||
|
TimeoutSeconds: 120,
|
||||||
|
CancelPolicy: entity.CancelPolicy{
|
||||||
|
Supported: false,
|
||||||
|
},
|
||||||
|
RetryPolicy: entity.RetryPolicy{
|
||||||
|
MaxAttempts: 2,
|
||||||
|
BackoffSeconds: []int{60},
|
||||||
|
},
|
||||||
|
Steps: []entity.TemplateStep{
|
||||||
|
{ID: "check", Name: "Check analytics", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func scanViralTemplate() *entity.Template {
|
func scanViralTemplate() *entity.Template {
|
||||||
return &entity.Template{
|
return &entity.Template{
|
||||||
Type: scanViralTemplateType,
|
Type: scanViralTemplateType,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,40 +15,47 @@ type ScanReply struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScanPost struct {
|
type ScanPost struct {
|
||||||
ID string `bson:"_id"`
|
ID string `bson:"_id"`
|
||||||
TenantID string `bson:"tenant_id"`
|
TenantID string `bson:"tenant_id"`
|
||||||
OwnerUID string `bson:"owner_uid"`
|
OwnerUID string `bson:"owner_uid"`
|
||||||
BrandID string `bson:"brand_id"`
|
BrandID string `bson:"brand_id"`
|
||||||
TopicID string `bson:"topic_id,omitempty"`
|
TopicID string `bson:"topic_id,omitempty"`
|
||||||
LegacyPersonaID string `bson:"persona_id,omitempty"`
|
LegacyPersonaID string `bson:"persona_id,omitempty"`
|
||||||
CopyMissionID string `bson:"copy_mission_id,omitempty"`
|
CopyMissionID string `bson:"copy_mission_id,omitempty"`
|
||||||
Flow string `bson:"flow,omitempty"`
|
Flow string `bson:"flow,omitempty"`
|
||||||
GraphID string `bson:"graph_id"`
|
GraphID string `bson:"graph_id"`
|
||||||
ScanJobID string `bson:"scan_job_id"`
|
ScanJobID string `bson:"scan_job_id"`
|
||||||
GraphNodeID string `bson:"graph_node_id"`
|
GraphNodeID string `bson:"graph_node_id"`
|
||||||
SearchTag string `bson:"search_tag"`
|
SearchTag string `bson:"search_tag"`
|
||||||
QueryDimension string `bson:"query_dimension"`
|
QueryDimension string `bson:"query_dimension"`
|
||||||
ExternalID string `bson:"external_id"`
|
ExternalID string `bson:"external_id"`
|
||||||
Permalink string `bson:"permalink"`
|
Permalink string `bson:"permalink"`
|
||||||
Author string `bson:"author"`
|
Author string `bson:"author"`
|
||||||
AuthorVerified bool `bson:"author_verified,omitempty"`
|
AuthorID string `bson:"author_id,omitempty"`
|
||||||
FollowerCount int `bson:"follower_count,omitempty"`
|
AuthorAvatar string `bson:"author_avatar,omitempty"`
|
||||||
Text string `bson:"text"`
|
AuthorVerified bool `bson:"author_verified,omitempty"`
|
||||||
Priority string `bson:"priority"`
|
FollowerCount int `bson:"follower_count,omitempty"`
|
||||||
LikeCount int `bson:"like_count,omitempty"`
|
AuthorFollowers int `bson:"author_followers,omitempty"`
|
||||||
ReplyCount int `bson:"reply_count,omitempty"`
|
Text string `bson:"text"`
|
||||||
EngagementScore int `bson:"engagement_score,omitempty"`
|
Priority string `bson:"priority"`
|
||||||
PlacementScore int `bson:"placement_score"`
|
LikeCount int `bson:"like_count,omitempty"`
|
||||||
ProductFitScore int `bson:"product_fit_score"`
|
ReplyCount int `bson:"reply_count,omitempty"`
|
||||||
SolvedByProduct bool `bson:"solved_by_product"`
|
EngagementScore int `bson:"engagement_score,omitempty"`
|
||||||
Source string `bson:"source"`
|
PlacementScore int `bson:"placement_score"`
|
||||||
OutreachStatus string `bson:"outreach_status,omitempty"`
|
ProductFitScore int `bson:"product_fit_score"`
|
||||||
PublishedReplyID string `bson:"published_reply_id,omitempty"`
|
SolvedByProduct bool `bson:"solved_by_product"`
|
||||||
PublishedPermalink string `bson:"published_permalink,omitempty"`
|
Source string `bson:"source"`
|
||||||
OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"`
|
OutreachStatus string `bson:"outreach_status,omitempty"`
|
||||||
PostedAt string `bson:"posted_at,omitempty"`
|
PublishedReplyID string `bson:"published_reply_id,omitempty"`
|
||||||
Replies []ScanReply `bson:"replies,omitempty"`
|
PublishedPermalink string `bson:"published_permalink,omitempty"`
|
||||||
CreateAt int64 `bson:"create_at"`
|
OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"`
|
||||||
|
PostedAt string `bson:"posted_at,omitempty"`
|
||||||
|
Replies []ScanReply `bson:"replies,omitempty"`
|
||||||
|
Embedding []float32 `bson:"embedding,omitempty"`
|
||||||
|
SemanticScore int `bson:"semantic_score,omitempty"`
|
||||||
|
EngagementPredicted int `bson:"engagement_predicted,omitempty"`
|
||||||
|
AudienceQualityScore int `bson:"audience_quality_score,omitempty"`
|
||||||
|
CreateAt int64 `bson:"create_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type ListFilter struct {
|
||||||
TopicID string
|
TopicID string
|
||||||
Priority string
|
Priority string
|
||||||
ProductFitMin int
|
ProductFitMin int
|
||||||
|
SemanticMin int
|
||||||
Recent7dOnly bool
|
Recent7dOnly bool
|
||||||
Limit int
|
Limit int
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,36 +16,43 @@ type ScanReplySummary struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScanPostSummary struct {
|
type ScanPostSummary struct {
|
||||||
ID string
|
ID string
|
||||||
BrandID string
|
BrandID string
|
||||||
PersonaID string
|
PersonaID string
|
||||||
CopyMissionID string
|
CopyMissionID string
|
||||||
Flow string
|
Flow string
|
||||||
GraphNodeID string
|
GraphNodeID string
|
||||||
SearchTag string
|
SearchTag string
|
||||||
QueryDimension string
|
QueryDimension string
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Permalink string
|
Permalink string
|
||||||
Author string
|
Author string
|
||||||
AuthorVerified bool
|
AuthorID string
|
||||||
FollowerCount int
|
AuthorAvatar string
|
||||||
Text string
|
AuthorVerified bool
|
||||||
Priority string
|
FollowerCount int
|
||||||
LikeCount int
|
AuthorFollowers int
|
||||||
ReplyCount int
|
Text string
|
||||||
EngagementScore int
|
Priority string
|
||||||
PlacementScore int
|
LikeCount int
|
||||||
ProductFitScore int
|
ReplyCount int
|
||||||
SolvedByProduct bool
|
EngagementScore int
|
||||||
Source string
|
PlacementScore int
|
||||||
ScanJobID string
|
ProductFitScore int
|
||||||
OutreachStatus string
|
SolvedByProduct bool
|
||||||
PublishedReplyID string
|
Source string
|
||||||
PublishedPermalink string
|
ScanJobID string
|
||||||
OutreachUpdateAt int64
|
OutreachStatus string
|
||||||
PostedAt string
|
PublishedReplyID string
|
||||||
Replies []ScanReplySummary
|
PublishedPermalink string
|
||||||
CreateAt int64
|
OutreachUpdateAt int64
|
||||||
|
PostedAt string
|
||||||
|
Replies []ScanReplySummary
|
||||||
|
Embedding []float32
|
||||||
|
SemanticScore int
|
||||||
|
EngagementPredicted int
|
||||||
|
AudienceQualityScore int
|
||||||
|
CreateAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListRequest struct {
|
type ListRequest struct {
|
||||||
|
|
@ -55,6 +62,7 @@ type ListRequest struct {
|
||||||
TopicID string
|
TopicID string
|
||||||
Priority string
|
Priority string
|
||||||
ProductFitMin int
|
ProductFitMin int
|
||||||
|
SemanticMin int
|
||||||
Recent7dOnly bool
|
Recent7dOnly bool
|
||||||
Limit int
|
Limit int
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,9 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f
|
||||||
if filter.ProductFitMin > 0 {
|
if filter.ProductFitMin > 0 {
|
||||||
query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin}
|
query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin}
|
||||||
}
|
}
|
||||||
|
if filter.SemanticMin > 0 {
|
||||||
|
query["semantic_score"] = bson.M{"$gte": filter.SemanticMin}
|
||||||
|
}
|
||||||
limit := filter.Limit
|
limit := filter.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|
|
||||||
|
|
@ -43,28 +43,35 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca
|
||||||
entities := make([]entity.ScanPost, 0, len(req.Posts))
|
entities := make([]entity.ScanPost, 0, len(req.Posts))
|
||||||
for _, item := range req.Posts {
|
for _, item := range req.Posts {
|
||||||
entities = append(entities, entity.ScanPost{
|
entities = append(entities, entity.ScanPost{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
TenantID: req.TenantID,
|
TenantID: req.TenantID,
|
||||||
OwnerUID: req.OwnerUID,
|
OwnerUID: req.OwnerUID,
|
||||||
LegacyPersonaID: req.PersonaID,
|
LegacyPersonaID: req.PersonaID,
|
||||||
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
|
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
|
||||||
Flow: entity.FlowViral,
|
Flow: entity.FlowViral,
|
||||||
ScanJobID: req.ScanJobID,
|
ScanJobID: req.ScanJobID,
|
||||||
SearchTag: item.SearchTag,
|
SearchTag: item.SearchTag,
|
||||||
ExternalID: item.ExternalID,
|
ExternalID: item.ExternalID,
|
||||||
Permalink: item.Permalink,
|
Permalink: item.Permalink,
|
||||||
Author: item.Author,
|
Author: item.Author,
|
||||||
AuthorVerified: item.AuthorVerified,
|
AuthorID: item.AuthorID,
|
||||||
FollowerCount: item.FollowerCount,
|
AuthorAvatar: item.AuthorAvatar,
|
||||||
Text: item.Text,
|
AuthorVerified: item.AuthorVerified,
|
||||||
Priority: item.Priority,
|
FollowerCount: item.FollowerCount,
|
||||||
LikeCount: item.LikeCount,
|
AuthorFollowers: item.AuthorFollowers,
|
||||||
ReplyCount: item.ReplyCount,
|
Text: item.Text,
|
||||||
EngagementScore: item.EngagementScore,
|
Priority: item.Priority,
|
||||||
PlacementScore: item.PlacementScore,
|
LikeCount: item.LikeCount,
|
||||||
Source: string(item.Source),
|
ReplyCount: item.ReplyCount,
|
||||||
Replies: toReplyEntities(item.Replies),
|
EngagementScore: item.EngagementScore,
|
||||||
CreateAt: now,
|
PlacementScore: item.PlacementScore,
|
||||||
|
Embedding: item.Embedding,
|
||||||
|
SemanticScore: item.SemanticScore,
|
||||||
|
EngagementPredicted: item.EngagementPredicted,
|
||||||
|
AudienceQualityScore: item.AudienceQualityScore,
|
||||||
|
Source: string(item.Source),
|
||||||
|
Replies: toReplyEntities(item.Replies),
|
||||||
|
CreateAt: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil {
|
if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil {
|
||||||
|
|
@ -128,30 +135,37 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entities = append(entities, entity.ScanPost{
|
entities = append(entities, entity.ScanPost{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
OwnerUID: ownerUID,
|
OwnerUID: ownerUID,
|
||||||
BrandID: brandID,
|
BrandID: brandID,
|
||||||
TopicID: strings.TrimSpace(topicID),
|
TopicID: strings.TrimSpace(topicID),
|
||||||
Flow: entity.FlowPlacement,
|
Flow: entity.FlowPlacement,
|
||||||
GraphID: graphID,
|
GraphID: graphID,
|
||||||
ScanJobID: scanJobID,
|
ScanJobID: scanJobID,
|
||||||
GraphNodeID: item.GraphNodeID,
|
GraphNodeID: item.GraphNodeID,
|
||||||
SearchTag: item.SearchTag,
|
SearchTag: item.SearchTag,
|
||||||
QueryDimension: string(item.QueryDimension),
|
QueryDimension: string(item.QueryDimension),
|
||||||
ExternalID: item.ExternalID,
|
ExternalID: item.ExternalID,
|
||||||
Permalink: item.Permalink,
|
Permalink: item.Permalink,
|
||||||
Author: item.Author,
|
Author: item.Author,
|
||||||
Text: item.Text,
|
AuthorID: item.AuthorID,
|
||||||
Priority: item.Priority,
|
AuthorAvatar: item.AuthorAvatar,
|
||||||
PlacementScore: item.PlacementScore,
|
AuthorFollowers: item.AuthorFollowers,
|
||||||
ProductFitScore: item.ProductFitScore,
|
Text: item.Text,
|
||||||
SolvedByProduct: item.SolvedByProduct,
|
Priority: item.Priority,
|
||||||
Source: string(item.Source),
|
PlacementScore: item.PlacementScore,
|
||||||
OutreachStatus: entity.OutreachStatusPending,
|
ProductFitScore: item.ProductFitScore,
|
||||||
PostedAt: strings.TrimSpace(item.PostedAt),
|
SolvedByProduct: item.SolvedByProduct,
|
||||||
Replies: toReplyEntities(item.Replies),
|
Source: string(item.Source),
|
||||||
CreateAt: now,
|
OutreachStatus: entity.OutreachStatusPending,
|
||||||
|
PostedAt: strings.TrimSpace(item.PostedAt),
|
||||||
|
Replies: toReplyEntities(item.Replies),
|
||||||
|
Embedding: item.Embedding,
|
||||||
|
SemanticScore: item.SemanticScore,
|
||||||
|
EngagementPredicted: item.EngagementPredicted,
|
||||||
|
AudienceQualityScore: item.AudienceQualityScore,
|
||||||
|
CreateAt: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return entities
|
return entities
|
||||||
|
|
@ -276,6 +290,7 @@ func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest)
|
||||||
TopicID: req.TopicID,
|
TopicID: req.TopicID,
|
||||||
Priority: req.Priority,
|
Priority: req.Priority,
|
||||||
ProductFitMin: req.ProductFitMin,
|
ProductFitMin: req.ProductFitMin,
|
||||||
|
SemanticMin: req.SemanticMin,
|
||||||
Recent7dOnly: req.Recent7dOnly,
|
Recent7dOnly: req.Recent7dOnly,
|
||||||
Limit: req.Limit,
|
Limit: req.Limit,
|
||||||
})
|
})
|
||||||
|
|
@ -361,35 +376,42 @@ func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary
|
||||||
|
|
||||||
func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
|
func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
|
||||||
return domusecase.ScanPostSummary{
|
return domusecase.ScanPostSummary{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID),
|
BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID),
|
||||||
PersonaID: strings.TrimSpace(item.LegacyPersonaID),
|
PersonaID: strings.TrimSpace(item.LegacyPersonaID),
|
||||||
CopyMissionID: item.CopyMissionID,
|
CopyMissionID: item.CopyMissionID,
|
||||||
Flow: item.Flow,
|
Flow: item.Flow,
|
||||||
GraphNodeID: item.GraphNodeID,
|
GraphNodeID: item.GraphNodeID,
|
||||||
SearchTag: item.SearchTag,
|
SearchTag: item.SearchTag,
|
||||||
QueryDimension: item.QueryDimension,
|
QueryDimension: item.QueryDimension,
|
||||||
ExternalID: item.ExternalID,
|
ExternalID: item.ExternalID,
|
||||||
Permalink: item.Permalink,
|
Permalink: item.Permalink,
|
||||||
Author: item.Author,
|
Author: item.Author,
|
||||||
AuthorVerified: item.AuthorVerified,
|
AuthorID: item.AuthorID,
|
||||||
FollowerCount: item.FollowerCount,
|
AuthorAvatar: item.AuthorAvatar,
|
||||||
Text: item.Text,
|
AuthorVerified: item.AuthorVerified,
|
||||||
Priority: item.Priority,
|
FollowerCount: item.FollowerCount,
|
||||||
LikeCount: item.LikeCount,
|
AuthorFollowers: item.AuthorFollowers,
|
||||||
ReplyCount: item.ReplyCount,
|
Text: item.Text,
|
||||||
EngagementScore: item.EngagementScore,
|
Priority: item.Priority,
|
||||||
PlacementScore: item.PlacementScore,
|
LikeCount: item.LikeCount,
|
||||||
ProductFitScore: item.ProductFitScore,
|
ReplyCount: item.ReplyCount,
|
||||||
SolvedByProduct: item.SolvedByProduct,
|
EngagementScore: item.EngagementScore,
|
||||||
Source: item.Source,
|
PlacementScore: item.PlacementScore,
|
||||||
ScanJobID: item.ScanJobID,
|
ProductFitScore: item.ProductFitScore,
|
||||||
OutreachStatus: item.OutreachStatus,
|
SolvedByProduct: item.SolvedByProduct,
|
||||||
PublishedReplyID: item.PublishedReplyID,
|
Source: item.Source,
|
||||||
PublishedPermalink: item.PublishedPermalink,
|
ScanJobID: item.ScanJobID,
|
||||||
OutreachUpdateAt: item.OutreachUpdateAt,
|
OutreachStatus: item.OutreachStatus,
|
||||||
PostedAt: item.PostedAt,
|
PublishedReplyID: item.PublishedReplyID,
|
||||||
Replies: toReplySummaries(item.Replies),
|
PublishedPermalink: item.PublishedPermalink,
|
||||||
CreateAt: item.CreateAt,
|
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
|
EnsureIndexes(ctx context.Context) error
|
||||||
FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
|
FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
|
||||||
SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error)
|
SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error)
|
||||||
|
SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,13 @@ type ImportBrowserSessionResult struct {
|
||||||
UpdateAt int64
|
UpdateAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretsData struct {
|
||||||
|
AccountID string
|
||||||
|
APIAccessToken string
|
||||||
|
APITokenExpiresAt int64
|
||||||
|
UpdateAt int64
|
||||||
|
}
|
||||||
|
|
||||||
type BrowserSessionData struct {
|
type BrowserSessionData struct {
|
||||||
AccountID string
|
AccountID string
|
||||||
StorageState string
|
StorageState string
|
||||||
|
|
@ -147,4 +154,5 @@ type UseCase interface {
|
||||||
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
|
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
|
||||||
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
|
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
|
||||||
ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
|
ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
|
||||||
|
SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,3 +92,30 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac
|
||||||
out.BrowserStorageState = storageState
|
out.BrowserStorageState = storageState
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) {
|
||||||
|
if r.collection == nil {
|
||||||
|
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||||
|
}
|
||||||
|
now := clock.NowUnixNano()
|
||||||
|
var out entity.Secrets
|
||||||
|
err := r.collection.FindOneAndUpdate(
|
||||||
|
ctx,
|
||||||
|
bson.M{"_id": accountID},
|
||||||
|
bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"api_access_token": accessToken,
|
||||||
|
"api_token_expires_at": expiresAt,
|
||||||
|
"update_at": now,
|
||||||
|
},
|
||||||
|
"$setOnInsert": bson.M{"_id": accountID},
|
||||||
|
},
|
||||||
|
options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
|
||||||
|
).Decode(&out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.APIAccessToken = accessToken
|
||||||
|
out.APITokenExpiresAt = expiresAt
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
|
||||||
}
|
}
|
||||||
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
|
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key")
|
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key")
|
||||||
}
|
}
|
||||||
return apiKey, nil
|
return apiKey, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,27 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *threadsAccountUseCase) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*domusecase.SecretsData, error) {
|
||||||
|
accountID = strings.TrimSpace(accountID)
|
||||||
|
if accountID == "" {
|
||||||
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
||||||
|
}
|
||||||
|
accessToken = strings.TrimSpace(accessToken)
|
||||||
|
if accessToken == "" {
|
||||||
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("access_token is required")
|
||||||
|
}
|
||||||
|
result, err := u.secretsRepo.SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &domusecase.SecretsData{
|
||||||
|
AccountID: result.AccountID,
|
||||||
|
APIAccessToken: result.APIAccessToken,
|
||||||
|
APITokenExpiresAt: result.APITokenExpiresAt,
|
||||||
|
UpdateAt: result.UpdateAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) {
|
func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) {
|
||||||
secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID)
|
secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import (
|
||||||
copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
|
copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
|
||||||
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
|
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
|
||||||
copymissionusecase "haixun-backend/internal/model/copy_mission/usecase"
|
copymissionusecase "haixun-backend/internal/model/copy_mission/usecase"
|
||||||
|
crmcontactdomain "haixun-backend/internal/model/crm_contact/domain/usecase"
|
||||||
|
crmcontactrepo "haixun-backend/internal/model/crm_contact/repository"
|
||||||
|
crmcontactusecase "haixun-backend/internal/model/crm_contact/usecase"
|
||||||
jobrepo "haixun-backend/internal/model/job/repository"
|
jobrepo "haixun-backend/internal/model/job/repository"
|
||||||
jobusecase "haixun-backend/internal/model/job/usecase"
|
jobusecase "haixun-backend/internal/model/job/usecase"
|
||||||
kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
|
kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
|
||||||
|
|
@ -51,6 +54,9 @@ import (
|
||||||
placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
|
placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
|
||||||
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
|
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
|
||||||
placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase"
|
placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase"
|
||||||
|
publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
|
||||||
|
publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
|
||||||
|
publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase"
|
||||||
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
|
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
|
||||||
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
|
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
|
||||||
scanpostusecase "haixun-backend/internal/model/scan_post/usecase"
|
scanpostusecase "haixun-backend/internal/model/scan_post/usecase"
|
||||||
|
|
@ -71,23 +77,25 @@ type ServiceContext struct {
|
||||||
Mongo *libmongo.Client
|
Mongo *libmongo.Client
|
||||||
Redis *goredis.Client
|
Redis *goredis.Client
|
||||||
|
|
||||||
Setting settingusecase.UseCase
|
Setting settingusecase.UseCase
|
||||||
AI aisettings.UseCase
|
AI aisettings.UseCase
|
||||||
Job jobusecase.UseCase
|
Job jobusecase.UseCase
|
||||||
AuthToken authdomain.TokenUseCase
|
AuthToken authdomain.TokenUseCase
|
||||||
Member memberdomain.UseCase
|
Member memberdomain.UseCase
|
||||||
Permission permissiondomain.UseCase
|
Permission permissiondomain.UseCase
|
||||||
Persona personadomain.UseCase
|
Persona personadomain.UseCase
|
||||||
CopyMission copymissiondomain.UseCase
|
CopyMission copymissiondomain.UseCase
|
||||||
Brand branddomain.UseCase
|
Brand branddomain.UseCase
|
||||||
PlacementTopic placementtopicdomain.UseCase
|
PlacementTopic placementtopicdomain.UseCase
|
||||||
KnowledgeGraph kgdomain.UseCase
|
KnowledgeGraph kgdomain.UseCase
|
||||||
Placement placementusecase.UseCase
|
Placement placementusecase.UseCase
|
||||||
ScanPost scanpostdomain.UseCase
|
ScanPost scanpostdomain.UseCase
|
||||||
OutreachDraft outreachdraftdomain.UseCase
|
OutreachDraft outreachdraftdomain.UseCase
|
||||||
ContentMatrix cmatrixdomain.UseCase
|
ContentMatrix cmatrixdomain.UseCase
|
||||||
CopyDraft copydraftdomain.UseCase
|
CopyDraft copydraftdomain.UseCase
|
||||||
ThreadsAccount threadsaccountdomain.UseCase
|
ThreadsAccount threadsaccountdomain.UseCase
|
||||||
|
CrmContact crmcontactdomain.UseCase
|
||||||
|
PublishAnalytics publishanalyticsdomain.UseCase
|
||||||
|
|
||||||
// Middlewares mounted per route group via generate/api `middleware:` directive.
|
// Middlewares mounted per route group via generate/api `middleware:` directive.
|
||||||
AuthJWT rest.Middleware
|
AuthJWT rest.Middleware
|
||||||
|
|
@ -189,6 +197,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil {
|
if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database())
|
copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database())
|
||||||
if err := copyMissionRepository.EnsureIndexes(ctx); err != nil {
|
if err := copyMissionRepository.EnsureIndexes(ctx); err != nil {
|
||||||
|
|
@ -266,28 +280,42 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
|
||||||
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
|
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
|
||||||
|
|
||||||
|
crmContactRepository := crmcontactrepo.NewMongoRepository(mongoClient.Database())
|
||||||
|
if err := crmContactRepository.EnsureIndexes(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository)
|
||||||
|
|
||||||
|
publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database())
|
||||||
|
if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository)
|
||||||
|
|
||||||
sc := &ServiceContext{
|
sc := &ServiceContext{
|
||||||
Config: c,
|
Config: c,
|
||||||
Validator: validate.New(),
|
Validator: validate.New(),
|
||||||
Mongo: mongoClient,
|
Mongo: mongoClient,
|
||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
Setting: settingUseCase,
|
Setting: settingUseCase,
|
||||||
AI: aisettings.NewUseCase(),
|
AI: aisettings.NewUseCase(),
|
||||||
Job: jobUseCase,
|
Job: jobUseCase,
|
||||||
AuthToken: authTokenUseCase,
|
AuthToken: authTokenUseCase,
|
||||||
Member: memberUseCase,
|
Member: memberUseCase,
|
||||||
Permission: permissionUseCase,
|
Permission: permissionUseCase,
|
||||||
Persona: personaUseCase,
|
Persona: personaUseCase,
|
||||||
CopyMission: copyMissionUseCase,
|
CopyMission: copyMissionUseCase,
|
||||||
Brand: brandUseCase,
|
Brand: brandUseCase,
|
||||||
PlacementTopic: placementTopicUseCase,
|
PlacementTopic: placementTopicUseCase,
|
||||||
KnowledgeGraph: knowledgeGraphUseCase,
|
KnowledgeGraph: knowledgeGraphUseCase,
|
||||||
Placement: placementUseCase,
|
Placement: placementUseCase,
|
||||||
ScanPost: scanPostUseCase,
|
ScanPost: scanPostUseCase,
|
||||||
OutreachDraft: outreachDraftUseCase,
|
OutreachDraft: outreachDraftUseCase,
|
||||||
ContentMatrix: contentMatrixUseCase,
|
ContentMatrix: contentMatrixUseCase,
|
||||||
CopyDraft: copyDraftUseCase,
|
CopyDraft: copyDraftUseCase,
|
||||||
ThreadsAccount: threadsAccountUseCase,
|
ThreadsAccount: threadsAccountUseCase,
|
||||||
|
CrmContact: crmContactUseCase,
|
||||||
|
PublishAnalytics: publishAnalyticsUseCase,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
|
@ -357,6 +385,15 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
ThreadsAccount: threadsAccountUseCase,
|
ThreadsAccount: threadsAccountUseCase,
|
||||||
AI: sc.AI,
|
AI: sc.AI,
|
||||||
})
|
})
|
||||||
|
jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{
|
||||||
|
Jobs: jobUseCase,
|
||||||
|
ThreadsAccount: threadsAccountUseCase,
|
||||||
|
})
|
||||||
|
jobworker.RegisterPublishAnalyticsHandler(runner, jobworker.PublishAnalyticsDeps{
|
||||||
|
Jobs: jobUseCase,
|
||||||
|
ThreadsAccount: threadsAccountUseCase,
|
||||||
|
PublishAnalytics: publishAnalyticsUseCase,
|
||||||
|
})
|
||||||
go runner.Start(workerCtx)
|
go runner.Start(workerCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1448,6 +1448,97 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct {
|
||||||
UpsertBrandScanScheduleReq
|
UpsertBrandScanScheduleReq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CrmContactData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BrandID string `json:"brand_id,omitempty"`
|
||||||
|
AuthorID string `json:"author_id"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorAvatar string `json:"author_avatar,omitempty"`
|
||||||
|
AuthorFollowers int `json:"author_followers,omitempty"`
|
||||||
|
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OutreachCount int `json:"outreach_count,omitempty"`
|
||||||
|
LastContactedAt int64 `json:"last_contacted_at,omitempty"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
UpdateAt int64 `json:"update_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrmContactListData struct {
|
||||||
|
Pagination PaginationData `json:"pagination"`
|
||||||
|
List []CrmContactData `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrmContactIDPath struct {
|
||||||
|
ID string `path:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListCrmContactsReq struct {
|
||||||
|
BrandID string `form:"brand_id,optional"`
|
||||||
|
Status string `form:"status,optional"`
|
||||||
|
Tag string `form:"tag,optional"`
|
||||||
|
Page int `form:"page,optional"`
|
||||||
|
PageSize int `form:"pageSize,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCrmContactReq struct {
|
||||||
|
BrandID string `json:"brand_id,omitempty"`
|
||||||
|
AuthorID string `json:"author_id" validate:"required"`
|
||||||
|
AuthorName string `json:"author_name" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCrmContactReq struct {
|
||||||
|
CrmContactIDPath
|
||||||
|
Notes *string `json:"notes,optional"`
|
||||||
|
Tags *[]string `json:"tags,optional"`
|
||||||
|
Status *string `json:"status,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishAnalyticsData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
Permalink string `json:"permalink,omitempty"`
|
||||||
|
PublishedAt int64 `json:"published_at"`
|
||||||
|
Checkpoint string `json:"checkpoint"`
|
||||||
|
LikeCount int `json:"like_count,omitempty"`
|
||||||
|
ReplyCount int `json:"reply_count,omitempty"`
|
||||||
|
RepostCount int `json:"repost_count,omitempty"`
|
||||||
|
QuoteCount int `json:"quote_count,omitempty"`
|
||||||
|
ImpressionCount int `json:"impression_count,omitempty"`
|
||||||
|
ReachCount int `json:"reach_count,omitempty"`
|
||||||
|
CheckedAt int64 `json:"checked_at"`
|
||||||
|
CreateAt int64 `json:"create_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishAnalyticsListData struct {
|
||||||
|
List []PublishAnalyticsData `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LookalikeReq struct {
|
||||||
|
SourcePostID string `json:"source_post_id" validate:"required"`
|
||||||
|
BrandID string `json:"brand_id,optional"`
|
||||||
|
Limit int `json:"limit,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LookalikeItemData struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
AuthorID string `json:"author_id"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorAvatar string `json:"author_avatar,omitempty"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
PlacementScore int `json:"placement_score"`
|
||||||
|
SemanticScore int `json:"semantic_score"`
|
||||||
|
EngagementPredicted int `json:"engagement_predicted"`
|
||||||
|
AudienceQuality int `json:"audience_quality"`
|
||||||
|
Similarity float64 `json:"similarity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LookalikeData struct {
|
||||||
|
List []LookalikeItemData `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
type ViralScanPostData struct {
|
type ViralScanPostData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SearchTag string `json:"search_tag"`
|
SearchTag string `json:"search_tag"`
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -543,6 +544,25 @@ func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSo
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func int64Field(payload map[string]any, key string) int64 {
|
||||||
|
if payload == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := payload[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(v)
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
case json.Number:
|
||||||
|
n, _ := v.Int64()
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stringField(payload map[string]any, key string) string {
|
func stringField(payload map[string]any, key string) string {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -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