fix swagger
This commit is contained in:
parent
5e9c8c915d
commit
1c85024f6b
|
|
@ -0,0 +1,13 @@
|
|||
.git
|
||||
.run
|
||||
.cursor
|
||||
**/node_modules
|
||||
backend/bin
|
||||
backend/web/dist
|
||||
backend/dev-console/dist
|
||||
frontend/dist
|
||||
**/*_test.go
|
||||
**/.DS_Store
|
||||
infra
|
||||
*.md
|
||||
!deploy/**
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# --- secrets / env ---
|
||||
deploy/.env
|
||||
deploy/.env.dev
|
||||
infra/.env
|
||||
*.env
|
||||
!.env.example
|
||||
!*.env.example
|
||||
|
||||
# --- runtime / logs ---
|
||||
.run/
|
||||
*.log
|
||||
|
||||
# --- build artifacts ---
|
||||
backend/bin/
|
||||
backend/web/dist/
|
||||
backend/dev-console/dist/
|
||||
frontend/dist/
|
||||
dist/
|
||||
|
||||
# --- dependencies ---
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# --- OS / editor ---
|
||||
.DS_Store
|
||||
.cursor/
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
SHELL := /bin/bash
|
||||
|
||||
BACKEND_DIR := backend
|
||||
WEB_DIR := backend/web
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## 顯示可用指令
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: gen-api
|
||||
gen-api: ## 重新產生後端 API handler / logic / types
|
||||
$(MAKE) -C $(BACKEND_DIR) gen-api
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## 格式化後端 Go 程式
|
||||
$(MAKE) -C $(BACKEND_DIR) fmt
|
||||
|
||||
.PHONY: test
|
||||
test: ## 執行後端測試
|
||||
$(MAKE) -C $(BACKEND_DIR) test
|
||||
|
||||
.PHONY: run
|
||||
run: ## 啟動後端 API
|
||||
$(MAKE) -C $(BACKEND_DIR) run
|
||||
|
||||
.PHONY: web-dev
|
||||
web-dev: ## 啟動正式前端 dev server
|
||||
cd $(WEB_DIR) && npm install && npm run dev
|
||||
|
||||
.PHONY: web-build
|
||||
web-build: ## 建置正式前端
|
||||
cd $(WEB_DIR) && npm install && npm run build
|
||||
|
||||
.PHONY: verify
|
||||
verify: test web-build ## 後端測試與前端建置
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: gen-api fmt test run
|
||||
.PHONY: gen-api fmt test run web-dev web-build
|
||||
|
||||
API_FILE := generate/api/gateway.api
|
||||
GOCTL_HOME := generate/goctl
|
||||
|
|
@ -14,4 +14,10 @@ test:
|
|||
go test ./...
|
||||
|
||||
run:
|
||||
go run gateway.go -f etc/gateway.yaml
|
||||
go run gateway.go -f etc/gateway.yaml
|
||||
|
||||
web-dev:
|
||||
cd web && npm install && npm run dev
|
||||
|
||||
web-build:
|
||||
cd web && npm install && npm run build
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
- `auth`:native email/password 登入、JWT access/refresh token、logout revoke。
|
||||
- `member`:目前登入會員的 profile 讀寫。
|
||||
- `permission`:permission catalog 與目前會員權限查詢。
|
||||
- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。
|
||||
|
||||
暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
|
||||
|
||||
|
|
@ -23,6 +24,13 @@ go mod download
|
|||
make run
|
||||
```
|
||||
|
||||
正式前端:
|
||||
|
||||
```bash
|
||||
make web-dev # Vite dev server :5173,proxy 到 :8890
|
||||
make web-build # TypeScript + Vite build
|
||||
```
|
||||
|
||||
預設服務:
|
||||
|
||||
```text
|
||||
|
|
@ -72,6 +80,8 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
|
|||
haixun-backend/
|
||||
gateway.go # go-zero server 入口
|
||||
Makefile # gen-api / fmt / test / run
|
||||
web/ # 正式巡樓 Console(Vite + React + TypeScript)
|
||||
dev-console/ # 本機診斷用最小開發面板,不是產品 UI
|
||||
etc/ # runtime config
|
||||
generate/
|
||||
api/ # goctl .api 定義
|
||||
|
|
@ -87,6 +97,11 @@ haixun-backend/
|
|||
auth/ # JWT token issue/refresh/logout + Redis revoke store
|
||||
member/ # Native member profile + password hash
|
||||
permission/ # Permission catalog + role permission mapping
|
||||
publish_queue/ # Threads 發文 queue、guarded transition、retry/missed
|
||||
publish_inventory/ # 自動補庫存 policy
|
||||
publish_guard/ # 發文頻率護欄與 pause/resume
|
||||
publish_queue_event/ # Queue 狀態轉移與告警事件
|
||||
style_preset/ # 人設語調 preset、CTA、禁用詞
|
||||
worker/ # 常駐背景 worker / scheduler / reaper
|
||||
library/ # 最小 runtime library
|
||||
response/ # 統一 JSON response envelope
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
# Threads 自動營運補功能計畫
|
||||
|
||||
## 目前狀態
|
||||
|
||||
P1/P2 已落地到後端 API、worker 與正式前端:
|
||||
|
||||
- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。
|
||||
- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。
|
||||
- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。
|
||||
- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。
|
||||
- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。
|
||||
- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。
|
||||
|
||||
## 目標
|
||||
|
||||
把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。
|
||||
|
||||
## P1:自動補庫存
|
||||
|
||||
### 功能
|
||||
|
||||
- 每個 Threads account 可設定每日/每週目標篇數。
|
||||
- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。
|
||||
- 指定內容來源:persona、copy mission、brand scan posts、manual seed。
|
||||
- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 新增 model:`internal/model/publish_inventory/`。
|
||||
- 新增設定 entity:
|
||||
- `account_id`
|
||||
- `target_daily_count`
|
||||
- `low_stock_threshold`
|
||||
- `lookahead_days`
|
||||
- `timezone`
|
||||
- `slots`
|
||||
- `source_refs`
|
||||
- `enabled`
|
||||
- 新增 job step:`refill_publish_inventory`。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy`
|
||||
- `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy`
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs`
|
||||
|
||||
## P1:智慧時段
|
||||
|
||||
### 功能
|
||||
|
||||
- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。
|
||||
- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。
|
||||
- 前端顯示「推薦時段」與「低成效時段」。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-slot-insights`
|
||||
- 回傳:
|
||||
- `slots[]`
|
||||
- `avg_likes_1h/24h/7d`
|
||||
- `avg_replies_1h/24h/7d`
|
||||
- `sample_size`
|
||||
- `recommendation`
|
||||
|
||||
## P1:草稿批量排程
|
||||
|
||||
### 功能
|
||||
|
||||
- 使用者在 copy drafts 中選多篇,一次排到未來 slots。
|
||||
- 支援根據智慧時段自動分配 `scheduled_at`。
|
||||
- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 補 API:
|
||||
- `POST /api/v1/personas/:personaId/copy-drafts/schedule`
|
||||
- `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule`
|
||||
- request:
|
||||
- `account_id`
|
||||
- `draft_ids`
|
||||
- `start_at`
|
||||
- `timezone`
|
||||
- `slots`
|
||||
- `mode`: `manual` / `recommended`
|
||||
|
||||
## P1:發文 retry / 告警 / 漏發偵測
|
||||
|
||||
### 功能
|
||||
|
||||
- failed queue item 可重試,保留失敗歷史。
|
||||
- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。
|
||||
- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。
|
||||
- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。
|
||||
- 補 API:
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry`
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events`
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-alerts`
|
||||
|
||||
## P1:頻率護欄
|
||||
|
||||
### 功能
|
||||
|
||||
- 每帳號每日上限。
|
||||
- 最小發文間隔。
|
||||
- 連續失敗自動 pause。
|
||||
- 手動解除 pause。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。
|
||||
- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-guard-policy`
|
||||
- `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy`
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-guard/resume`
|
||||
|
||||
## P2
|
||||
|
||||
- 內容日曆:補 queue date range query,前端做週/月視圖。
|
||||
- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。
|
||||
- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。
|
||||
- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。
|
||||
|
||||
## 驗證策略
|
||||
|
||||
- 所有列表維持 `page/pageSize` 與 `pagination/list`。
|
||||
- 所有時間欄位寫入 unix nanoseconds。
|
||||
- queue/job 狀態轉移都使用 guarded update。
|
||||
- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。
|
||||
|
|
@ -267,6 +267,26 @@ type (
|
|||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
ScheduleCopyDraftsReq {
|
||||
AccountID string `json:"account_id" validate:"required"`
|
||||
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
|
||||
StartAt int64 `json:"start_at,optional"`
|
||||
Timezone string `json:"timezone,optional"`
|
||||
Slots []PublishSlotData `json:"slots,optional"`
|
||||
Mode string `json:"mode,optional"`
|
||||
}
|
||||
|
||||
ScheduleCopyMissionDraftsHandlerReq {
|
||||
CopyMissionPath
|
||||
ScheduleCopyDraftsReq
|
||||
}
|
||||
|
||||
ScheduleCopyDraftsData {
|
||||
Scheduled int `json:"scheduled"`
|
||||
List []PublishQueueItemData `json:"list"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
CopyMissionInspirationSourceData {
|
||||
Query string `json:"query,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
|
|
@ -336,6 +356,9 @@ service gateway {
|
|||
@handler deleteCopyMissionMatrixDrafts
|
||||
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
|
||||
|
||||
@handler scheduleCopyMissionDrafts
|
||||
post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (ScheduleCopyDraftsData)
|
||||
|
||||
@handler getCopyMissionScanSchedule
|
||||
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ type (
|
|||
ReferenceNotes string `json:"reference_notes,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PublishQueueID string `json:"publish_queue_id,omitempty"`
|
||||
PublishedMediaID string `json:"published_media_id,omitempty"`
|
||||
PublishedPermalink string `json:"published_permalink,omitempty"`
|
||||
PublishedAt int64 `json:"published_at,omitempty"`
|
||||
|
|
@ -184,6 +185,46 @@ type (
|
|||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
SchedulePersonaDraftsHandlerReq {
|
||||
PersonaPath
|
||||
ScheduleCopyDraftsReq
|
||||
}
|
||||
|
||||
StylePresetData {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"persona_id"`
|
||||
Name string `json:"name"`
|
||||
Tone string `json:"tone,omitempty"`
|
||||
CTA []string `json:"cta,omitempty"`
|
||||
BannedWords []string `json:"banned_words,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
ListStylePresetsData {
|
||||
List []StylePresetData `json:"list"`
|
||||
}
|
||||
|
||||
UpsertStylePresetReq {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Tone string `json:"tone,optional"`
|
||||
CTA []string `json:"cta,optional"`
|
||||
BannedWords []string `json:"banned_words,optional"`
|
||||
Notes string `json:"notes,optional"`
|
||||
Apply bool `json:"apply,optional"`
|
||||
}
|
||||
|
||||
StylePresetPath {
|
||||
ID string `path:"id" validate:"required"`
|
||||
PresetID string `path:"presetId" validate:"required"`
|
||||
}
|
||||
|
||||
UpsertStylePresetHandlerReq {
|
||||
StylePresetPath
|
||||
UpsertStylePresetReq
|
||||
}
|
||||
|
||||
DeleteCopyDraftData {
|
||||
DraftID string `json:"draft_id"`
|
||||
Message string `json:"message"`
|
||||
|
|
@ -234,6 +275,18 @@ service gateway {
|
|||
@handler publishPersonaCopyDraft
|
||||
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
|
||||
|
||||
@handler schedulePersonaDrafts
|
||||
post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
|
||||
|
||||
@handler deletePersonaCopyDraft
|
||||
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
|
||||
|
||||
@handler listStylePresets
|
||||
get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
|
||||
|
||||
@handler upsertStylePreset
|
||||
put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData)
|
||||
|
||||
@handler deleteStylePreset
|
||||
delete /:id/style-presets/:presetId (StylePresetPath)
|
||||
}
|
||||
|
|
@ -20,6 +20,12 @@ type (
|
|||
ActiveAccountID string `json:"active_account_id"`
|
||||
}
|
||||
|
||||
DeleteThreadsAccountData {
|
||||
DeletedID string `json:"deleted_id"`
|
||||
ActiveAccountID string `json:"active_account_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
CreateThreadsAccountReq {
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Activate *bool `json:"activate,optional"`
|
||||
|
|
@ -310,6 +316,9 @@ type (
|
|||
PublishQueueItemData {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
||||
CopyDraftID string `json:"copy_draft_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ScheduledAt int64 `json:"scheduled_at"`
|
||||
Status string `json:"status"`
|
||||
|
|
@ -317,6 +326,11 @@ type (
|
|||
Permalink string `json:"permalink,optional"`
|
||||
PublishedAt int64 `json:"published_at,optional"`
|
||||
ErrorMessage string `json:"error_message,optional"`
|
||||
RetryCount int `json:"retry_count,omitempty"`
|
||||
LastAttemptAt int64 `json:"last_attempt_at,omitempty"`
|
||||
NextAttemptAt int64 `json:"next_attempt_at,omitempty"`
|
||||
MissedAt int64 `json:"missed_at,omitempty"`
|
||||
PausedReason string `json:"paused_reason,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
|
@ -373,6 +387,175 @@ type (
|
|||
Pagination PaginationData `json:"pagination"`
|
||||
List []ThreadsPublishHealthItem `json:"list"`
|
||||
}
|
||||
|
||||
PublishSlotData {
|
||||
Weekday int `json:"weekday"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
PublishSourceRefData {
|
||||
Type string `json:"type"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
||||
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||
ManualSeed string `json:"manual_seed,omitempty"`
|
||||
}
|
||||
|
||||
PublishInventoryPolicyData {
|
||||
AccountID string `json:"account_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetDailyCount int `json:"target_daily_count"`
|
||||
LowStockThreshold int `json:"low_stock_threshold"`
|
||||
LookaheadDays int `json:"lookahead_days"`
|
||||
Timezone string `json:"timezone"`
|
||||
Slots []PublishSlotData `json:"slots"`
|
||||
SourceRefs []PublishSourceRefData `json:"source_refs"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
UpsertPublishInventoryPolicyReq {
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetDailyCount int `json:"target_daily_count,optional"`
|
||||
LowStockThreshold int `json:"low_stock_threshold,optional"`
|
||||
LookaheadDays int `json:"lookahead_days,optional"`
|
||||
Timezone string `json:"timezone,optional"`
|
||||
Slots []PublishSlotData `json:"slots,optional"`
|
||||
SourceRefs []PublishSourceRefData `json:"source_refs,optional"`
|
||||
}
|
||||
|
||||
UpsertPublishInventoryPolicyHandlerReq {
|
||||
ThreadsAccountPath
|
||||
UpsertPublishInventoryPolicyReq
|
||||
}
|
||||
|
||||
StartPublishInventoryRefillJobReq {
|
||||
Count int `json:"count,optional"`
|
||||
}
|
||||
|
||||
StartPublishInventoryRefillJobHandlerReq {
|
||||
ThreadsAccountPath
|
||||
StartPublishInventoryRefillJobReq
|
||||
}
|
||||
|
||||
StartPublishInventoryRefillJobData {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
PublishSlotInsightData {
|
||||
Weekday int `json:"weekday"`
|
||||
Time string `json:"time"`
|
||||
AvgLikes1h int `json:"avg_likes_1h"`
|
||||
AvgReplies1h int `json:"avg_replies_1h"`
|
||||
AvgLikes24h int `json:"avg_likes_24h"`
|
||||
AvgReplies24h int `json:"avg_replies_24h"`
|
||||
AvgLikes7d int `json:"avg_likes_7d"`
|
||||
AvgReplies7d int `json:"avg_replies_7d"`
|
||||
SampleSize int `json:"sample_size"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
}
|
||||
|
||||
PublishSlotInsightsData {
|
||||
AccountID string `json:"account_id"`
|
||||
Timezone string `json:"timezone"`
|
||||
Slots []PublishSlotInsightData `json:"slots"`
|
||||
}
|
||||
|
||||
PublishGuardPolicyData {
|
||||
AccountID string `json:"account_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxDailyPosts int `json:"max_daily_posts"`
|
||||
MinIntervalMinutes int `json:"min_interval_minutes"`
|
||||
ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"`
|
||||
Paused bool `json:"paused"`
|
||||
PausedReason string `json:"paused_reason,omitempty"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
UpsertPublishGuardPolicyReq {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxDailyPosts int `json:"max_daily_posts,optional"`
|
||||
MinIntervalMinutes int `json:"min_interval_minutes,optional"`
|
||||
ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"`
|
||||
Paused *bool `json:"paused,optional"`
|
||||
PausedReason string `json:"paused_reason,optional"`
|
||||
}
|
||||
|
||||
UpsertPublishGuardPolicyHandlerReq {
|
||||
ThreadsAccountPath
|
||||
UpsertPublishGuardPolicyReq
|
||||
}
|
||||
|
||||
PublishQueueEventData {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queue_id"`
|
||||
EventType string `json:"event_type"`
|
||||
FromStatus string `json:"from_status,omitempty"`
|
||||
ToStatus string `json:"to_status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
PublishQueueEventsData {
|
||||
List []PublishQueueEventData `json:"list"`
|
||||
}
|
||||
|
||||
PublishAlertData {
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
QueueID string `json:"queue_id,omitempty"`
|
||||
AccountID string `json:"account_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
PublishAlertsData {
|
||||
List []PublishAlertData `json:"list"`
|
||||
}
|
||||
|
||||
PublishQueueRangeQuery {
|
||||
StartAt int64 `form:"startAt,optional"`
|
||||
EndAt int64 `form:"endAt,optional"`
|
||||
Status string `form:"status,optional"`
|
||||
}
|
||||
|
||||
PublishQueueRangeHandlerReq {
|
||||
ThreadsAccountPath
|
||||
PublishQueueRangeQuery
|
||||
}
|
||||
|
||||
PublishDashboardAccountSummary {
|
||||
AccountID string `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
PendingScheduled int64 `json:"pending_scheduled"`
|
||||
FailedCount int64 `json:"failed_count"`
|
||||
Published7d int64 `json:"published_7d"`
|
||||
TotalLikes7d int `json:"total_likes_7d"`
|
||||
TotalReplies7d int `json:"total_replies_7d"`
|
||||
Paused bool `json:"paused"`
|
||||
BestSlot string `json:"best_slot,omitempty"`
|
||||
LowSlot string `json:"low_slot,omitempty"`
|
||||
}
|
||||
|
||||
PublishDashboardSummaryData {
|
||||
List []PublishDashboardAccountSummary `json:"list"`
|
||||
TotalPending int64 `json:"total_pending"`
|
||||
TotalFailed int64 `json:"total_failed"`
|
||||
TotalPublished7d int64 `json:"total_published_7d"`
|
||||
TotalLikes7d int `json:"total_likes_7d"`
|
||||
TotalReplies7d int `json:"total_replies_7d"`
|
||||
}
|
||||
|
||||
ThreadsDiagnosticsData {
|
||||
AccountID string `json:"account_id"`
|
||||
CheckedAt int64 `json:"checked_at"`
|
||||
ApiConnected bool `json:"api_connected"`
|
||||
TokenExpiresAt int64 `json:"token_expires_at,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Items []ThreadsAPISmokeTestItem `json:"items"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -395,6 +578,9 @@ service gateway {
|
|||
@handler updateThreadsAccount
|
||||
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
|
||||
|
||||
@handler deleteThreadsAccount
|
||||
delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData)
|
||||
|
||||
@handler activateThreadsAccount
|
||||
post /:id/activate (ThreadsAccountPath)
|
||||
|
||||
|
|
@ -457,6 +643,45 @@ service gateway {
|
|||
|
||||
@handler listThreadsPublishHealth
|
||||
get /:id/publish-health (ListThreadsPublishHealthHandlerReq) returns (ThreadsPublishHealthData)
|
||||
|
||||
@handler getPublishInventoryPolicy
|
||||
get /:id/publish-inventory-policy (ThreadsAccountPath) returns (PublishInventoryPolicyData)
|
||||
|
||||
@handler upsertPublishInventoryPolicy
|
||||
put /:id/publish-inventory-policy (UpsertPublishInventoryPolicyHandlerReq) returns (PublishInventoryPolicyData)
|
||||
|
||||
@handler startPublishInventoryRefillJob
|
||||
post /:id/publish-inventory/refill-jobs (StartPublishInventoryRefillJobHandlerReq) returns (StartPublishInventoryRefillJobData)
|
||||
|
||||
@handler getPublishSlotInsights
|
||||
get /:id/publish-slot-insights (ThreadsAccountPath) returns (PublishSlotInsightsData)
|
||||
|
||||
@handler getPublishGuardPolicy
|
||||
get /:id/publish-guard-policy (ThreadsAccountPath) returns (PublishGuardPolicyData)
|
||||
|
||||
@handler upsertPublishGuardPolicy
|
||||
put /:id/publish-guard-policy (UpsertPublishGuardPolicyHandlerReq) returns (PublishGuardPolicyData)
|
||||
|
||||
@handler resumePublishGuard
|
||||
post /:id/publish-guard/resume (ThreadsAccountPath) returns (PublishGuardPolicyData)
|
||||
|
||||
@handler retryPublishQueueItem
|
||||
post /:id/publish-queue/:qid/retry (PublishQueueItemPath) returns (PublishQueueItemData)
|
||||
|
||||
@handler listPublishQueueEvents
|
||||
get /:id/publish-queue/:qid/events (PublishQueueItemPath) returns (PublishQueueEventsData)
|
||||
|
||||
@handler listPublishAlerts
|
||||
get /:id/publish-alerts (ThreadsAccountPath) returns (PublishAlertsData)
|
||||
|
||||
@handler listPublishQueueRange
|
||||
get /:id/publish-calendar (PublishQueueRangeHandlerReq) returns (PublishQueueListData)
|
||||
|
||||
@handler getThreadsDiagnostics
|
||||
get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData)
|
||||
|
||||
@handler getPublishDashboardSummary
|
||||
get /publish-dashboard-summary returns (PublishDashboardSummaryData)
|
||||
}
|
||||
|
||||
@server(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ go 1.23
|
|||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
|
|
@ -27,7 +28,6 @@ require (
|
|||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/grafana/pyroscope-go v1.2.7 // indirect
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
|
|||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package copy_mission
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/copy_mission"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ScheduleCopyMissionDraftsHandlerReq
|
||||
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 := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx)
|
||||
data, err := l.ScheduleCopyMissionDrafts(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/persona"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.StylePresetPath
|
||||
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 := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx)
|
||||
err := l.DeleteStylePreset(&req)
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
package crm
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"haixun-backend/internal/logic/crm"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/persona"
|
||||
"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 {
|
||||
func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.CreateCrmContactReq
|
||||
var req types.PersonaPath
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
|
|
@ -22,8 +24,9 @@ func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
l := crm.NewCreateCrmContactLogic(r.Context(), svcCtx)
|
||||
data, err := l.CreateCrmContact(&req)
|
||||
|
||||
l := persona.NewListStylePresetsLogic(r.Context(), svcCtx)
|
||||
data, err := l.ListStylePresets(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/persona"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.SchedulePersonaDraftsHandlerReq
|
||||
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 := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx)
|
||||
data, err := l.SchedulePersonaDrafts(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
package scan_post
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"haixun-backend/internal/logic/scan_post"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/persona"
|
||||
"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 {
|
||||
func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.LookalikeReq
|
||||
var req types.UpsertStylePresetHandlerReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
|
|
@ -22,8 +24,9 @@ func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
l := scan_post.NewLookalikeLogic(r.Context(), svcCtx)
|
||||
data, err := l.Lookalike(&req)
|
||||
|
||||
l := persona.NewUpsertStylePresetLogic(r.Context(), svcCtx)
|
||||
data, err := l.UpsertStylePreset(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +276,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:personaId/copy-missions/:id/copy-drafts",
|
||||
Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/copy-drafts/schedule",
|
||||
Handler: copy_mission.ScheduleCopyMissionDraftsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:personaId/copy-missions/:id/knowledge-graph",
|
||||
|
|
@ -597,11 +602,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/copy-drafts/generate",
|
||||
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/copy-drafts/schedule",
|
||||
Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/style-analysis",
|
||||
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/style-presets",
|
||||
Handler: persona.ListStylePresetsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/:id/style-presets/:presetId",
|
||||
Handler: persona.UpsertStylePresetHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id/style-presets/:presetId",
|
||||
Handler: persona.DeleteStylePresetHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/viral-scan-jobs",
|
||||
|
|
@ -774,6 +799,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id",
|
||||
Handler: threads_account.UpdateThreadsAccountHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id",
|
||||
Handler: threads_account.DeleteThreadsAccountHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/activate",
|
||||
|
|
@ -819,16 +849,61 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/connection",
|
||||
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/diagnostics",
|
||||
Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/post-performance",
|
||||
Handler: threads_account.ListThreadsPostPerformanceHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-alerts",
|
||||
Handler: threads_account.ListPublishAlertsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-calendar",
|
||||
Handler: threads_account.ListPublishQueueRangeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-guard-policy",
|
||||
Handler: threads_account.GetPublishGuardPolicyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/:id/publish-guard-policy",
|
||||
Handler: threads_account.UpsertPublishGuardPolicyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/publish-guard/resume",
|
||||
Handler: threads_account.ResumePublishGuardHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-health",
|
||||
Handler: threads_account.ListThreadsPublishHealthHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-inventory-policy",
|
||||
Handler: threads_account.GetPublishInventoryPolicyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/:id/publish-inventory-policy",
|
||||
Handler: threads_account.UpsertPublishInventoryPolicyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/publish-inventory/refill-jobs",
|
||||
Handler: threads_account.StartPublishInventoryRefillJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/publish-queue",
|
||||
|
|
@ -859,6 +934,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/publish-queue/:qid/cancel",
|
||||
Handler: threads_account.CancelPublishQueueItemHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-queue/:qid/events",
|
||||
Handler: threads_account.ListPublishQueueEventsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/publish-queue/:qid/retry",
|
||||
Handler: threads_account.RetryPublishQueueItemHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/publish-slot-insights",
|
||||
Handler: threads_account.GetPublishSlotInsightsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/session/import",
|
||||
|
|
@ -879,6 +969,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/oauth/start",
|
||||
Handler: threads_account.StartThreadsOauthHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/publish-dashboard-summary",
|
||||
Handler: threads_account.GetPublishDashboardSummaryHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/threads-accounts"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func DeleteThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewDeleteThreadsAccountLogic(r.Context(), svcCtx)
|
||||
data, err := l.DeleteThreadsAccount(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
)
|
||||
|
||||
func GetPublishDashboardSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
l := threads_account.NewGetPublishDashboardSummaryLogic(r.Context(), svcCtx)
|
||||
data, err := l.GetPublishDashboardSummary()
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func GetPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewGetPublishGuardPolicyLogic(r.Context(), svcCtx)
|
||||
data, err := l.GetPublishGuardPolicy(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func GetPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewGetPublishInventoryPolicyLogic(r.Context(), svcCtx)
|
||||
data, err := l.GetPublishInventoryPolicy(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func GetPublishSlotInsightsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewGetPublishSlotInsightsLogic(r.Context(), svcCtx)
|
||||
data, err := l.GetPublishSlotInsights(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func GetThreadsDiagnosticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewGetThreadsDiagnosticsLogic(r.Context(), svcCtx)
|
||||
data, err := l.GetThreadsDiagnostics(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func ListPublishAlertsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewListPublishAlertsLogic(r.Context(), svcCtx)
|
||||
data, err := l.ListPublishAlerts(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func ListPublishQueueEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.PublishQueueItemPath
|
||||
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 := threads_account.NewListPublishQueueEventsLogic(r.Context(), svcCtx)
|
||||
data, err := l.ListPublishQueueEvents(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func ListPublishQueueRangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.PublishQueueRangeHandlerReq
|
||||
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 := threads_account.NewListPublishQueueRangeLogic(r.Context(), svcCtx)
|
||||
data, err := l.ListPublishQueueRange(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func ResumePublishGuardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ThreadsAccountPath
|
||||
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 := threads_account.NewResumePublishGuardLogic(r.Context(), svcCtx)
|
||||
data, err := l.ResumePublishGuard(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func RetryPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.PublishQueueItemPath
|
||||
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 := threads_account.NewRetryPublishQueueItemLogic(r.Context(), svcCtx)
|
||||
data, err := l.RetryPublishQueueItem(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.StartPublishInventoryRefillJobHandlerReq
|
||||
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 := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx)
|
||||
data, err := l.StartPublishInventoryRefillJob(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func UpsertPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.UpsertPublishGuardPolicyHandlerReq
|
||||
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 := threads_account.NewUpsertPublishGuardPolicyLogic(r.Context(), svcCtx)
|
||||
data, err := l.UpsertPublishGuardPolicy(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"haixun-backend/internal/logic/threads_account"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
func UpsertPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.UpsertPublishInventoryPolicyHandlerReq
|
||||
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 := threads_account.NewUpsertPublishInventoryPolicyLogic(r.Context(), svcCtx)
|
||||
data, err := l.UpsertPublishInventoryPolicy(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,8 +25,9 @@ func TestPathAllowed(t *testing.T) {
|
|||
|
||||
func TestRequestAllowed(t *testing.T) {
|
||||
perms := map[string]string{
|
||||
"/api/v1/members/me": "GET|PATCH",
|
||||
"/api/v1/jobs/*": "GET|POST",
|
||||
"/api/v1/members/me": "GET|PATCH",
|
||||
"/api/v1/jobs/*": "GET|POST",
|
||||
"/api/v1/threads-accounts/*": "GET|POST|PUT|PATCH|DELETE",
|
||||
}
|
||||
if !RequestAllowed(perms, "GET", "/api/v1/members/me") {
|
||||
t.Fatal("expected member me")
|
||||
|
|
@ -37,6 +38,12 @@ func TestRequestAllowed(t *testing.T) {
|
|||
if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") {
|
||||
t.Fatal("expected job cancel")
|
||||
}
|
||||
if !RequestAllowed(perms, "DELETE", "/api/v1/threads-accounts/acc-1") {
|
||||
t.Fatal("expected threads account delete")
|
||||
}
|
||||
if !RequestAllowed(perms, "POST", "/api/v1/threads-accounts/acc-1/activate") {
|
||||
t.Fatal("expected threads account activate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathAllowedListRoot(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package publishschedule
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Slot struct {
|
||||
Weekday int
|
||||
Time string
|
||||
}
|
||||
|
||||
func BuildSchedule(startAt int64, timezone string, slots []Slot, count int) []int64 {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
loc := time.UTC
|
||||
if strings.TrimSpace(timezone) != "" {
|
||||
if loaded, err := time.LoadLocation(strings.TrimSpace(timezone)); err == nil {
|
||||
loc = loaded
|
||||
}
|
||||
}
|
||||
start := time.Now().In(loc)
|
||||
if startAt > 0 {
|
||||
start = time.Unix(0, startAt).In(loc)
|
||||
}
|
||||
normalized := normalizeSlots(slots)
|
||||
if len(normalized) == 0 {
|
||||
out := make([]int64, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
out = append(out, start.Add(time.Duration(i)*2*time.Hour).UTC().UnixNano())
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make([]int64, 0, count)
|
||||
day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
|
||||
for len(out) < count {
|
||||
for _, slot := range normalized {
|
||||
if int(day.Weekday()) != slot.Weekday {
|
||||
continue
|
||||
}
|
||||
hour, minute, ok := parseHHMM(slot.Time)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
|
||||
if candidate.Before(start) {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate.UTC().UnixNano())
|
||||
if len(out) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlots(slots []Slot) []Slot {
|
||||
out := make([]Slot, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
if strings.TrimSpace(slot.Time) == "" {
|
||||
continue
|
||||
}
|
||||
weekday := slot.Weekday
|
||||
if weekday < 0 || weekday > 6 {
|
||||
weekday = ((weekday % 7) + 7) % 7
|
||||
}
|
||||
out = append(out, Slot{Weekday: weekday, Time: strings.TrimSpace(slot.Time)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Weekday == out[j].Weekday {
|
||||
return out[i].Time < out[j].Time
|
||||
}
|
||||
return out[i].Weekday < out[j].Weekday
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHHMM(value string) (int, int, bool) {
|
||||
parsed, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return parsed.Hour(), parsed.Minute(), true
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package publishschedule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildScheduleUsesTimezoneSlotsAndReturnsUTCNano(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Asia/Taipei")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, 6, 29, 9, 0, 0, 0, loc) // Monday
|
||||
got := BuildSchedule(start.UTC().UnixNano(), "Asia/Taipei", []Slot{
|
||||
{Weekday: 1, Time: "09:30"},
|
||||
{Weekday: 3, Time: "12:30"},
|
||||
}, 2)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 timestamps, got %d", len(got))
|
||||
}
|
||||
first := time.Unix(0, got[0]).In(loc)
|
||||
if first.Weekday() != time.Monday || first.Hour() != 9 || first.Minute() != 30 {
|
||||
t.Fatalf("unexpected first slot: %s", first)
|
||||
}
|
||||
second := time.Unix(0, got[1]).In(loc)
|
||||
if second.Weekday() != time.Wednesday || second.Hour() != 12 || second.Minute() != 30 {
|
||||
t.Fatalf("unexpected second slot: %s", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScheduleFallsBackToIntervalsWhenNoSlots(t *testing.T) {
|
||||
start := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
|
||||
got := BuildSchedule(start.UnixNano(), "UTC", nil, 2)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 timestamps, got %d", len(got))
|
||||
}
|
||||
if got[1]-got[0] != int64(2*time.Hour) {
|
||||
t.Fatalf("expected 2h interval, got %s", time.Duration(got[1]-got[0]))
|
||||
}
|
||||
}
|
||||
|
|
@ -291,6 +291,7 @@ func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData
|
|||
ReferenceNotes: item.ReferenceNotes,
|
||||
Sources: item.Sources,
|
||||
Status: item.Status,
|
||||
PublishQueueID: item.PublishQueueID,
|
||||
PublishedMediaID: item.PublishedMediaID,
|
||||
PublishedPermalink: item.PublishedPermalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package copy_mission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/library/publishschedule"
|
||||
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
|
||||
pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ScheduleCopyMissionDraftsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic {
|
||||
return &ScheduleCopyMissionDraftsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, req.PersonaID, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs))
|
||||
list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs))
|
||||
for idx, draftID := range req.DraftIDs {
|
||||
draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.PersonaID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if draft.CopyMissionID != req.ID {
|
||||
return nil, app.For(code.Persona).InputInvalidFormat("草稿不屬於此文案任務")
|
||||
}
|
||||
scheduledAt := int64(0)
|
||||
if idx < len(times) {
|
||||
scheduledAt = times[idx]
|
||||
}
|
||||
item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
|
||||
PersonaID: req.PersonaID, CopyMissionID: req.ID, CopyDraftID: draft.ID,
|
||||
Text: draft.Text, ScheduledAt: scheduledAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, PersonaID: req.PersonaID, DraftID: draft.ID, QueueID: item.ID,
|
||||
})
|
||||
list = append(list, publishQueueData(item))
|
||||
}
|
||||
return &types.ScheduleCopyDraftsData{
|
||||
Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
|
||||
converted := make([]publishschedule.Slot, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time})
|
||||
}
|
||||
return publishschedule.BuildSchedule(startAt, timezone, converted, count)
|
||||
}
|
||||
|
||||
func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData {
|
||||
if item == nil {
|
||||
return types.PublishQueueItemData{}
|
||||
}
|
||||
return types.PublishQueueItemData{
|
||||
ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID,
|
||||
Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status,
|
||||
MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt,
|
||||
NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason,
|
||||
CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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,35 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type DeleteStylePresetLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDeleteStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteStylePresetLogic {
|
||||
return &DeleteStylePresetLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *DeleteStylePresetLogic) DeleteStylePreset(req *types.StylePresetPath) error {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.svcCtx.StylePreset.Delete(l.ctx, tenantID, uid, req.ID, req.PresetID)
|
||||
}
|
||||
|
|
@ -154,20 +154,24 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft(
|
|||
|
||||
return &types.GeneratePersonaCopyDraftData{
|
||||
Draft: types.CopyDraftData{
|
||||
ID: saved.ID,
|
||||
PersonaID: saved.PersonaID,
|
||||
CopyMissionID: saved.CopyMissionID,
|
||||
ScanPostID: saved.ScanPostID,
|
||||
DraftType: saved.DraftType,
|
||||
SortOrder: saved.SortOrder,
|
||||
Text: saved.Text,
|
||||
Angle: saved.Angle,
|
||||
Hook: saved.Hook,
|
||||
Rationale: saved.Rationale,
|
||||
ReferenceNotes: saved.ReferenceNotes,
|
||||
Sources: saved.Sources,
|
||||
Status: saved.Status,
|
||||
CreateAt: saved.CreateAt,
|
||||
ID: saved.ID,
|
||||
PersonaID: saved.PersonaID,
|
||||
CopyMissionID: saved.CopyMissionID,
|
||||
ScanPostID: saved.ScanPostID,
|
||||
DraftType: saved.DraftType,
|
||||
SortOrder: saved.SortOrder,
|
||||
Text: saved.Text,
|
||||
Angle: saved.Angle,
|
||||
Hook: saved.Hook,
|
||||
Rationale: saved.Rationale,
|
||||
ReferenceNotes: saved.ReferenceNotes,
|
||||
Sources: saved.Sources,
|
||||
Status: saved.Status,
|
||||
PublishQueueID: saved.PublishQueueID,
|
||||
PublishedMediaID: saved.PublishedMediaID,
|
||||
PublishedPermalink: saved.PublishedPermalink,
|
||||
PublishedAt: saved.PublishedAt,
|
||||
CreateAt: saved.CreateAt,
|
||||
},
|
||||
Message: "已產出仿寫草稿",
|
||||
}, nil
|
||||
|
|
|
|||
|
|
@ -34,20 +34,24 @@ func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPat
|
|||
list := make([]types.CopyDraftData, 0, len(drafts))
|
||||
for _, item := range drafts {
|
||||
list = append(list, types.CopyDraftData{
|
||||
ID: item.ID,
|
||||
PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID,
|
||||
ScanPostID: item.ScanPostID,
|
||||
DraftType: item.DraftType,
|
||||
SortOrder: item.SortOrder,
|
||||
Text: item.Text,
|
||||
Angle: item.Angle,
|
||||
Hook: item.Hook,
|
||||
Rationale: item.Rationale,
|
||||
ReferenceNotes: item.ReferenceNotes,
|
||||
Sources: item.Sources,
|
||||
Status: item.Status,
|
||||
CreateAt: item.CreateAt,
|
||||
ID: item.ID,
|
||||
PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID,
|
||||
ScanPostID: item.ScanPostID,
|
||||
DraftType: item.DraftType,
|
||||
SortOrder: item.SortOrder,
|
||||
Text: item.Text,
|
||||
Angle: item.Angle,
|
||||
Hook: item.Hook,
|
||||
Rationale: item.Rationale,
|
||||
ReferenceNotes: item.ReferenceNotes,
|
||||
Sources: item.Sources,
|
||||
Status: item.Status,
|
||||
PublishQueueID: item.PublishQueueID,
|
||||
PublishedMediaID: item.PublishedMediaID,
|
||||
PublishedPermalink: item.PublishedPermalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
CreateAt: item.CreateAt,
|
||||
})
|
||||
}
|
||||
return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ListStylePresetsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListStylePresetsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListStylePresetsLogic {
|
||||
return &ListStylePresetsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListStylePresetsLogic) ListStylePresets(req *types.PersonaPath) (resp *types.ListStylePresetsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := l.svcCtx.StylePreset.List(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]types.StylePresetData, 0, len(items))
|
||||
for _, item := range items {
|
||||
list = append(list, toStylePresetData(item))
|
||||
}
|
||||
return &types.ListStylePresetsData{List: list}, nil
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package persona
|
|||
|
||||
import (
|
||||
domusecase "haixun-backend/internal/model/persona/domain/usecase"
|
||||
stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
|
|
@ -55,3 +56,11 @@ func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch {
|
|||
StyleBenchmark: req.StyleBenchmark,
|
||||
}
|
||||
}
|
||||
|
||||
func toStylePresetData(item stylepresetdomain.PresetSummary) types.StylePresetData {
|
||||
return types.StylePresetData{
|
||||
ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone,
|
||||
CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...),
|
||||
Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"haixun-backend/internal/library/publishschedule"
|
||||
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
|
||||
pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type SchedulePersonaDraftsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewSchedulePersonaDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaDraftsLogic {
|
||||
return &SchedulePersonaDraftsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePersonaDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs))
|
||||
list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs))
|
||||
for idx, draftID := range req.DraftIDs {
|
||||
draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheduledAt := int64(0)
|
||||
if idx < len(times) {
|
||||
scheduledAt = times[idx]
|
||||
}
|
||||
item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
|
||||
PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID,
|
||||
Text: draft.Text, ScheduledAt: scheduledAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID,
|
||||
})
|
||||
list = append(list, publishQueueData(item))
|
||||
}
|
||||
return &types.ScheduleCopyDraftsData{
|
||||
Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
|
||||
converted := make([]publishschedule.Slot, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time})
|
||||
}
|
||||
return publishschedule.BuildSchedule(startAt, timezone, converted, count)
|
||||
}
|
||||
|
||||
func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData {
|
||||
if item == nil {
|
||||
return types.PublishQueueItemData{}
|
||||
}
|
||||
return types.PublishQueueItemData{
|
||||
ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID,
|
||||
Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status,
|
||||
MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt,
|
||||
NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason,
|
||||
CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft(
|
|||
ReferenceNotes: updated.ReferenceNotes,
|
||||
Sources: updated.Sources,
|
||||
Status: updated.Status,
|
||||
PublishQueueID: updated.PublishQueueID,
|
||||
PublishedMediaID: updated.PublishedMediaID,
|
||||
PublishedPermalink: updated.PublishedPermalink,
|
||||
PublishedAt: updated.PublishedAt,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package persona
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type UpsertStylePresetLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpsertStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertStylePresetLogic {
|
||||
return &UpsertStylePresetLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *UpsertStylePresetLogic) UpsertStylePreset(req *types.UpsertStylePresetHandlerReq) (resp *types.StylePresetData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.StylePreset.Upsert(l.ctx, stylepresetdomain.UpsertRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, PresetID: req.PresetID,
|
||||
Name: req.Name, Tone: req.Tone, CTA: req.CTA, BannedWords: req.BannedWords, Notes: req.Notes, Apply: req.Apply,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := toStylePresetData(*item)
|
||||
return &data, nil
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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,36 @@
|
|||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
||||
type DeleteThreadsAccountLogic struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDeleteThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteThreadsAccountLogic {
|
||||
return &DeleteThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *DeleteThreadsAccountLogic) DeleteThreadsAccount(req *types.ThreadsAccountPath) (*types.DeleteThreadsAccountData, error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := l.svcCtx.ThreadsAccount.Delete(l.ctx, tenantID, uid, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.DeleteThreadsAccountData{
|
||||
DeletedID: req.ID,
|
||||
ActiveAccountID: accounts.ActiveAccountID,
|
||||
Message: "帳號已刪除",
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type GetPublishDashboardSummaryLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetPublishDashboardSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishDashboardSummaryLogic {
|
||||
return &GetPublishDashboardSummaryLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetPublishDashboardSummaryLogic) GetPublishDashboardSummary() (resp *types.PublishDashboardSummaryData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp = &types.PublishDashboardSummaryData{List: []types.PublishDashboardAccountSummary{}}
|
||||
for _, account := range accounts.List {
|
||||
health, hErr := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, account.ID, 1, 10)
|
||||
if hErr != nil {
|
||||
continue
|
||||
}
|
||||
guard, _ := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, account.ID)
|
||||
slots, _ := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, account.ID, "Asia/Taipei")
|
||||
bestSlot, lowSlot := "", ""
|
||||
for _, slot := range slots {
|
||||
if bestSlot == "" && slot.Recommendation == "recommended" {
|
||||
bestSlot = slot.Time
|
||||
}
|
||||
if lowSlot == "" && slot.Recommendation == "low" {
|
||||
lowSlot = slot.Time
|
||||
}
|
||||
}
|
||||
row := types.PublishDashboardAccountSummary{
|
||||
AccountID: account.ID, AccountName: firstNonEmpty(account.DisplayName, account.Username, account.ID),
|
||||
PendingScheduled: health.Summary.PendingScheduled, FailedCount: health.Summary.FailedCount,
|
||||
Published7d: health.Summary.Published7d, TotalLikes7d: health.Summary.TotalLikes7d,
|
||||
TotalReplies7d: health.Summary.TotalReplies7d, BestSlot: bestSlot, LowSlot: lowSlot,
|
||||
}
|
||||
if guard != nil {
|
||||
row.Paused = guard.Paused
|
||||
}
|
||||
resp.List = append(resp.List, row)
|
||||
resp.TotalPending += row.PendingScheduled
|
||||
resp.TotalFailed += row.FailedCount
|
||||
resp.TotalPublished7d += row.Published7d
|
||||
resp.TotalLikes7d += row.TotalLikes7d
|
||||
resp.TotalReplies7d += row.TotalReplies7d
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type GetPublishGuardPolicyLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishGuardPolicyLogic {
|
||||
return &GetPublishGuardPolicyLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetPublishGuardPolicyLogic) GetPublishGuardPolicy(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toGuardPolicyData(item), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type GetPublishInventoryPolicyLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishInventoryPolicyLogic {
|
||||
return &GetPublishInventoryPolicyLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetPublishInventoryPolicyLogic) GetPublishInventoryPolicy(req *types.ThreadsAccountPath) (resp *types.PublishInventoryPolicyData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toInventoryPolicyData(item), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type GetPublishSlotInsightsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetPublishSlotInsightsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishSlotInsightsLogic {
|
||||
return &GetPublishSlotInsightsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetPublishSlotInsightsLogic) GetPublishSlotInsights(req *types.ThreadsAccountPath) (resp *types.PublishSlotInsightsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy, _ := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
|
||||
timezone := "Asia/Taipei"
|
||||
if policy != nil && policy.Timezone != "" {
|
||||
timezone = policy.Timezone
|
||||
}
|
||||
items, err := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, req.ID, timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]types.PublishSlotInsightData, 0, len(items))
|
||||
for _, item := range items {
|
||||
list = append(list, types.PublishSlotInsightData{
|
||||
Weekday: item.Weekday, Time: item.Time, AvgLikes1h: item.AvgLikes1h, AvgReplies1h: item.AvgReplies1h,
|
||||
AvgLikes24h: item.AvgLikes24h, AvgReplies24h: item.AvgReplies24h,
|
||||
AvgLikes7d: item.AvgLikes7d, AvgReplies7d: item.AvgReplies7d,
|
||||
SampleSize: item.SampleSize, Recommendation: item.Recommendation,
|
||||
})
|
||||
}
|
||||
return &types.PublishSlotInsightsData{AccountID: req.ID, Timezone: timezone, Slots: list}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/library/clock"
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type GetThreadsDiagnosticsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetThreadsDiagnosticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsDiagnosticsLogic {
|
||||
return &GetThreadsDiagnosticsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetThreadsDiagnosticsLogic) GetThreadsDiagnostics(req *types.ThreadsAccountPath) (resp *types.ThreadsDiagnosticsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
smoke, smokeErr := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID)
|
||||
items := make([]types.ThreadsAPISmokeTestItem, 0, len(smoke))
|
||||
for _, item := range smoke {
|
||||
items = append(items, types.ThreadsAPISmokeTestItem{
|
||||
Scope: item.Scope, Name: item.Name, Status: item.Status, Message: item.Message, Count: item.Count,
|
||||
})
|
||||
}
|
||||
message := "診斷完成"
|
||||
if smokeErr != nil {
|
||||
message = smokeErr.Error()
|
||||
}
|
||||
return &types.ThreadsDiagnosticsData{
|
||||
AccountID: req.ID, CheckedAt: clock.NowUnixNano(), ApiConnected: account.ApiConnected,
|
||||
TokenExpiresAt: account.APITokenExpiresAt, Scopes: libthreads.DefaultOAuthScopes, Items: items, Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ListPublishAlertsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListPublishAlertsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishAlertsLogic {
|
||||
return &ListPublishAlertsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListPublishAlertsLogic) ListPublishAlerts(req *types.ThreadsAccountPath) (resp *types.PublishAlertsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := l.svcCtx.PublishQueue.ListAlerts(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]types.PublishAlertData, 0, len(items))
|
||||
for _, item := range items {
|
||||
list = append(list, types.PublishAlertData{
|
||||
Type: item.Type, Severity: item.Severity, Message: item.Message,
|
||||
QueueID: item.QueueID, AccountID: item.AccountID, CreateAt: item.CreateAt,
|
||||
})
|
||||
}
|
||||
return &types.PublishAlertsData{List: list}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ListPublishQueueEventsLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListPublishQueueEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueEventsLogic {
|
||||
return &ListPublishQueueEventsLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListPublishQueueEventsLogic) ListPublishQueueEvents(req *types.PublishQueueItemPath) (resp *types.PublishQueueEventsData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := l.svcCtx.PublishQueueEvent.ListByQueue(l.ctx, tenantID, uid, req.ID, req.QID, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.PublishQueueEventsData{List: toQueueEventData(items)}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ListPublishQueueRangeLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListPublishQueueRangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueRangeLogic {
|
||||
return &ListPublishQueueRangeLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListPublishQueueRangeLogic) ListPublishQueueRange(req *types.PublishQueueRangeHandlerReq) (resp *types.PublishQueueListData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := l.svcCtx.PublishQueue.ListRange(l.ctx, pqdomain.RangeRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, AccountID: req.ID,
|
||||
Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, Page: 1, PageSize: 200,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toPublishQueueListData(result), nil
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
package threads_account
|
||||
|
||||
import (
|
||||
guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
|
||||
inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
|
||||
pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
|
||||
eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
|
||||
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||
"haixun-backend/internal/types"
|
||||
)
|
||||
|
|
@ -97,20 +100,80 @@ func toPublishQueueItemData(item *pqdomain.QueueItemSummary) *types.PublishQueue
|
|||
return nil
|
||||
}
|
||||
return &types.PublishQueueItemData{
|
||||
ID: item.ID,
|
||||
AccountID: item.AccountID,
|
||||
Text: item.Text,
|
||||
ScheduledAt: item.ScheduledAt,
|
||||
Status: item.Status,
|
||||
MediaID: item.MediaID,
|
||||
Permalink: item.Permalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
CreateAt: item.CreateAt,
|
||||
UpdateAt: item.UpdateAt,
|
||||
ID: item.ID,
|
||||
AccountID: item.AccountID,
|
||||
PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID,
|
||||
CopyDraftID: item.CopyDraftID,
|
||||
Text: item.Text,
|
||||
ScheduledAt: item.ScheduledAt,
|
||||
Status: item.Status,
|
||||
MediaID: item.MediaID,
|
||||
Permalink: item.Permalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
RetryCount: item.RetryCount,
|
||||
LastAttemptAt: item.LastAttemptAt,
|
||||
NextAttemptAt: item.NextAttemptAt,
|
||||
MissedAt: item.MissedAt,
|
||||
PausedReason: item.PausedReason,
|
||||
CreateAt: item.CreateAt,
|
||||
UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toInventoryPolicyData(item *inventorydomain.PolicySummary) *types.PublishInventoryPolicyData {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
slots := make([]types.PublishSlotData, 0, len(item.Slots))
|
||||
for _, slot := range item.Slots {
|
||||
slots = append(slots, types.PublishSlotData{Weekday: slot.Weekday, Time: slot.Time})
|
||||
}
|
||||
refs := make([]types.PublishSourceRefData, 0, len(item.SourceRefs))
|
||||
for _, ref := range item.SourceRefs {
|
||||
refs = append(refs, types.PublishSourceRefData{
|
||||
Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
|
||||
})
|
||||
}
|
||||
return &types.PublishInventoryPolicyData{
|
||||
AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
|
||||
LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
|
||||
Timezone: item.Timezone, Slots: slots, SourceRefs: refs, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toGuardPolicyData(item *guarddomain.PolicySummary) *types.PublishGuardPolicyData {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.PublishGuardPolicyData{
|
||||
AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
|
||||
MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
|
||||
Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toQueueEventData(items []eventdomain.EventSummary) []types.PublishQueueEventData {
|
||||
out := make([]types.PublishQueueEventData, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, types.PublishQueueEventData{
|
||||
ID: item.ID, QueueID: item.QueueID, EventType: item.EventType,
|
||||
FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toPublishQueueListData(result *pqdomain.ListResult) *types.PublishQueueListData {
|
||||
if result == nil {
|
||||
return &types.PublishQueueListData{List: []types.PublishQueueItemData{}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ResumePublishGuardLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewResumePublishGuardLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResumePublishGuardLogic {
|
||||
return &ResumePublishGuardLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ResumePublishGuardLogic) ResumePublishGuard(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.PublishGuard.Resume(l.ctx, tenantID, uid, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toGuardPolicyData(item), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type RetryPublishQueueItemLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewRetryPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryPublishQueueItemLogic {
|
||||
return &RetryPublishQueueItemLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RetryPublishQueueItemLogic) RetryPublishQueueItem(req *types.PublishQueueItemPath) (resp *types.PublishQueueItemData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.PublishQueue.Retry(l.ctx, tenantID, uid, req.ID, req.QID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toPublishQueueItemData(item), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
jobdom "haixun-backend/internal/model/job/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type StartPublishInventoryRefillJobLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic {
|
||||
return &StartPublishInventoryRefillJobLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := req.Count
|
||||
if count <= 0 {
|
||||
count = 3
|
||||
}
|
||||
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
|
||||
TemplateType: "refill-publish-inventory",
|
||||
Scope: "threads_account",
|
||||
ScopeID: req.ID,
|
||||
TenantID: tenantID,
|
||||
OwnerUID: uid,
|
||||
Payload: map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
"owner_uid": uid,
|
||||
"account_id": req.ID,
|
||||
"count": count,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.StartPublishInventoryRefillJobData{
|
||||
JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立",
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type UpsertPublishGuardPolicyLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpsertPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishGuardPolicyLogic {
|
||||
return &UpsertPublishGuardPolicyLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *UpsertPublishGuardPolicyLogic) UpsertPublishGuardPolicy(req *types.UpsertPublishGuardPolicyHandlerReq) (resp *types.PublishGuardPolicyData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := l.svcCtx.PublishGuard.Upsert(l.ctx, guarddomain.UpsertRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
|
||||
MaxDailyPosts: req.MaxDailyPosts, MinIntervalMinutes: req.MinIntervalMinutes,
|
||||
ConsecutiveFailurePauseLimit: req.ConsecutiveFailurePauseLimit,
|
||||
Paused: req.Paused, PausedReason: req.PausedReason,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toGuardPolicyData(item), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package threads_account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type UpsertPublishInventoryPolicyLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpsertPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishInventoryPolicyLogic {
|
||||
return &UpsertPublishInventoryPolicyLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *UpsertPublishInventoryPolicyLogic) UpsertPublishInventoryPolicy(req *types.UpsertPublishInventoryPolicyHandlerReq) (resp *types.PublishInventoryPolicyData, err error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slots := make([]inventorydomain.Slot, 0, len(req.Slots))
|
||||
for _, slot := range req.Slots {
|
||||
slots = append(slots, inventorydomain.Slot{Weekday: slot.Weekday, Time: slot.Time})
|
||||
}
|
||||
refs := make([]inventorydomain.SourceRef, 0, len(req.SourceRefs))
|
||||
for _, ref := range req.SourceRefs {
|
||||
refs = append(refs, inventorydomain.SourceRef{
|
||||
Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID,
|
||||
ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
|
||||
})
|
||||
}
|
||||
item, err := l.svcCtx.PublishInventory.Upsert(l.ctx, inventorydomain.UpsertRequest{
|
||||
TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
|
||||
TargetDailyCount: req.TargetDailyCount, LowStockThreshold: req.LowStockThreshold,
|
||||
LookaheadDays: req.LookaheadDays, Timezone: req.Timezone, Slots: slots, SourceRefs: refs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toInventoryPolicyData(item), nil
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ type CopyDraft struct {
|
|||
ReferenceNotes string `bson:"reference_notes,omitempty"`
|
||||
Sources []string `bson:"sources,omitempty"`
|
||||
Status string `bson:"status,omitempty"`
|
||||
PublishQueueID string `bson:"publish_queue_id,omitempty"`
|
||||
PublishedMediaID string `bson:"published_media_id,omitempty"`
|
||||
PublishedPermalink string `bson:"published_permalink,omitempty"`
|
||||
PublishedAt int64 `bson:"published_at,omitempty"`
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type CopyDraftSummary struct {
|
|||
ReferenceNotes string
|
||||
Sources []string
|
||||
Status string
|
||||
PublishQueueID string
|
||||
PublishedMediaID string
|
||||
PublishedPermalink string
|
||||
PublishedAt int64
|
||||
|
|
@ -33,6 +34,14 @@ type MarkPublishedRequest struct {
|
|||
Permalink string
|
||||
}
|
||||
|
||||
type MarkScheduledRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
PersonaID string
|
||||
DraftID string
|
||||
QueueID string
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
|
|
@ -86,6 +95,7 @@ type UseCase interface {
|
|||
Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*CopyDraftSummary, error)
|
||||
Update(ctx context.Context, req UpdateRequest) (*CopyDraftSummary, error)
|
||||
MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error)
|
||||
MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error)
|
||||
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error)
|
||||
ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequ
|
|||
}
|
||||
if req.Patch.Status != nil {
|
||||
status := strings.TrimSpace(*req.Patch.Status)
|
||||
if status != "" && status != "pending" && status != "ready" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("status must be pending or ready")
|
||||
if status != "" && status != "pending" && status != "ready" && status != "scheduled" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready or scheduled")
|
||||
}
|
||||
if status != "" {
|
||||
patch["status"] = status
|
||||
|
|
@ -181,6 +181,7 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar
|
|||
now := clock.NowUnixNano()
|
||||
patch := map[string]interface{}{
|
||||
"status": "published",
|
||||
"publish_queue_id": "",
|
||||
"published_media_id": mediaID,
|
||||
"published_permalink": strings.TrimSpace(req.Permalink),
|
||||
"published_at": now,
|
||||
|
|
@ -193,6 +194,26 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar
|
|||
return &summary, nil
|
||||
}
|
||||
|
||||
func (u *copyDraftUseCase) MarkScheduled(ctx context.Context, req domusecase.MarkScheduledRequest) (*domusecase.CopyDraftSummary, error) {
|
||||
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
draftID := strings.TrimSpace(req.DraftID)
|
||||
queueID := strings.TrimSpace(req.QueueID)
|
||||
if draftID == "" || queueID == "" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("draft_id and queue_id are required")
|
||||
}
|
||||
item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, map[string]interface{}{
|
||||
"status": "scheduled",
|
||||
"publish_queue_id": queueID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := toSummary(*item)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (u *copyDraftUseCase) ReplaceMissionMatrix(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
|
|
@ -388,6 +409,7 @@ func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary {
|
|||
ReferenceNotes: item.ReferenceNotes,
|
||||
Sources: item.Sources,
|
||||
Status: item.Status,
|
||||
PublishQueueID: item.PublishQueueID,
|
||||
PublishedMediaID: item.PublishedMediaID,
|
||||
PublishedPermalink: item.PublishedPermalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ type UseCase interface {
|
|||
EnsureGenerateCopyDraftTemplate(ctx context.Context) error
|
||||
EnsureRefreshThreadsTokenTemplate(ctx context.Context) error
|
||||
EnsurePublishAnalyticsTemplate(ctx context.Context) error
|
||||
EnsureRefillPublishInventoryTemplate(ctx context.Context) error
|
||||
|
||||
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
|
||||
GetRun(ctx context.Context, jobID string) (*entity.Run, error)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const (
|
|||
generateCopyDraftTemplateType = "generate-copy-draft"
|
||||
refreshThreadsTokenTemplateType = "refresh-threads-token"
|
||||
publishAnalyticsTemplateType = "publish-analytics"
|
||||
refillPublishInventoryTemplateType = "refill-publish-inventory"
|
||||
style8DWorkerType = "node"
|
||||
)
|
||||
|
||||
|
|
@ -117,6 +118,11 @@ func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (u *jobUseCase) EnsureRefillPublishInventoryTemplate(ctx context.Context) error {
|
||||
_, err := u.templates.Upsert(ctx, refillPublishInventoryTemplate())
|
||||
return err
|
||||
}
|
||||
|
||||
func expandCopyMissionGraphTemplate() *entity.Template {
|
||||
return &entity.Template{
|
||||
Type: expandCopyMissionGraphTemplateType,
|
||||
|
|
@ -269,6 +275,32 @@ func publishAnalyticsTemplate() *entity.Template {
|
|||
}
|
||||
}
|
||||
|
||||
func refillPublishInventoryTemplate() *entity.Template {
|
||||
return &entity.Template{
|
||||
Type: refillPublishInventoryTemplateType,
|
||||
Version: 1,
|
||||
Name: "Refill Publish Inventory",
|
||||
Description: "Generate and schedule drafts when Threads publish inventory is low",
|
||||
Enabled: true,
|
||||
Repeatable: true,
|
||||
ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
|
||||
DedupeKeys: []string{"account_id"},
|
||||
TimeoutSeconds: 900,
|
||||
CancelPolicy: entity.CancelPolicy{
|
||||
Supported: true,
|
||||
Mode: "cooperative",
|
||||
GraceSeconds: 30,
|
||||
},
|
||||
RetryPolicy: entity.RetryPolicy{
|
||||
MaxAttempts: 1,
|
||||
BackoffSeconds: []int{},
|
||||
},
|
||||
Steps: []entity.TemplateStep{
|
||||
{ID: "refill_publish_inventory", Name: "Refill publish inventory", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 900, Cancelable: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func scanViralTemplate() *entity.Template {
|
||||
return &entity.Template{
|
||||
Type: scanViralTemplateType,
|
||||
|
|
|
|||
|
|
@ -95,14 +95,14 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(perms) == 0 {
|
||||
perms, err = u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasRole(roles, "admin") {
|
||||
perms = filterPermissionsByName(perms, defaultUserPermissionNames())
|
||||
}
|
||||
openPerms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasRole(roles, "admin") {
|
||||
perms = mergePermissions(perms, openPerms)
|
||||
} else {
|
||||
perms = mergePermissions(perms, filterPermissionsByName(openPerms, defaultUserPermissionNames()))
|
||||
}
|
||||
nodes := permissionNodes(perms)
|
||||
result := &domusecase.MePermissions{
|
||||
|
|
@ -169,6 +169,28 @@ func filterPermissionsByName(items []*entity.Permission, names []string) []*enti
|
|||
return out
|
||||
}
|
||||
|
||||
func mergePermissions(base []*entity.Permission, extras []*entity.Permission) []*entity.Permission {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]*entity.Permission, 0, len(base)+len(extras))
|
||||
for _, group := range [][]*entity.Permission{base, extras} {
|
||||
for _, item := range group {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
key := item.Name
|
||||
if key == "" {
|
||||
key = item.ID.Hex()
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultUserPermissionNames() []string {
|
||||
return []string{
|
||||
"auth.logout",
|
||||
|
|
@ -251,7 +273,7 @@ func defaultPermissions() []*entity.Permission {
|
|||
{Name: "brand.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/brands/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
{Name: "persona.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/personas/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
{Name: "placement_topic.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/placement/topics/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
{Name: "threads_account.manage", HTTPMethods: "GET|POST|PUT|PATCH", HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
{Name: "threads_account.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
|
||||
{Name: "job.manage", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
{Name: "job.schedule.manage", HTTPMethods: "GET|POST|PUT|DELETE", HTTPPath: "/api/v1/job/schedules/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ type Repository interface {
|
|||
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)
|
||||
DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,9 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA
|
|||
return nil, app.For(code.AI).InputMissingRequired("analytics id is required")
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": analytics.ID}
|
||||
filter := actorFilter(analytics.TenantID, analytics.OwnerUID)
|
||||
filter["media_id"] = strings.TrimSpace(analytics.MediaID)
|
||||
filter["checkpoint"] = strings.TrimSpace(analytics.Checkpoint)
|
||||
opts := options.Replace().SetUpsert(true)
|
||||
_, err := r.collection.ReplaceOne(ctx, filter, analytics, opts)
|
||||
if err != nil {
|
||||
|
|
@ -123,3 +125,32 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA
|
|||
}
|
||||
return analytics, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) {
|
||||
if r.collection == nil {
|
||||
return 0, app.For(code.AI).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
ids := make([]string, 0, len(mediaIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, mediaID := range mediaIDs {
|
||||
trimmed := strings.TrimSpace(mediaID)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
ids = append(ids, trimmed)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
filter := actorFilter(tenantID, ownerUID)
|
||||
filter["media_id"] = bson.M{"$in": ids}
|
||||
res, err := r.collection.DeleteMany(ctx, filter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.DeletedCount, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ type Draft struct {
|
|||
Tags []string `bson:"tags,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
UpdateAt int64 `bson:"update_at"`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ type Repository interface {
|
|||
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
|
||||
Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,4 +45,4 @@ type UseCase interface {
|
|||
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
|
||||
Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package entity
|
||||
|
||||
const CollectionName = "publish_guard_policies"
|
||||
|
||||
type Policy struct {
|
||||
ID string `bson:"_id"`
|
||||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
AccountID string `bson:"account_id"`
|
||||
Enabled bool `bson:"enabled"`
|
||||
MaxDailyPosts int `bson:"max_daily_posts"`
|
||||
MinIntervalMinutes int `bson:"min_interval_minutes"`
|
||||
ConsecutiveFailurePauseLimit int `bson:"consecutive_failure_pause_limit"`
|
||||
Paused bool `bson:"paused"`
|
||||
PausedReason string `bson:"paused_reason,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
UpdateAt int64 `bson:"update_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/model/publish_guard/domain/entity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
EnsureIndexes(ctx context.Context) error
|
||||
Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
|
||||
Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
|
||||
DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package usecase
|
||||
|
||||
import "context"
|
||||
|
||||
type PolicySummary struct {
|
||||
AccountID string
|
||||
Enabled bool
|
||||
MaxDailyPosts int
|
||||
MinIntervalMinutes int
|
||||
ConsecutiveFailurePauseLimit int
|
||||
Paused bool
|
||||
PausedReason string
|
||||
UpdateAt int64
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Enabled bool
|
||||
MaxDailyPosts int
|
||||
MinIntervalMinutes int
|
||||
ConsecutiveFailurePauseLimit int
|
||||
Paused *bool
|
||||
PausedReason string
|
||||
}
|
||||
|
||||
type CheckRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Now int64
|
||||
PublishedToday int64
|
||||
LastPublishedAt int64
|
||||
FailureStreak int
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Allowed bool
|
||||
DelayUntil int64
|
||||
Reason string
|
||||
ShouldPause bool
|
||||
}
|
||||
|
||||
type UseCase interface {
|
||||
Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
|
||||
Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
|
||||
Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
|
||||
Check(ctx context.Context, req CheckRequest) (*CheckResult, error)
|
||||
Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*PolicySummary, error)
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/model/publish_guard/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/publish_guard/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().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
var out entity.Policy
|
||||
err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, app.For(code.ThreadsAccount).ResNotFound("publish guard policy not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
if policy == nil {
|
||||
return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
|
||||
}
|
||||
opts := options.Replace().SetUpsert(true)
|
||||
_, err := r.collection.ReplaceOne(ctx, actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID), policy, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
|
||||
if r.collection == nil {
|
||||
return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
_, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
|
||||
return err
|
||||
}
|
||||
|
||||
func actorFilter(tenantID, ownerUID, accountID string) bson.M {
|
||||
return bson.M{
|
||||
"tenant_id": strings.TrimSpace(tenantID),
|
||||
"owner_uid": strings.TrimSpace(ownerUID),
|
||||
"account_id": strings.TrimSpace(accountID),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"haixun-backend/internal/library/clock"
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/model/publish_guard/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/publish_guard/domain/repository"
|
||||
domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
|
||||
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type guardUseCase struct {
|
||||
repo domrepo.Repository
|
||||
threadsAccount threadsaccountdomain.UseCase
|
||||
}
|
||||
|
||||
func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
|
||||
return &guardUseCase{repo: repo, threadsAccount: threadsAccount}
|
||||
}
|
||||
|
||||
func (u *guardUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
|
||||
if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
item = defaultPolicy(tenantID, ownerUID, accountID)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := toSummary(item)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (u *guardUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
|
||||
if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
|
||||
item.Enabled = req.Enabled
|
||||
if req.MaxDailyPosts > 0 {
|
||||
item.MaxDailyPosts = req.MaxDailyPosts
|
||||
}
|
||||
if req.MinIntervalMinutes > 0 {
|
||||
item.MinIntervalMinutes = req.MinIntervalMinutes
|
||||
}
|
||||
if req.ConsecutiveFailurePauseLimit > 0 {
|
||||
item.ConsecutiveFailurePauseLimit = req.ConsecutiveFailurePauseLimit
|
||||
}
|
||||
if req.Paused != nil {
|
||||
item.Paused = *req.Paused
|
||||
}
|
||||
item.PausedReason = strings.TrimSpace(req.PausedReason)
|
||||
item.UpdateAt = clock.NowUnixNano()
|
||||
saved, err := u.repo.Upsert(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := toSummary(saved)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (u *guardUseCase) Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
|
||||
policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paused := false
|
||||
return u.Upsert(ctx, domusecase.UpsertRequest{
|
||||
TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
|
||||
MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
|
||||
ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
|
||||
Paused: &paused,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *guardUseCase) Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*domusecase.PolicySummary, error) {
|
||||
policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paused := true
|
||||
return u.Upsert(ctx, domusecase.UpsertRequest{
|
||||
TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
|
||||
MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
|
||||
ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
|
||||
Paused: &paused, PausedReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *guardUseCase) Check(ctx context.Context, req domusecase.CheckRequest) (*domusecase.CheckResult, error) {
|
||||
policy, err := u.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !policy.Enabled {
|
||||
return &domusecase.CheckResult{Allowed: true}, nil
|
||||
}
|
||||
now := req.Now
|
||||
if now <= 0 {
|
||||
now = clock.NowUnixNano()
|
||||
}
|
||||
if policy.Paused {
|
||||
return &domusecase.CheckResult{Allowed: false, Reason: firstNonEmpty(policy.PausedReason, "發文已暫停")}, nil
|
||||
}
|
||||
if policy.MaxDailyPosts > 0 && req.PublishedToday >= int64(policy.MaxDailyPosts) {
|
||||
return &domusecase.CheckResult{Allowed: false, DelayUntil: now + int64(time.Hour), Reason: "已達每日發文上限"}, nil
|
||||
}
|
||||
if policy.MinIntervalMinutes > 0 && req.LastPublishedAt > 0 {
|
||||
minNext := req.LastPublishedAt + int64(time.Duration(policy.MinIntervalMinutes)*time.Minute)
|
||||
if minNext > now {
|
||||
return &domusecase.CheckResult{Allowed: false, DelayUntil: minNext, Reason: "未達最小發文間隔"}, nil
|
||||
}
|
||||
}
|
||||
if policy.ConsecutiveFailurePauseLimit > 0 && req.FailureStreak >= policy.ConsecutiveFailurePauseLimit {
|
||||
return &domusecase.CheckResult{Allowed: false, Reason: "連續失敗達上限,已暫停帳號發文", ShouldPause: true}, nil
|
||||
}
|
||||
return &domusecase.CheckResult{Allowed: true}, nil
|
||||
}
|
||||
|
||||
func (u *guardUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
|
||||
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||||
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
||||
}
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
||||
}
|
||||
if u.threadsAccount != nil {
|
||||
_, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
|
||||
now := clock.NowUnixNano()
|
||||
return &entity.Policy{
|
||||
ID: uuid.NewString(), TenantID: strings.TrimSpace(tenantID), OwnerUID: strings.TrimSpace(ownerUID), AccountID: strings.TrimSpace(accountID),
|
||||
Enabled: true, MaxDailyPosts: 6, MinIntervalMinutes: 90, ConsecutiveFailurePauseLimit: 3,
|
||||
CreateAt: now, UpdateAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func toSummary(item *entity.Policy) domusecase.PolicySummary {
|
||||
if item == nil {
|
||||
return domusecase.PolicySummary{}
|
||||
}
|
||||
return domusecase.PolicySummary{
|
||||
AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
|
||||
MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
|
||||
Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"haixun-backend/internal/model/publish_guard/domain/entity"
|
||||
domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
|
||||
)
|
||||
|
||||
type guardRepoStub struct {
|
||||
policy *entity.Policy
|
||||
}
|
||||
|
||||
func (r *guardRepoStub) EnsureIndexes(ctx context.Context) error { return nil }
|
||||
func (r *guardRepoStub) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
|
||||
if r.policy == nil {
|
||||
r.policy = defaultPolicy(tenantID, ownerUID, accountID)
|
||||
}
|
||||
return r.policy, nil
|
||||
}
|
||||
func (r *guardRepoStub) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
|
||||
r.policy = policy
|
||||
return policy, nil
|
||||
}
|
||||
func (r *guardRepoStub) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
|
||||
r.policy = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGuardCheckBlocksPausedPolicy(t *testing.T) {
|
||||
repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
|
||||
repo.policy.Paused = true
|
||||
repo.policy.PausedReason = "token error"
|
||||
uc := NewUseCase(repo, nil)
|
||||
|
||||
result, err := uc.Check(context.Background(), checkReq())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Allowed || result.Reason != "token error" {
|
||||
t.Fatalf("expected paused block, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardCheckDelaysWhenDailyLimitReached(t *testing.T) {
|
||||
repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
|
||||
repo.policy.MaxDailyPosts = 1
|
||||
uc := NewUseCase(repo, nil)
|
||||
|
||||
result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
|
||||
req.publishedToday = 1
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Allowed || result.DelayUntil == 0 {
|
||||
t.Fatalf("expected delayed daily limit, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardCheckDelaysWhenIntervalNotReached(t *testing.T) {
|
||||
repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
|
||||
repo.policy.MinIntervalMinutes = 90
|
||||
uc := NewUseCase(repo, nil)
|
||||
|
||||
now := time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()
|
||||
result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
|
||||
req.now = now
|
||||
req.lastPublishedAt = now - int64(30*time.Minute)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Allowed || result.DelayUntil <= now {
|
||||
t.Fatalf("expected interval delay, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type CheckRequestBuilder struct {
|
||||
now int64
|
||||
publishedToday int64
|
||||
lastPublishedAt int64
|
||||
}
|
||||
|
||||
func checkReq() domusecase.CheckRequest {
|
||||
return checkReqWith(nil)
|
||||
}
|
||||
|
||||
func checkReqWith(update func(*CheckRequestBuilder)) domusecase.CheckRequest {
|
||||
builder := &CheckRequestBuilder{now: time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()}
|
||||
if update != nil {
|
||||
update(builder)
|
||||
}
|
||||
return domusecase.CheckRequest{
|
||||
TenantID: "t", OwnerUID: "u", AccountID: "a",
|
||||
Now: builder.now, PublishedToday: builder.publishedToday, LastPublishedAt: builder.lastPublishedAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package entity
|
||||
|
||||
const CollectionName = "publish_inventory_policies"
|
||||
|
||||
type Slot struct {
|
||||
Weekday int `bson:"weekday" json:"weekday"`
|
||||
Time string `bson:"time" json:"time"`
|
||||
}
|
||||
|
||||
type SourceRef struct {
|
||||
Type string `bson:"type" json:"type"`
|
||||
PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"`
|
||||
CopyMissionID string `bson:"copy_mission_id,omitempty" json:"copy_mission_id,omitempty"`
|
||||
ScanPostID string `bson:"scan_post_id,omitempty" json:"scan_post_id,omitempty"`
|
||||
ManualSeed string `bson:"manual_seed,omitempty" json:"manual_seed,omitempty"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
ID string `bson:"_id"`
|
||||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
AccountID string `bson:"account_id"`
|
||||
Enabled bool `bson:"enabled"`
|
||||
TargetDailyCount int `bson:"target_daily_count"`
|
||||
LowStockThreshold int `bson:"low_stock_threshold"`
|
||||
LookaheadDays int `bson:"lookahead_days"`
|
||||
Timezone string `bson:"timezone"`
|
||||
Slots []Slot `bson:"slots,omitempty"`
|
||||
SourceRefs []SourceRef `bson:"source_refs,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
UpdateAt int64 `bson:"update_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/model/publish_inventory/domain/entity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
EnsureIndexes(ctx context.Context) error
|
||||
Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
|
||||
Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
|
||||
ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error)
|
||||
DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package usecase
|
||||
|
||||
import "context"
|
||||
|
||||
type Slot struct {
|
||||
Weekday int
|
||||
Time string
|
||||
}
|
||||
|
||||
type SourceRef struct {
|
||||
Type string
|
||||
PersonaID string
|
||||
CopyMissionID string
|
||||
ScanPostID string
|
||||
ManualSeed string
|
||||
}
|
||||
|
||||
type PolicySummary struct {
|
||||
AccountID string
|
||||
Enabled bool
|
||||
TargetDailyCount int
|
||||
LowStockThreshold int
|
||||
LookaheadDays int
|
||||
Timezone string
|
||||
Slots []Slot
|
||||
SourceRefs []SourceRef
|
||||
UpdateAt int64
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Enabled bool
|
||||
TargetDailyCount int
|
||||
LowStockThreshold int
|
||||
LookaheadDays int
|
||||
Timezone string
|
||||
Slots []Slot
|
||||
SourceRefs []SourceRef
|
||||
}
|
||||
|
||||
type UseCase interface {
|
||||
Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
|
||||
Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
|
||||
ListEnabled(ctx context.Context, limit int) ([]PolicySummary, error)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/model/publish_inventory/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/publish_inventory/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: "account_id", Value: 1}}, Options: options.Index().SetUnique(true)},
|
||||
{Keys: bson.D{{Key: "enabled", Value: 1}}},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
var out entity.Policy
|
||||
err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, app.For(code.ThreadsAccount).ResNotFound("publish inventory policy not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
if policy == nil {
|
||||
return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
|
||||
}
|
||||
filter := actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID)
|
||||
opts := options.Replace().SetUpsert(true)
|
||||
if _, err := r.collection.ReplaceOne(ctx, filter, policy, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
cur, err := r.collection.Find(ctx, bson.M{"enabled": true}, options.Find().SetLimit(int64(limit)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var out []entity.Policy
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
|
||||
if r.collection == nil {
|
||||
return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
_, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
|
||||
return err
|
||||
}
|
||||
|
||||
func actorFilter(tenantID, ownerUID, accountID string) bson.M {
|
||||
return bson.M{
|
||||
"tenant_id": strings.TrimSpace(tenantID),
|
||||
"owner_uid": strings.TrimSpace(ownerUID),
|
||||
"account_id": strings.TrimSpace(accountID),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
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_inventory/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
|
||||
domusecase "haixun-backend/internal/model/publish_inventory/domain/usecase"
|
||||
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type inventoryUseCase struct {
|
||||
repo domrepo.Repository
|
||||
threadsAccount threadsaccountdomain.UseCase
|
||||
}
|
||||
|
||||
func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
|
||||
return &inventoryUseCase{repo: repo, threadsAccount: threadsAccount}
|
||||
}
|
||||
|
||||
func (u *inventoryUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
|
||||
if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
item = defaultPolicy(tenantID, ownerUID, accountID)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := toSummary(item)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (u *inventoryUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
|
||||
if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
|
||||
item.Enabled = req.Enabled
|
||||
if req.TargetDailyCount > 0 {
|
||||
item.TargetDailyCount = req.TargetDailyCount
|
||||
}
|
||||
if req.LowStockThreshold > 0 {
|
||||
item.LowStockThreshold = req.LowStockThreshold
|
||||
}
|
||||
if req.LookaheadDays > 0 {
|
||||
item.LookaheadDays = req.LookaheadDays
|
||||
}
|
||||
if strings.TrimSpace(req.Timezone) != "" {
|
||||
item.Timezone = strings.TrimSpace(req.Timezone)
|
||||
}
|
||||
item.Slots = slotsToEntity(req.Slots)
|
||||
item.SourceRefs = sourceRefsToEntity(req.SourceRefs)
|
||||
now := clock.NowUnixNano()
|
||||
item.UpdateAt = now
|
||||
saved, err := u.repo.Upsert(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := toSummary(saved)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (u *inventoryUseCase) ListEnabled(ctx context.Context, limit int) ([]domusecase.PolicySummary, error) {
|
||||
items, err := u.repo.ListEnabled(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domusecase.PolicySummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
summary := toSummary(&item)
|
||||
out = append(out, summary)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (u *inventoryUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
|
||||
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||||
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
||||
}
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
||||
}
|
||||
if u.threadsAccount != nil {
|
||||
_, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
|
||||
now := clock.NowUnixNano()
|
||||
return &entity.Policy{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: strings.TrimSpace(tenantID),
|
||||
OwnerUID: strings.TrimSpace(ownerUID),
|
||||
AccountID: strings.TrimSpace(accountID),
|
||||
Enabled: false,
|
||||
TargetDailyCount: 2,
|
||||
LowStockThreshold: 3,
|
||||
LookaheadDays: 7,
|
||||
Timezone: "Asia/Taipei",
|
||||
Slots: []entity.Slot{
|
||||
{Weekday: 1, Time: "09:30"},
|
||||
{Weekday: 3, Time: "12:30"},
|
||||
{Weekday: 5, Time: "18:30"},
|
||||
},
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func toSummary(item *entity.Policy) domusecase.PolicySummary {
|
||||
if item == nil {
|
||||
return domusecase.PolicySummary{}
|
||||
}
|
||||
return domusecase.PolicySummary{
|
||||
AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
|
||||
LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
|
||||
Timezone: item.Timezone, Slots: slotsFromEntity(item.Slots), SourceRefs: sourceRefsFromEntity(item.SourceRefs), UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
||||
func slotsToEntity(items []domusecase.Slot) []entity.Slot {
|
||||
out := make([]entity.Slot, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.Time) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, entity.Slot{Weekday: item.Weekday, Time: strings.TrimSpace(item.Time)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slotsFromEntity(items []entity.Slot) []domusecase.Slot {
|
||||
out := make([]domusecase.Slot, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, domusecase.Slot{Weekday: item.Weekday, Time: item.Time})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceRefsToEntity(items []domusecase.SourceRef) []entity.SourceRef {
|
||||
out := make([]entity.SourceRef, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.Type) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, entity.SourceRef{
|
||||
Type: strings.TrimSpace(item.Type), PersonaID: strings.TrimSpace(item.PersonaID),
|
||||
CopyMissionID: strings.TrimSpace(item.CopyMissionID), ScanPostID: strings.TrimSpace(item.ScanPostID), ManualSeed: strings.TrimSpace(item.ManualSeed),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceRefsFromEntity(items []entity.SourceRef) []domusecase.SourceRef {
|
||||
out := make([]domusecase.SourceRef, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, domusecase.SourceRef{
|
||||
Type: item.Type, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, ManualSeed: item.ManualSeed,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -8,20 +8,29 @@ const (
|
|||
StatusPublished = "published"
|
||||
StatusFailed = "failed"
|
||||
StatusCancelled = "cancelled"
|
||||
StatusMissed = "missed"
|
||||
)
|
||||
|
||||
type QueueItem struct {
|
||||
ID string `bson:"_id"`
|
||||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
AccountID string `bson:"account_id"`
|
||||
Text string `bson:"text"`
|
||||
ScheduledAt int64 `bson:"scheduled_at"`
|
||||
Status string `bson:"status"`
|
||||
MediaID string `bson:"media_id,omitempty"`
|
||||
Permalink string `bson:"permalink,omitempty"`
|
||||
PublishedAt int64 `bson:"published_at,omitempty"`
|
||||
ErrorMessage string `bson:"error_message,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
UpdateAt int64 `bson:"update_at"`
|
||||
ID string `bson:"_id"`
|
||||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
AccountID string `bson:"account_id"`
|
||||
PersonaID string `bson:"persona_id,omitempty"`
|
||||
CopyMissionID string `bson:"copy_mission_id,omitempty"`
|
||||
CopyDraftID string `bson:"copy_draft_id,omitempty"`
|
||||
Text string `bson:"text"`
|
||||
ScheduledAt int64 `bson:"scheduled_at"`
|
||||
Status string `bson:"status"`
|
||||
MediaID string `bson:"media_id,omitempty"`
|
||||
Permalink string `bson:"permalink,omitempty"`
|
||||
PublishedAt int64 `bson:"published_at,omitempty"`
|
||||
ErrorMessage string `bson:"error_message,omitempty"`
|
||||
RetryCount int `bson:"retry_count,omitempty"`
|
||||
LastAttemptAt int64 `bson:"last_attempt_at,omitempty"`
|
||||
NextAttemptAt int64 `bson:"next_attempt_at,omitempty"`
|
||||
MissedAt int64 `bson:"missed_at,omitempty"`
|
||||
PausedReason string `bson:"paused_reason,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
UpdateAt int64 `bson:"update_at"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ type ListFilter struct {
|
|||
OwnerUID string
|
||||
AccountID string
|
||||
Status string
|
||||
StartAt int64
|
||||
EndAt int64
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
|
|
@ -24,8 +26,13 @@ type Repository interface {
|
|||
Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error)
|
||||
List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error)
|
||||
ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error)
|
||||
ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error)
|
||||
UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error)
|
||||
Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error)
|
||||
CountByStatus(ctx context.Context, tenantID, ownerUID, accountID string, status string) (int64, error)
|
||||
CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error)
|
||||
LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
|
||||
ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error)
|
||||
DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,25 +3,36 @@ package usecase
|
|||
import "context"
|
||||
|
||||
type QueueItemSummary struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Text string
|
||||
ScheduledAt int64
|
||||
Status string
|
||||
MediaID string
|
||||
Permalink string
|
||||
PublishedAt int64
|
||||
ErrorMessage string
|
||||
CreateAt int64
|
||||
UpdateAt int64
|
||||
ID string
|
||||
AccountID string
|
||||
PersonaID string
|
||||
CopyMissionID string
|
||||
CopyDraftID string
|
||||
Text string
|
||||
ScheduledAt int64
|
||||
Status string
|
||||
MediaID string
|
||||
Permalink string
|
||||
PublishedAt int64
|
||||
ErrorMessage string
|
||||
RetryCount int
|
||||
LastAttemptAt int64
|
||||
NextAttemptAt int64
|
||||
MissedAt int64
|
||||
PausedReason string
|
||||
CreateAt int64
|
||||
UpdateAt int64
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Text string
|
||||
ScheduledAt int64
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
PersonaID string
|
||||
CopyMissionID string
|
||||
CopyDraftID string
|
||||
Text string
|
||||
ScheduledAt int64
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
|
|
@ -42,6 +53,17 @@ type ListRequest struct {
|
|||
PageSize int
|
||||
}
|
||||
|
||||
type RangeRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Status string
|
||||
StartAt int64
|
||||
EndAt int64
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Items []QueueItemSummary
|
||||
Total int64
|
||||
|
|
@ -92,13 +114,38 @@ type PublishHealthResult struct {
|
|||
}
|
||||
|
||||
type RecordPublishedRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
Text string
|
||||
MediaID string
|
||||
Permalink string
|
||||
PublishedAt int64
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
AccountID string
|
||||
PersonaID string
|
||||
CopyMissionID string
|
||||
CopyDraftID string
|
||||
Text string
|
||||
MediaID string
|
||||
Permalink string
|
||||
PublishedAt int64
|
||||
}
|
||||
|
||||
type SlotInsight struct {
|
||||
Weekday int
|
||||
Time string
|
||||
AvgLikes1h int
|
||||
AvgReplies1h int
|
||||
AvgLikes24h int
|
||||
AvgReplies24h int
|
||||
AvgLikes7d int
|
||||
AvgReplies7d int
|
||||
SampleSize int
|
||||
Recommendation string
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
Type string
|
||||
Severity string
|
||||
Message string
|
||||
QueueID string
|
||||
AccountID string
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
type UseCase interface {
|
||||
|
|
@ -106,9 +153,14 @@ type UseCase interface {
|
|||
RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error)
|
||||
Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
|
||||
List(ctx context.Context, req ListRequest) (*ListResult, error)
|
||||
ListRange(ctx context.Context, req RangeRequest) (*ListResult, error)
|
||||
Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error)
|
||||
Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
|
||||
Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
|
||||
DispatchDue(ctx context.Context, limit int) (int, error)
|
||||
MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error)
|
||||
ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]Alert, error)
|
||||
ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]SlotInsight, error)
|
||||
ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*PublishHealthResult, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,16 @@ func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, p
|
|||
if status := strings.TrimSpace(filter.Status); status != "" {
|
||||
mongoFilter["status"] = status
|
||||
}
|
||||
if filter.StartAt > 0 || filter.EndAt > 0 {
|
||||
rangeFilter := bson.M{}
|
||||
if filter.StartAt > 0 {
|
||||
rangeFilter["$gte"] = filter.StartAt
|
||||
}
|
||||
if filter.EndAt > 0 {
|
||||
rangeFilter["$lte"] = filter.EndAt
|
||||
}
|
||||
mongoFilter["scheduled_at"] = rangeFilter
|
||||
}
|
||||
total, err := r.collection.CountDocuments(ctx, mongoFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -154,6 +164,32 @@ func (r *mongoRepository) ListDue(ctx context.Context, now int64, limit int) ([]
|
|||
return items, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
filter := bson.M{
|
||||
"status": entity.StatusScheduled,
|
||||
"scheduled_at": bson.M{"$lte": before},
|
||||
}
|
||||
opts := options.Find().
|
||||
SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).
|
||||
SetLimit(int64(limit))
|
||||
cur, err := r.collection.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var items []entity.QueueItem
|
||||
if err := cur.All(ctx, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
|
|
@ -221,3 +257,75 @@ func (r *mongoRepository) CountByStatus(ctx context.Context, tenantID, ownerUID,
|
|||
filter["status"] = strings.TrimSpace(status)
|
||||
return r.collection.CountDocuments(ctx, filter)
|
||||
}
|
||||
|
||||
func (r *mongoRepository) CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) {
|
||||
if r.collection == nil {
|
||||
return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
filter := actorFilter(tenantID, ownerUID, accountID)
|
||||
filter["status"] = entity.StatusPublished
|
||||
filter["published_at"] = bson.M{"$gte": since}
|
||||
return r.collection.CountDocuments(ctx, filter)
|
||||
}
|
||||
|
||||
func (r *mongoRepository) LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
|
||||
if r.collection == nil {
|
||||
return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
filter := actorFilter(tenantID, ownerUID, accountID)
|
||||
filter["status"] = entity.StatusPublished
|
||||
opts := options.FindOne().SetSort(bson.D{{Key: "published_at", Value: -1}})
|
||||
var out entity.QueueItem
|
||||
err := r.collection.FindOne(ctx, filter, opts).Decode(&out)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return out.PublishedAt, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
filter := actorFilter(tenantID, ownerUID, accountID)
|
||||
filter["media_id"] = bson.M{"$ne": ""}
|
||||
cur, err := r.collection.Find(ctx, filter, options.Find().SetProjection(bson.M{"media_id": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
seen := map[string]struct{}{}
|
||||
var ids []string
|
||||
for cur.Next(ctx) {
|
||||
var row struct {
|
||||
MediaID string `bson:"media_id"`
|
||||
}
|
||||
if err := cur.Decode(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id := strings.TrimSpace(row.MediaID); id != "" {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
|
||||
if r.collection == nil {
|
||||
return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
res, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.DeletedCount, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,14 @@ import (
|
|||
"haixun-backend/internal/library/errors/code"
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
"haixun-backend/internal/library/threadspost"
|
||||
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
|
||||
padomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
|
||||
guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
|
||||
"haixun-backend/internal/model/publish_queue/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
|
||||
domusecase "haixun-backend/internal/model/publish_queue/domain/usecase"
|
||||
evententity "haixun-backend/internal/model/publish_queue_event/domain/entity"
|
||||
eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
|
||||
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -23,17 +27,26 @@ type publishQueueUseCase struct {
|
|||
repo domrepo.Repository
|
||||
threadsAccount threadsaccountdomain.UseCase
|
||||
publishAnalytics padomain.UseCase
|
||||
guard guarddomain.UseCase
|
||||
events eventdomain.UseCase
|
||||
copyDraft copydraftdomain.UseCase
|
||||
}
|
||||
|
||||
func NewUseCase(
|
||||
repo domrepo.Repository,
|
||||
threadsAccount threadsaccountdomain.UseCase,
|
||||
publishAnalytics padomain.UseCase,
|
||||
guard guarddomain.UseCase,
|
||||
events eventdomain.UseCase,
|
||||
copyDraft copydraftdomain.UseCase,
|
||||
) domusecase.UseCase {
|
||||
return &publishQueueUseCase{
|
||||
repo: repo,
|
||||
threadsAccount: threadsAccount,
|
||||
publishAnalytics: publishAnalytics,
|
||||
guard: guard,
|
||||
events: events,
|
||||
copyDraft: copyDraft,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,19 +78,23 @@ func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateR
|
|||
}
|
||||
|
||||
item := &entity.QueueItem{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
AccountID: accountID,
|
||||
Text: text,
|
||||
ScheduledAt: scheduledAt,
|
||||
Status: entity.StatusScheduled,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
AccountID: accountID,
|
||||
PersonaID: strings.TrimSpace(req.PersonaID),
|
||||
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
|
||||
CopyDraftID: strings.TrimSpace(req.CopyDraftID),
|
||||
Text: text,
|
||||
ScheduledAt: scheduledAt,
|
||||
Status: entity.StatusScheduled,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
}
|
||||
if err := u.repo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存")
|
||||
|
||||
if scheduledAt <= now {
|
||||
_, _ = u.DispatchDue(ctx, 1)
|
||||
|
|
@ -112,22 +129,26 @@ func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecas
|
|||
publishedAt = now
|
||||
}
|
||||
item := &entity.QueueItem{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
AccountID: accountID,
|
||||
Text: strings.TrimSpace(req.Text),
|
||||
ScheduledAt: publishedAt,
|
||||
Status: entity.StatusPublished,
|
||||
MediaID: mediaID,
|
||||
Permalink: strings.TrimSpace(req.Permalink),
|
||||
PublishedAt: publishedAt,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
AccountID: accountID,
|
||||
PersonaID: strings.TrimSpace(req.PersonaID),
|
||||
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
|
||||
CopyDraftID: strings.TrimSpace(req.CopyDraftID),
|
||||
Text: strings.TrimSpace(req.Text),
|
||||
ScheduledAt: publishedAt,
|
||||
Status: entity.StatusPublished,
|
||||
MediaID: mediaID,
|
||||
Permalink: strings.TrimSpace(req.Permalink),
|
||||
PublishedAt: publishedAt,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
}
|
||||
if err := u.repo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果")
|
||||
_ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
|
||||
TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID,
|
||||
MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt,
|
||||
|
|
@ -188,6 +209,38 @@ func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListReque
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) ListRange(ctx context.Context, req domusecase.RangeRequest) (*domusecase.ListResult, error) {
|
||||
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, pageSize := req.Page, req.PageSize
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
result, err := u.repo.List(ctx, domrepo.ListFilter{
|
||||
TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID,
|
||||
Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt,
|
||||
}, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
items = append(items, toSummary(item))
|
||||
}
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
return &domusecase.ListResult{Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) {
|
||||
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -238,6 +291,39 @@ func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, ac
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程")
|
||||
out := toSummary(*updated)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
|
||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.Status != entity.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed {
|
||||
return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試")
|
||||
}
|
||||
from := item.Status
|
||||
item.Status = entity.StatusScheduled
|
||||
item.ErrorMessage = ""
|
||||
item.PausedReason = ""
|
||||
item.MissedAt = 0
|
||||
item.RetryCount++
|
||||
item.NextAttemptAt = 0
|
||||
now := clock.NowUnixNano()
|
||||
item.UpdateAt = now
|
||||
if item.ScheduledAt < now {
|
||||
item.ScheduledAt = now
|
||||
}
|
||||
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程")
|
||||
out := toSummary(*updated)
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -283,12 +369,33 @@ func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int,
|
|||
}
|
||||
|
||||
func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error {
|
||||
if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed {
|
||||
if result.ShouldPause && u.guard != nil {
|
||||
_, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason)
|
||||
}
|
||||
item.PausedReason = result.Reason
|
||||
if result.DelayUntil > 0 {
|
||||
item.ScheduledAt = result.DelayUntil
|
||||
} else {
|
||||
item.Status = entity.StatusFailed
|
||||
item.ErrorMessage = result.Reason
|
||||
}
|
||||
item.UpdateAt = clock.NowUnixNano()
|
||||
updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason)
|
||||
return nil
|
||||
}
|
||||
item.Status = entity.StatusPublishing
|
||||
item.LastAttemptAt = clock.NowUnixNano()
|
||||
item.UpdateAt = clock.NowUnixNano()
|
||||
claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布")
|
||||
|
||||
account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
|
||||
if err != nil {
|
||||
|
|
@ -322,6 +429,13 @@ func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.Queu
|
|||
if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil {
|
||||
return err
|
||||
}
|
||||
u.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成")
|
||||
if claimed.CopyDraftID != "" && u.copyDraft != nil {
|
||||
_, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{
|
||||
TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID,
|
||||
DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink,
|
||||
})
|
||||
}
|
||||
_ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
|
||||
TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID,
|
||||
MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now,
|
||||
|
|
@ -332,8 +446,16 @@ func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.Queu
|
|||
func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error {
|
||||
item.Status = entity.StatusFailed
|
||||
item.ErrorMessage = message
|
||||
item.LastAttemptAt = clock.NowUnixNano()
|
||||
item.UpdateAt = clock.NowUnixNano()
|
||||
_, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
|
||||
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
|
||||
if err == nil {
|
||||
u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message)
|
||||
failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID)
|
||||
if u.guard != nil && failureStreak >= 3 {
|
||||
_, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -439,6 +561,162 @@ func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, o
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) {
|
||||
if graceNs <= 0 {
|
||||
graceNs = int64(10 * time.Minute)
|
||||
}
|
||||
before := clock.NowUnixNano() - graceNs
|
||||
items, err := u.repo.ListMissedCandidates(ctx, before, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
item.Status = entity.StatusMissed
|
||||
item.MissedAt = clock.NowUnixNano()
|
||||
item.UpdateAt = item.MissedAt
|
||||
updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布")
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) {
|
||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts := make([]domusecase.Alert, 0)
|
||||
for _, item := range result.Items {
|
||||
switch item.Status {
|
||||
case entity.StatusFailed:
|
||||
alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
|
||||
case entity.StatusMissed:
|
||||
alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt})
|
||||
}
|
||||
if item.PausedReason != "" {
|
||||
alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
|
||||
}
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) {
|
||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type agg struct {
|
||||
likes7d int
|
||||
replies7d int
|
||||
samples int
|
||||
}
|
||||
slots := map[string]*agg{}
|
||||
for _, item := range health.Items {
|
||||
t := time.Unix(0, item.PublishedAt).UTC()
|
||||
key := t.Format("15:04")
|
||||
if _, ok := slots[key]; !ok {
|
||||
slots[key] = &agg{}
|
||||
}
|
||||
slots[key].likes7d += item.LikeCount
|
||||
slots[key].replies7d += item.ReplyCount
|
||||
slots[key].samples++
|
||||
}
|
||||
out := make([]domusecase.SlotInsight, 0, len(slots))
|
||||
for key, item := range slots {
|
||||
avgLikes := 0
|
||||
avgReplies := 0
|
||||
if item.samples > 0 {
|
||||
avgLikes = item.likes7d / item.samples
|
||||
avgReplies = item.replies7d / item.samples
|
||||
}
|
||||
recommendation := "neutral"
|
||||
if avgLikes+avgReplies >= 5 {
|
||||
recommendation = "recommended"
|
||||
} else if item.samples >= 2 {
|
||||
recommendation = "low"
|
||||
}
|
||||
out = append(out, domusecase.SlotInsight{
|
||||
Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies,
|
||||
AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies,
|
||||
SampleSize: item.samples, Recommendation: recommendation,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []domusecase.SlotInsight{
|
||||
{Weekday: 1, Time: "09:30", Recommendation: "recommended"},
|
||||
{Weekday: 3, Time: "12:30", Recommendation: "neutral"},
|
||||
{Weekday: 5, Time: "18:30", Recommendation: "recommended"},
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) {
|
||||
if u.guard == nil || item == nil {
|
||||
return &guarddomain.CheckResult{Allowed: true}, nil
|
||||
}
|
||||
now := clock.NowUnixNano()
|
||||
publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now))
|
||||
lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID)
|
||||
return u.guard.Check(ctx, guarddomain.CheckRequest{
|
||||
TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID,
|
||||
Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID),
|
||||
})
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int {
|
||||
result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
streak := 0
|
||||
for _, item := range result.Items {
|
||||
if item.Status == entity.StatusFailed {
|
||||
streak++
|
||||
continue
|
||||
}
|
||||
if item.Status == entity.StatusPublished {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) {
|
||||
if u.events == nil || item == nil {
|
||||
return
|
||||
}
|
||||
_ = u.events.Record(ctx, eventdomain.RecordRequest{
|
||||
TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID,
|
||||
EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func startOfUTCDay(ns int64) int64 {
|
||||
t := time.Unix(0, ns).UTC()
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano()
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requireActor(tenantID, ownerUID string) error {
|
||||
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
||||
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
||||
|
|
@ -448,16 +726,24 @@ func requireActor(tenantID, ownerUID string) error {
|
|||
|
||||
func toSummary(item entity.QueueItem) domusecase.QueueItemSummary {
|
||||
return domusecase.QueueItemSummary{
|
||||
ID: item.ID,
|
||||
AccountID: item.AccountID,
|
||||
Text: item.Text,
|
||||
ScheduledAt: item.ScheduledAt,
|
||||
Status: item.Status,
|
||||
MediaID: item.MediaID,
|
||||
Permalink: item.Permalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
CreateAt: item.CreateAt,
|
||||
UpdateAt: item.UpdateAt,
|
||||
ID: item.ID,
|
||||
AccountID: item.AccountID,
|
||||
PersonaID: item.PersonaID,
|
||||
CopyMissionID: item.CopyMissionID,
|
||||
CopyDraftID: item.CopyDraftID,
|
||||
Text: item.Text,
|
||||
ScheduledAt: item.ScheduledAt,
|
||||
Status: item.Status,
|
||||
MediaID: item.MediaID,
|
||||
Permalink: item.Permalink,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
RetryCount: item.RetryCount,
|
||||
LastAttemptAt: item.LastAttemptAt,
|
||||
NextAttemptAt: item.NextAttemptAt,
|
||||
MissedAt: item.MissedAt,
|
||||
PausedReason: item.PausedReason,
|
||||
CreateAt: item.CreateAt,
|
||||
UpdateAt: item.UpdateAt,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package entity
|
||||
|
||||
const CollectionName = "publish_queue_events"
|
||||
|
||||
const (
|
||||
EventCreated = "created"
|
||||
EventRetry = "retry"
|
||||
EventPublishing = "publishing"
|
||||
EventPublished = "published"
|
||||
EventFailed = "failed"
|
||||
EventCancelled = "cancelled"
|
||||
EventMissed = "missed"
|
||||
EventGuardDelayed = "guard_delayed"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `bson:"_id"`
|
||||
TenantID string `bson:"tenant_id"`
|
||||
OwnerUID string `bson:"owner_uid"`
|
||||
AccountID string `bson:"account_id"`
|
||||
QueueID string `bson:"queue_id"`
|
||||
EventType string `bson:"event_type"`
|
||||
FromStatus string `bson:"from_status,omitempty"`
|
||||
ToStatus string `bson:"to_status,omitempty"`
|
||||
Message string `bson:"message,omitempty"`
|
||||
CreateAt int64 `bson:"create_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"haixun-backend/internal/model/publish_queue_event/domain/entity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
EnsureIndexes(ctx context.Context) error
|
||||
Create(ctx context.Context, event *entity.Event) error
|
||||
ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error)
|
||||
ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error)
|
||||
DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue