巡樓 Console
+把找題、寫文、排程、追成效串成一條線。
+diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4609ae1 --- /dev/null +++ b/.dockerignore @@ -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/** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdb34d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f677a7e --- /dev/null +++ b/Makefile @@ -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 ## 後端測試與前端建置 diff --git a/backend/Makefile b/backend/Makefile index de32c17..56c15b3 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 89132d7..96879cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/docs/threads-automation-gap-plan.md b/backend/docs/threads-automation-gap-plan.md new file mode 100644 index 0000000..23cd0b9 --- /dev/null +++ b/backend/docs/threads-automation-gap-plan.md @@ -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 語意。 diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api index 9e8ab69..b95fdb4 100644 --- a/backend/generate/api/copy_mission.api +++ b/backend/generate/api/copy_mission.api @@ -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) diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index 4b364a1..ca30be7 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -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) } \ No newline at end of file diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index 1cc04d0..b0ebba3 100644 --- a/backend/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -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( diff --git a/backend/go.mod b/backend/go.mod index 288e93a..4bae144 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 0f9b29a..6b31483 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go new file mode 100644 index 0000000..054fd6d --- /dev/null +++ b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go deleted file mode 100644 index c46723d..0000000 --- a/backend/internal/handler/crm/delete_crm_contact_handler.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/crm/get_crm_contact_handler.go deleted file mode 100644 index d4df08c..0000000 --- a/backend/internal/handler/crm/get_crm_contact_handler.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/crm/list_crm_contacts_handler.go deleted file mode 100644 index 6d74542..0000000 --- a/backend/internal/handler/crm/list_crm_contacts_handler.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/crm/update_crm_contact_handler.go deleted file mode 100644 index 8d2746c..0000000 --- a/backend/internal/handler/crm/update_crm_contact_handler.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/handler/persona/delete_style_preset_handler.go b/backend/internal/handler/persona/delete_style_preset_handler.go new file mode 100644 index 0000000..c7e0873 --- /dev/null +++ b/backend/internal/handler/persona/delete_style_preset_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/persona/list_style_presets_handler.go similarity index 64% rename from backend/internal/handler/crm/create_crm_contact_handler.go rename to backend/internal/handler/persona/list_style_presets_handler.go index 52d1aed..34cfa79 100644 --- a/backend/internal/handler/crm/create_crm_contact_handler.go +++ b/backend/internal/handler/persona/list_style_presets_handler.go @@ -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) } } diff --git a/backend/internal/handler/persona/schedule_persona_drafts_handler.go b/backend/internal/handler/persona/schedule_persona_drafts_handler.go new file mode 100644 index 0000000..a8e9471 --- /dev/null +++ b/backend/internal/handler/persona/schedule_persona_drafts_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/persona/upsert_style_preset_handler.go similarity index 61% rename from backend/internal/handler/scan_post/lookalike_handler.go rename to backend/internal/handler/persona/upsert_style_preset_handler.go index 5123c62..cfc28d6 100644 --- a/backend/internal/handler/scan_post/lookalike_handler.go +++ b/backend/internal/handler/persona/upsert_style_preset_handler.go @@ -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) } } diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 9991560..7ee7e7c 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -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"), diff --git a/backend/internal/handler/threads_account/delete_threads_account_handler.go b/backend/internal/handler/threads_account/delete_threads_account_handler.go new file mode 100644 index 0000000..9029e7f --- /dev/null +++ b/backend/internal/handler/threads_account/delete_threads_account_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go new file mode 100644 index 0000000..cc261ed --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go new file mode 100644 index 0000000..0f2171f --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go new file mode 100644 index 0000000..6b0e387 --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go new file mode 100644 index 0000000..d1d21ad --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go new file mode 100644 index 0000000..1053001 --- /dev/null +++ b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_alerts_handler.go b/backend/internal/handler/threads_account/list_publish_alerts_handler.go new file mode 100644 index 0000000..793552b --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_alerts_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_events_handler.go b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go new file mode 100644 index 0000000..7aed7d9 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_range_handler.go b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go new file mode 100644 index 0000000..0c910b4 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/resume_publish_guard_handler.go b/backend/internal/handler/threads_account/resume_publish_guard_handler.go new file mode 100644 index 0000000..67e2dd3 --- /dev/null +++ b/backend/internal/handler/threads_account/resume_publish_guard_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go new file mode 100644 index 0000000..c56893c --- /dev/null +++ b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go new file mode 100644 index 0000000..639f209 --- /dev/null +++ b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go new file mode 100644 index 0000000..95eeb81 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go new file mode 100644 index 0000000..9e55ad4 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go @@ -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) + } +} diff --git a/backend/internal/library/permmatch/match_test.go b/backend/internal/library/permmatch/match_test.go index 64dbcbd..3bfbf18 100644 --- a/backend/internal/library/permmatch/match_test.go +++ b/backend/internal/library/permmatch/match_test.go @@ -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) { diff --git a/backend/internal/library/publishschedule/slots.go b/backend/internal/library/publishschedule/slots.go new file mode 100644 index 0000000..611582b --- /dev/null +++ b/backend/internal/library/publishschedule/slots.go @@ -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 +} diff --git a/backend/internal/library/publishschedule/slots_test.go b/backend/internal/library/publishschedule/slots_test.go new file mode 100644 index 0000000..5cc8d29 --- /dev/null +++ b/backend/internal/library/publishschedule/slots_test.go @@ -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])) + } +} diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go index 8812738..e791184 100644 --- a/backend/internal/logic/copy_mission/mapper.go +++ b/backend/internal/logic/copy_mission/mapper.go @@ -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, diff --git a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go new file mode 100644 index 0000000..e0d9fcc --- /dev/null +++ b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go @@ -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, + } +} diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go deleted file mode 100644 index ff363fc..0000000 --- a/backend/internal/logic/crm/actor.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go deleted file mode 100644 index 6cc276b..0000000 --- a/backend/internal/logic/crm/create_crm_contact_logic.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go deleted file mode 100644 index 669ebcf..0000000 --- a/backend/internal/logic/crm/delete_crm_contact_logic.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go deleted file mode 100644 index 943f6c6..0000000 --- a/backend/internal/logic/crm/get_crm_contact_logic.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go deleted file mode 100644 index ce16203..0000000 --- a/backend/internal/logic/crm/list_crm_contacts_logic.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go deleted file mode 100644 index 48a9738..0000000 --- a/backend/internal/logic/crm/mapper.go +++ /dev/null @@ -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), - }, - } -} diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go deleted file mode 100644 index 853c5e4..0000000 --- a/backend/internal/logic/crm/update_crm_contact_logic.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/logic/persona/delete_style_preset_logic.go b/backend/internal/logic/persona/delete_style_preset_logic.go new file mode 100644 index 0000000..0327d5b --- /dev/null +++ b/backend/internal/logic/persona/delete_style_preset_logic.go @@ -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) +} diff --git a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go index fbfdf12..eb35709 100644 --- a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go @@ -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 diff --git a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go index de35a46..3288e38 100644 --- a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go +++ b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go @@ -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 diff --git a/backend/internal/logic/persona/list_style_presets_logic.go b/backend/internal/logic/persona/list_style_presets_logic.go new file mode 100644 index 0000000..c190192 --- /dev/null +++ b/backend/internal/logic/persona/list_style_presets_logic.go @@ -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 +} diff --git a/backend/internal/logic/persona/mapper.go b/backend/internal/logic/persona/mapper.go index 0f852e7..3d3c6dc 100644 --- a/backend/internal/logic/persona/mapper.go +++ b/backend/internal/logic/persona/mapper.go @@ -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, + } +} diff --git a/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/backend/internal/logic/persona/schedule_persona_drafts_logic.go new file mode 100644 index 0000000..2f00096 --- /dev/null +++ b/backend/internal/logic/persona/schedule_persona_drafts_logic.go @@ -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, + } +} diff --git a/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go index 07c78cb..cbe3f2b 100644 --- a/backend/internal/logic/persona/update_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/update_persona_copy_draft_logic.go @@ -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, diff --git a/backend/internal/logic/persona/upsert_style_preset_logic.go b/backend/internal/logic/persona/upsert_style_preset_logic.go new file mode 100644 index 0000000..33ca67a --- /dev/null +++ b/backend/internal/logic/persona/upsert_style_preset_logic.go @@ -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 +} diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go deleted file mode 100644 index 510b778..0000000 --- a/backend/internal/logic/scan_post/lookalike_logic.go +++ /dev/null @@ -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)) -} diff --git a/backend/internal/logic/threads_account/delete_threads_account_logic.go b/backend/internal/logic/threads_account/delete_threads_account_logic.go new file mode 100644 index 0000000..296aec4 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_threads_account_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go new file mode 100644 index 0000000..da4d205 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go new file mode 100644 index 0000000..6e15f0b --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go new file mode 100644 index 0000000..cde2e9e --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go new file mode 100644 index 0000000..7b84ba3 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go new file mode 100644 index 0000000..c4a23d5 --- /dev/null +++ b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/list_publish_alerts_logic.go b/backend/internal/logic/threads_account/list_publish_alerts_logic.go new file mode 100644 index 0000000..1d239c2 --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_alerts_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_events_logic.go b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go new file mode 100644 index 0000000..327998a --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_range_logic.go b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go new file mode 100644 index 0000000..db34dbc --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go index 4bd8606..9695d3d 100644 --- a/backend/internal/logic/threads_account/mapper.go +++ b/backend/internal/logic/threads_account/mapper.go @@ -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{}} diff --git a/backend/internal/logic/threads_account/resume_publish_guard_logic.go b/backend/internal/logic/threads_account/resume_publish_guard_logic.go new file mode 100644 index 0000000..8a52f9d --- /dev/null +++ b/backend/internal/logic/threads_account/resume_publish_guard_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go new file mode 100644 index 0000000..bd0b8e1 --- /dev/null +++ b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go new file mode 100644 index 0000000..dcda058 --- /dev/null +++ b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go new file mode 100644 index 0000000..8eb83ad --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go @@ -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 +} diff --git a/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go new file mode 100644 index 0000000..a46c585 --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go @@ -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 +} diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index 75e6eb8..a192603 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -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"` diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index 386601f..2aac7e9 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -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 diff --git a/backend/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go index 55f6392..e376d71 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -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, diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 1b45e59..739647b 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -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) diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 8145ff3..73af24e 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -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, diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go index 8fa0cd8..ad73322 100644 --- a/backend/internal/model/permission/usecase/usecase.go +++ b/backend/internal/model/permission/usecase/usecase.go @@ -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}, diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go index dcd2ced..b54dd32 100644 --- a/backend/internal/model/publish_analytics/domain/repository/repository.go +++ b/backend/internal/model/publish_analytics/domain/repository/repository.go @@ -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) } diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go index 6a5862d..29d78ae 100644 --- a/backend/internal/model/publish_analytics/repository/mongo.go +++ b/backend/internal/model/publish_analytics/repository/mongo.go @@ -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 +} diff --git a/backend/internal/model/publish_draft/domain/entity/draft.go b/backend/internal/model/publish_draft/domain/entity/draft.go index 7ace8d6..c3071f8 100644 --- a/backend/internal/model/publish_draft/domain/entity/draft.go +++ b/backend/internal/model/publish_draft/domain/entity/draft.go @@ -12,4 +12,4 @@ type Draft struct { Tags []string `bson:"tags,omitempty"` CreateAt int64 `bson:"create_at"` UpdateAt int64 `bson:"update_at"` -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_draft/domain/repository/repository.go b/backend/internal/model/publish_draft/domain/repository/repository.go index 61a8f49..0011df8 100644 --- a/backend/internal/model/publish_draft/domain/repository/repository.go +++ b/backend/internal/model/publish_draft/domain/repository/repository.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_draft/domain/usecase/usecase.go b/backend/internal/model/publish_draft/domain/usecase/usecase.go index c0db6bc..e62e87d 100644 --- a/backend/internal/model/publish_draft/domain/usecase/usecase.go +++ b/backend/internal/model/publish_draft/domain/usecase/usecase.go @@ -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 -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_guard/domain/entity/policy.go b/backend/internal/model/publish_guard/domain/entity/policy.go new file mode 100644 index 0000000..5e703e6 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/entity/policy.go @@ -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"` +} diff --git a/backend/internal/model/publish_guard/domain/repository/repository.go b/backend/internal/model/publish_guard/domain/repository/repository.go new file mode 100644 index 0000000..a958381 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/repository/repository.go @@ -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 +} diff --git a/backend/internal/model/publish_guard/domain/usecase/usecase.go b/backend/internal/model/publish_guard/domain/usecase/usecase.go new file mode 100644 index 0000000..4ec89b7 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/usecase/usecase.go @@ -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) +} diff --git a/backend/internal/model/publish_guard/repository/mongo.go b/backend/internal/model/publish_guard/repository/mongo.go new file mode 100644 index 0000000..dcc262e --- /dev/null +++ b/backend/internal/model/publish_guard/repository/mongo.go @@ -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), + } +} diff --git a/backend/internal/model/publish_guard/usecase/usecase.go b/backend/internal/model/publish_guard/usecase/usecase.go new file mode 100644 index 0000000..e7777b6 --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase.go @@ -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 "" +} diff --git a/backend/internal/model/publish_guard/usecase/usecase_test.go b/backend/internal/model/publish_guard/usecase/usecase_test.go new file mode 100644 index 0000000..6c829ad --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase_test.go @@ -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, + } +} diff --git a/backend/internal/model/publish_inventory/domain/entity/policy.go b/backend/internal/model/publish_inventory/domain/entity/policy.go new file mode 100644 index 0000000..053d97e --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/entity/policy.go @@ -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"` +} diff --git a/backend/internal/model/publish_inventory/domain/repository/repository.go b/backend/internal/model/publish_inventory/domain/repository/repository.go new file mode 100644 index 0000000..e55a79b --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/repository/repository.go @@ -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 +} diff --git a/backend/internal/model/publish_inventory/domain/usecase/usecase.go b/backend/internal/model/publish_inventory/domain/usecase/usecase.go new file mode 100644 index 0000000..68525d3 --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/usecase/usecase.go @@ -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) +} diff --git a/backend/internal/model/publish_inventory/repository/mongo.go b/backend/internal/model/publish_inventory/repository/mongo.go new file mode 100644 index 0000000..3edcef1 --- /dev/null +++ b/backend/internal/model/publish_inventory/repository/mongo.go @@ -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), + } +} diff --git a/backend/internal/model/publish_inventory/usecase/usecase.go b/backend/internal/model/publish_inventory/usecase/usecase.go new file mode 100644 index 0000000..497c412 --- /dev/null +++ b/backend/internal/model/publish_inventory/usecase/usecase.go @@ -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 +} diff --git a/backend/internal/model/publish_queue/domain/entity/queue_item.go b/backend/internal/model/publish_queue/domain/entity/queue_item.go index 4791f42..a648a15 100644 --- a/backend/internal/model/publish_queue/domain/entity/queue_item.go +++ b/backend/internal/model/publish_queue/domain/entity/queue_item.go @@ -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"` } diff --git a/backend/internal/model/publish_queue/domain/repository/repository.go b/backend/internal/model/publish_queue/domain/repository/repository.go index 6333ff1..8f23c1b 100644 --- a/backend/internal/model/publish_queue/domain/repository/repository.go +++ b/backend/internal/model/publish_queue/domain/repository/repository.go @@ -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) } diff --git a/backend/internal/model/publish_queue/domain/usecase/usecase.go b/backend/internal/model/publish_queue/domain/usecase/usecase.go index 66fb5bf..1565cce 100644 --- a/backend/internal/model/publish_queue/domain/usecase/usecase.go +++ b/backend/internal/model/publish_queue/domain/usecase/usecase.go @@ -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) } diff --git a/backend/internal/model/publish_queue/repository/mongo.go b/backend/internal/model/publish_queue/repository/mongo.go index ad5aa51..dc3f056 100644 --- a/backend/internal/model/publish_queue/repository/mongo.go +++ b/backend/internal/model/publish_queue/repository/mongo.go @@ -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 +} diff --git a/backend/internal/model/publish_queue/usecase/usecase.go b/backend/internal/model/publish_queue/usecase/usecase.go index 4281f11..6139158 100644 --- a/backend/internal/model/publish_queue/usecase/usecase.go +++ b/backend/internal/model/publish_queue/usecase/usecase.go @@ -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, } } diff --git a/backend/internal/model/publish_queue_event/domain/entity/event.go b/backend/internal/model/publish_queue_event/domain/entity/event.go new file mode 100644 index 0000000..9a8c938 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/entity/event.go @@ -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"` +} diff --git a/backend/internal/model/publish_queue_event/domain/repository/repository.go b/backend/internal/model/publish_queue_event/domain/repository/repository.go new file mode 100644 index 0000000..ec04c8d --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/repository/repository.go @@ -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 +} diff --git a/backend/internal/model/publish_queue_event/domain/usecase/usecase.go b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go new file mode 100644 index 0000000..704e271 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go @@ -0,0 +1,31 @@ +package usecase + +import "context" + +type EventSummary struct { + ID string + QueueID string + AccountID string + EventType string + FromStatus string + ToStatus string + Message string + CreateAt int64 +} + +type RecordRequest struct { + TenantID string + OwnerUID string + AccountID string + QueueID string + EventType string + FromStatus string + ToStatus string + Message string +} + +type UseCase interface { + Record(ctx context.Context, req RecordRequest) error + ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]EventSummary, error) + ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]EventSummary, error) +} diff --git a/backend/internal/model/publish_queue_event/repository/mongo.go b/backend/internal/model/publish_queue_event/repository/mongo.go new file mode 100644 index 0000000..310513a --- /dev/null +++ b/backend/internal/model/publish_queue_event/repository/mongo.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/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}, {Key: "queue_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, event *entity.Event) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if event == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("event is required") + } + _, err := r.collection.InsertOne(ctx, event) + return err +} + +func (r *mongoRepository) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) { + filter := actorFilter(tenantID, ownerUID, accountID) + filter["queue_id"] = strings.TrimSpace(queueID) + return r.list(ctx, filter, limit) +} + +func (r *mongoRepository) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) { + return r.list(ctx, actorFilter(tenantID, ownerUID, accountID), limit) +} + +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.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func (r *mongoRepository) list(ctx context.Context, filter bson.M, limit int) ([]entity.Event, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + opts := options.Find().SetSort(bson.D{{Key: "create_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 out []entity.Event + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + filter := bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } + if accountID = strings.TrimSpace(accountID); accountID != "" { + filter["account_id"] = accountID + } + return filter +} diff --git a/backend/internal/model/publish_queue_event/usecase/usecase.go b/backend/internal/model/publish_queue_event/usecase/usecase.go new file mode 100644 index 0000000..b109522 --- /dev/null +++ b/backend/internal/model/publish_queue_event/usecase/usecase.go @@ -0,0 +1,75 @@ +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_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" + domusecase "haixun-backend/internal/model/publish_queue_event/domain/usecase" + + "github.com/google/uuid" +) + +type eventUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &eventUseCase{repo: repo} +} + +func (u *eventUseCase) Record(ctx context.Context, req domusecase.RecordRequest) error { + if strings.TrimSpace(req.TenantID) == "" || strings.TrimSpace(req.OwnerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(req.AccountID) == "" || strings.TrimSpace(req.QueueID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id and queue_id are required") + } + eventType := strings.TrimSpace(req.EventType) + if eventType == "" { + eventType = entity.EventCreated + } + return u.repo.Create(ctx, &entity.Event{ + ID: uuid.NewString(), + TenantID: strings.TrimSpace(req.TenantID), + OwnerUID: strings.TrimSpace(req.OwnerUID), + AccountID: strings.TrimSpace(req.AccountID), + QueueID: strings.TrimSpace(req.QueueID), + EventType: eventType, + FromStatus: strings.TrimSpace(req.FromStatus), + ToStatus: strings.TrimSpace(req.ToStatus), + Message: strings.TrimSpace(req.Message), + CreateAt: clock.NowUnixNano(), + }) +} + +func (u *eventUseCase) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListByQueue(ctx, tenantID, ownerUID, accountID, queueID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func (u *eventUseCase) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListRecentByAccount(ctx, tenantID, ownerUID, accountID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func toSummaries(items []entity.Event) []domusecase.EventSummary { + out := make([]domusecase.EventSummary, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.EventSummary{ + ID: item.ID, QueueID: item.QueueID, AccountID: item.AccountID, EventType: item.EventType, + FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt, + }) + } + return out +} diff --git a/backend/internal/model/style_preset/domain/entity/preset.go b/backend/internal/model/style_preset/domain/entity/preset.go new file mode 100644 index 0000000..43fc367 --- /dev/null +++ b/backend/internal/model/style_preset/domain/entity/preset.go @@ -0,0 +1,17 @@ +package entity + +const CollectionName = "style_presets" + +type Preset struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Name string `bson:"name"` + Tone string `bson:"tone,omitempty"` + CTA []string `bson:"cta,omitempty"` + BannedWords []string `bson:"banned_words,omitempty"` + Notes string `bson:"notes,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/style_preset/domain/repository/repository.go b/backend/internal/model/style_preset/domain/repository/repository.go new file mode 100644 index 0000000..7cb0dcd --- /dev/null +++ b/backend/internal/model/style_preset/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/style_preset/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) + Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) + Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/domain/usecase/usecase.go b/backend/internal/model/style_preset/domain/usecase/usecase.go new file mode 100644 index 0000000..7486c20 --- /dev/null +++ b/backend/internal/model/style_preset/domain/usecase/usecase.go @@ -0,0 +1,34 @@ +package usecase + +import "context" + +type PresetSummary struct { + ID string + PersonaID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + CreateAt int64 + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + PersonaID string + PresetID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + Apply bool +} + +type UseCase interface { + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]PresetSummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PresetSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/repository/mongo.go b/backend/internal/model/style_preset/repository/mongo.go new file mode 100644 index 0000000..63385a6 --- /dev/null +++ b/backend/internal/model/style_preset/repository/mongo.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/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: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Preset + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + var out entity.Preset + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("style preset not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if preset == nil { + return nil, app.For(code.Persona).InputMissingRequired("preset is required") + } + filter := personaFilter(preset.TenantID, preset.OwnerUID, preset.PersonaID) + filter["_id"] = preset.ID + _, err := r.collection.ReplaceOne(ctx, filter, preset, options.Replace().SetUpsert(true)) + if err != nil { + return nil, err + } + return preset, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.Persona).ResNotFound("style preset not found") + } + return nil +} + +func personaFilter(tenantID, ownerUID, personaID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "persona_id": strings.TrimSpace(personaID), + } +} diff --git a/backend/internal/model/style_preset/usecase/usecase.go b/backend/internal/model/style_preset/usecase/usecase.go new file mode 100644 index 0000000..94a7893 --- /dev/null +++ b/backend/internal/model/style_preset/usecase/usecase.go @@ -0,0 +1,135 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/domain/repository" + domusecase "haixun-backend/internal/model/style_preset/domain/usecase" + + "github.com/google/uuid" +) + +type stylePresetUseCase struct { + repo domrepo.Repository + persona personadomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, persona personadomain.UseCase) domusecase.UseCase { + return &stylePresetUseCase{repo: repo, persona: persona} +} + +func (u *stylePresetUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]domusecase.PresetSummary, error) { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return nil, err + } + items, err := u.repo.List(ctx, tenantID, ownerUID, personaID) + if err != nil { + return nil, err + } + out := make([]domusecase.PresetSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func (u *stylePresetUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PresetSummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, app.For(code.Persona).InputMissingRequired("name is required") + } + now := clock.NowUnixNano() + id := strings.TrimSpace(req.PresetID) + item := &entity.Preset{ID: id} + if id == "" || id == "new" { + item.ID = uuid.NewString() + item.CreateAt = now + } else if existing, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.PersonaID, id); err == nil { + item = existing + } + item.TenantID = req.TenantID + item.OwnerUID = req.OwnerUID + item.PersonaID = req.PersonaID + item.Name = name + item.Tone = strings.TrimSpace(req.Tone) + item.CTA = cleanList(req.CTA) + item.BannedWords = cleanList(req.BannedWords) + item.Notes = strings.TrimSpace(req.Notes) + item.UpdateAt = now + if item.CreateAt == 0 { + item.CreateAt = now + } + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + if req.Apply && u.persona != nil { + profile := formatPresetProfile(saved) + _, _ = u.persona.Update(ctx, personadomain.UpdateRequest{ + TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, + Patch: personadomain.PersonaPatch{StyleProfile: &profile}, + }) + } + out := toSummary(*saved) + return &out, nil +} + +func (u *stylePresetUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return err + } + return u.repo.Delete(ctx, tenantID, ownerUID, personaID, presetID) +} + +func (u *stylePresetUseCase) require(ctx context.Context, tenantID, ownerUID, personaID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(personaID) == "" { + return app.For(code.Persona).InputMissingRequired("persona_id is required") + } + if u.persona != nil { + _, err := u.persona.Get(ctx, tenantID, ownerUID, personaID) + return err + } + return nil +} + +func toSummary(item entity.Preset) domusecase.PresetSummary { + return domusecase.PresetSummary{ + 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, + } +} + +func cleanList(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func formatPresetProfile(item *entity.Preset) string { + return fmt.Sprintf("語調:%s\nCTA:%s\n禁用詞:%s\n備註:%s", item.Tone, strings.Join(item.CTA, "、"), strings.Join(item.BannedWords, "、"), item.Notes) +} diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index 9337973..b50ca8a 100644 --- a/backend/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -17,6 +17,7 @@ type Repository interface { UpdateAPIProfile(ctx context.Context, tenantID, ownerUID, accountID, username, threadsUserID, displayName string) (*entity.Account, error) ClearAPIProfile(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error } @@ -26,6 +27,7 @@ type SecretsRepository interface { SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) ClearAPIAccessToken(ctx context.Context, accountID string) error + DeleteByAccountID(ctx context.Context, accountID string) error ListAPIAccessTokensExpiringBefore(ctx context.Context, beforeNs int64) ([]*entity.Secrets, error) } diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 870dfaa..ca21737 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -259,6 +259,7 @@ type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error) Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error) UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error) ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error) diff --git a/backend/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go index 79ca588..9336b67 100644 --- a/backend/internal/model/threads_account/repository/mongo.go +++ b/backend/internal/model/threads_account/repository/mongo.go @@ -205,6 +205,23 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, ac return nil } +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.DeleteOne( + ctx, + bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID}, + ) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return nil +} + func (r *mongoRepository) SetKnownAccounts( ctx context.Context, tenantID, ownerUID, accountID string, diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index 63b6f31..dc527a9 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -199,3 +199,11 @@ func (r *secretsMongoRepository) ClearAPIAccessToken(ctx context.Context, accoun } return nil } + +func (r *secretsMongoRepository) DeleteByAccountID(ctx context.Context, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": accountID}) + return err +} diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index e64af0e..29ec525 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -14,6 +14,11 @@ import ( libthreads "haixun-backend/internal/library/threadsapi" memberdomain "haixun-backend/internal/model/member/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" + analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + guardrepo "haixun-backend/internal/model/publish_guard/domain/repository" + inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + queuerepo "haixun-backend/internal/model/publish_queue/domain/repository" + eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" settingdomain "haixun-backend/internal/model/setting/domain/usecase" "haixun-backend/internal/model/threads_account/domain/entity" domrepo "haixun-backend/internal/model/threads_account/domain/repository" @@ -37,6 +42,11 @@ type threadsAccountUseCase struct { members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase + queueRepo queuerepo.Repository + analytics analyticsrepo.Repository + inventory inventoryrepo.Repository + guard guardrepo.Repository + queueEvents eventrepo.Repository cipher *crypto.Cipher oauth libthreads.OAuthConfig } @@ -48,6 +58,11 @@ func NewUseCase( members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, + queueRepo queuerepo.Repository, + analytics analyticsrepo.Repository, + inventory inventoryrepo.Repository, + guard guardrepo.Repository, + queueEvents eventrepo.Repository, cipher *crypto.Cipher, oauth libthreads.OAuthConfig, ) domusecase.UseCase { @@ -58,6 +73,11 @@ func NewUseCase( members: members, settings: settings, personas: personas, + queueRepo: queueRepo, + analytics: analytics, + inventory: inventory, + guard: guard, + queueEvents: queueEvents, cipher: cipher, oauth: oauth, } @@ -159,6 +179,59 @@ func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID) } +func (u *threadsAccountUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + mediaIDs := []string{} + if u.queueRepo != nil { + if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil { + mediaIDs = ids + } + } + if u.analytics != nil { + _, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs) + } + if u.queueEvents != nil { + _ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.queueRepo != nil { + _, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.inventory != nil { + _ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.guard != nil { + _ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.secretsRepo != nil { + _ = u.secretsRepo.DeleteByAccountID(ctx, accountID) + } + if u.settings != nil { + settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200) + if err == nil { + for _, item := range settings { + if item == nil { + continue + } + _ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key) + } + } + } + if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return err + } + nextActiveID := "" + if len(remaining) > 0 && remaining[0] != nil { + nextActiveID = remaining[0].ID + } + return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID) +} + func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index 4d15a7a..5ea9766 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -58,14 +58,26 @@ import ( publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase" + publishguarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + publishguardrepo "haixun-backend/internal/model/publish_guard/repository" + publishguardusecase "haixun-backend/internal/model/publish_guard/usecase" + publishinventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository" + publishinventoryusecase "haixun-backend/internal/model/publish_inventory/usecase" publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase" publishqueuerepo "haixun-backend/internal/model/publish_queue/repository" publishqueueusecase "haixun-backend/internal/model/publish_queue/usecase" + publishqueueeventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" + publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository" + publishqueueeventusecase "haixun-backend/internal/model/publish_queue_event/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" settingrepo "haixun-backend/internal/model/setting/repository" settingusecase "haixun-backend/internal/model/setting/usecase" + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" + stylepresetrepo "haixun-backend/internal/model/style_preset/repository" + stylepresetusecase "haixun-backend/internal/model/style_preset/usecase" threadsaccountrepodomain "haixun-backend/internal/model/threads_account/domain/repository" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" @@ -82,26 +94,30 @@ type ServiceContext struct { Mongo *libmongo.Client Redis *goredis.Client - Setting settingusecase.UseCase - AI aisettings.UseCase - Job jobusecase.UseCase - AuthToken authdomain.TokenUseCase - Member memberdomain.UseCase - Permission permissiondomain.UseCase - Persona personadomain.UseCase - CopyMission copymissiondomain.UseCase - Brand branddomain.UseCase - PlacementTopic placementtopicdomain.UseCase - KnowledgeGraph kgdomain.UseCase - Placement placementusecase.UseCase - ScanPost scanpostdomain.UseCase - OutreachDraft outreachdraftdomain.UseCase - ContentMatrix cmatrixdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - CrmContact crmcontactdomain.UseCase - PublishAnalytics publishanalyticsdomain.UseCase - PublishQueue publishqueuedomain.UseCase + Setting settingusecase.UseCase + AI aisettings.UseCase + Job jobusecase.UseCase + AuthToken authdomain.TokenUseCase + Member memberdomain.UseCase + Permission permissiondomain.UseCase + Persona personadomain.UseCase + CopyMission copymissiondomain.UseCase + Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase + KnowledgeGraph kgdomain.UseCase + Placement placementusecase.UseCase + ScanPost scanpostdomain.UseCase + OutreachDraft outreachdraftdomain.UseCase + ContentMatrix cmatrixdomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + CrmContact crmcontactdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase + PublishQueue publishqueuedomain.UseCase + PublishInventory publishinventorydomain.UseCase + PublishGuard publishguarddomain.UseCase + PublishQueueEvent publishqueueeventdomain.UseCase + StylePreset stylepresetdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware @@ -214,6 +230,9 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureRefillPublishInventoryTemplate(ctx); err != nil { + panic(err) + } copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { @@ -274,6 +293,12 @@ func NewServiceContext(c config.Config) *ServiceContext { ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher) + publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) + publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database()) + publishInventoryRepository := publishinventoryrepo.NewMongoRepository(mongoClient.Database()) + publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database()) + publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database()) + stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database()) var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore if redisClient != nil { threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient) @@ -290,6 +315,24 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil { panic(err) } + if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishInventoryRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishGuardRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueEventRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := stylePresetRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } threadsAccountUseCase := threadsaccountusecase.NewUseCase( threadsAccountRepository, threadsAccountSecretsRepository, @@ -297,6 +340,11 @@ func NewServiceContext(c config.Config) *ServiceContext { memberUseCase, settingUseCase, personaUseCase, + publishQueueRepository, + publishAnalyticsRepository, + publishInventoryRepository, + publishGuardRepository, + publishQueueEventRepository, secretsCipher, threadsOAuthConfig, ) @@ -309,47 +357,50 @@ func NewServiceContext(c config.Config) *ServiceContext { } crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository) - publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) - if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { - panic(err) - } publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository, jobUseCase) + publishQueueEventUseCase := publishqueueeventusecase.NewUseCase(publishQueueEventRepository) + publishInventoryUseCase := publishinventoryusecase.NewUseCase(publishInventoryRepository, threadsAccountUseCase) + publishGuardUseCase := publishguardusecase.NewUseCase(publishGuardRepository, threadsAccountUseCase) + stylePresetUseCase := stylepresetusecase.NewUseCase(stylePresetRepository, personaUseCase) - publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database()) - if err := publishQueueRepository.EnsureIndexes(ctx); err != nil { - panic(err) - } publishQueueUseCase := publishqueueusecase.NewUseCase( publishQueueRepository, threadsAccountUseCase, publishAnalyticsUseCase, + publishGuardUseCase, + publishQueueEventUseCase, + copyDraftUseCase, ) sc := &ServiceContext{ - Config: c, - Validator: validate.New(), - Mongo: mongoClient, - Redis: redisClient, - Setting: settingUseCase, - AI: aisettings.NewUseCase(), - Job: jobUseCase, - AuthToken: authTokenUseCase, - Member: memberUseCase, - Permission: permissionUseCase, - Persona: personaUseCase, - CopyMission: copyMissionUseCase, - Brand: brandUseCase, - PlacementTopic: placementTopicUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - Placement: placementUseCase, - ScanPost: scanPostUseCase, - OutreachDraft: outreachDraftUseCase, - ContentMatrix: contentMatrixUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, - CrmContact: crmContactUseCase, - PublishAnalytics: publishAnalyticsUseCase, - PublishQueue: publishQueueUseCase, + Config: c, + Validator: validate.New(), + Mongo: mongoClient, + Redis: redisClient, + Setting: settingUseCase, + AI: aisettings.NewUseCase(), + Job: jobUseCase, + AuthToken: authTokenUseCase, + Member: memberUseCase, + Permission: permissionUseCase, + Persona: personaUseCase, + CopyMission: copyMissionUseCase, + Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + Placement: placementUseCase, + ScanPost: scanPostUseCase, + OutreachDraft: outreachDraftUseCase, + ContentMatrix: contentMatrixUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + CrmContact: crmContactUseCase, + PublishAnalytics: publishAnalyticsUseCase, + PublishQueue: publishQueueUseCase, + PublishInventory: publishInventoryUseCase, + PublishGuard: publishGuardUseCase, + PublishQueueEvent: publishQueueEventUseCase, + StylePreset: stylePresetUseCase, } hostname, _ := os.Hostname() @@ -438,6 +489,13 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, PublishAnalytics: publishAnalyticsUseCase, }) + jobworker.RegisterRefillPublishInventoryHandler(runner, jobworker.RefillPublishInventoryDeps{ + Jobs: jobUseCase, + PublishInventory: publishInventoryUseCase, + PublishQueue: publishQueueUseCase, + CopyMission: copyMissionUseCase, + CopyDraft: copyDraftUseCase, + }) go runner.Start(workerCtx) } diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index c5e4b1a..8a06643 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -216,6 +216,7 @@ type CopyDraftData struct { 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"` @@ -445,6 +446,12 @@ type DeletePlacementTopicScanPostHandlerReq struct { PostID string `path:"postId" validate:"required"` } +type DeleteThreadsAccountData struct { + DeletedID string `json:"deleted_id"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Message string `json:"message"` +} + type ErrorDetail struct { BizCode string `json:"biz_code,optional"` Scope int64 `json:"scope,optional"` @@ -857,6 +864,10 @@ type ListPublishQueueQuery struct { PageSize int `form:"pageSize,optional"` } +type ListStylePresetsData struct { + List []StylePresetData `json:"list"` +} + type ListThreadsAccountAiProviderModelsReq struct { ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key } @@ -1083,6 +1094,19 @@ type PlacementTopicPath struct { ID string `path:"id" validate:"required"` } +type PublishAlertData struct { + 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"` +} + +type PublishAlertsData struct { + List []PublishAlertData `json:"list"` +} + type PublishCopyDraftData struct { DraftID string `json:"draft_id"` MediaID string `json:"media_id"` @@ -1101,6 +1125,51 @@ type PublishCopyDraftReq struct { Confirm bool `json:"confirm"` } +type PublishDashboardAccountSummary struct { + 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"` +} + +type PublishDashboardSummaryData struct { + 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"` +} + +type PublishGuardPolicyData struct { + 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"` +} + +type PublishInventoryPolicyData struct { + 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"` +} + type PublishOutreachDraftData struct { ScanPostID string `json:"scan_post_id"` ReplyID string `json:"reply_id"` @@ -1126,18 +1195,40 @@ type PublishPlacementTopicOutreachDraftHandlerReq struct { PublishOutreachDraftReq } +type PublishQueueEventData struct { + 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"` +} + +type PublishQueueEventsData struct { + List []PublishQueueEventData `json:"list"` +} + type PublishQueueItemData struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Text string `json:"text"` - ScheduledAt int64 `json:"scheduled_at"` - Status string `json:"status"` - MediaID string `json:"media_id,optional"` - Permalink string `json:"permalink,optional"` - PublishedAt int64 `json:"published_at,optional"` - ErrorMessage string `json:"error_message,optional"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + 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"` + MediaID string `json:"media_id,optional"` + 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"` } type PublishQueueItemPath struct { @@ -1150,6 +1241,49 @@ type PublishQueueListData struct { List []PublishQueueItemData `json:"list"` } +type PublishQueueRangeHandlerReq struct { + ThreadsAccountPath + PublishQueueRangeQuery +} + +type PublishQueueRangeQuery struct { + StartAt int64 `form:"startAt,optional"` + EndAt int64 `form:"endAt,optional"` + Status string `form:"status,optional"` +} + +type PublishSlotData struct { + Weekday int `json:"weekday"` + Time string `json:"time"` +} + +type PublishSlotInsightData struct { + 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"` +} + +type PublishSlotInsightsData struct { + AccountID string `json:"account_id"` + Timezone string `json:"timezone"` + Slots []PublishSlotInsightData `json:"slots"` +} + +type PublishSourceRefData struct { + 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"` +} + type ResearchItemData struct { Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` @@ -1229,6 +1363,31 @@ type ScanReplyData struct { PostedAt string `json:"posted_at,omitempty"` } +type ScheduleCopyDraftsData struct { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` +} + +type ScheduleCopyDraftsReq struct { + 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"` +} + +type ScheduleCopyMissionDraftsHandlerReq struct { + CopyMissionPath + ScheduleCopyDraftsReq +} + +type SchedulePersonaDraftsHandlerReq struct { + PersonaPath + ScheduleCopyDraftsReq +} + type SettingData struct { ID string `json:"id"` Scope string `json:"scope"` @@ -1362,6 +1521,21 @@ type StartPlacementTopicScanJobHandlerReq struct { StartBrandScanJobReq } +type StartPublishInventoryRefillJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Message string `json:"message"` +} + +type StartPublishInventoryRefillJobHandlerReq struct { + ThreadsAccountPath + StartPublishInventoryRefillJobReq +} + +type StartPublishInventoryRefillJobReq struct { + Count int `json:"count,optional"` +} + type StartThreadsOAuthData struct { AuthorizeURL string `json:"authorize_url"` State string `json:"state"` @@ -1392,6 +1566,23 @@ type StorePersonaStyleProfileReq struct { StyleBenchmark string `json:"style_benchmark,optional"` } +type StylePresetData struct { + 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"` +} + +type StylePresetPath struct { + ID string `path:"id" validate:"required"` + PresetID string `path:"presetId" validate:"required"` +} + type ThreadsAPIPlaygroundData struct { Action string `json:"action"` Ok bool `json:"ok"` @@ -1479,6 +1670,16 @@ type ThreadsAccountPath struct { ID string `path:"id" validate:"required"` } +type ThreadsDiagnosticsData struct { + 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"` +} + type ThreadsInsightMetricItem struct { Name string `json:"name"` Value int `json:"value"` @@ -1784,6 +1985,49 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct { UpsertBrandScanScheduleReq } +type UpsertPublishGuardPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishGuardPolicyReq +} + +type UpsertPublishGuardPolicyReq struct { + 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"` +} + +type UpsertPublishInventoryPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishInventoryPolicyReq +} + +type UpsertPublishInventoryPolicyReq struct { + 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"` +} + +type UpsertStylePresetHandlerReq struct { + StylePresetPath + UpsertStylePresetReq +} + +type UpsertStylePresetReq struct { + 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"` +} + type ViralScanPostData struct { ID string `json:"id"` SearchTag string `json:"search_tag"` diff --git a/backend/internal/worker/job/publish_queue_sweeper.go b/backend/internal/worker/job/publish_queue_sweeper.go index 3d3c0d2..746ccbb 100644 --- a/backend/internal/worker/job/publish_queue_sweeper.go +++ b/backend/internal/worker/job/publish_queue_sweeper.go @@ -37,6 +37,12 @@ func (s *PublishQueueSweeper) Start(ctx context.Context, interval time.Duration) log.Printf("publish queue sweeper stopped") return case <-ticker.C: + missed, err := s.queue.MarkMissed(ctx, int64(10*time.Minute), s.batchSize) + if err != nil { + log.Printf("publish queue missed sweep error: %v", err) + } else if missed > 0 { + log.Printf("publish queue marked %d missed item(s)", missed) + } dispatched, err := s.queue.DispatchDue(ctx, s.batchSize) if err != nil { log.Printf("publish queue sweeper tick error: %v", err) diff --git a/backend/internal/worker/job/refill_publish_inventory.go b/backend/internal/worker/job/refill_publish_inventory.go new file mode 100644 index 0000000..d80a16d --- /dev/null +++ b/backend/internal/worker/job/refill_publish_inventory.go @@ -0,0 +1,134 @@ +package job + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/publishschedule" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" +) + +type RefillPublishInventoryDeps struct { + Jobs jobdom.UseCase + PublishInventory inventorydomain.UseCase + PublishQueue pqdomain.UseCase + CopyMission missiondomain.UseCase + CopyDraft copydraftdomain.UseCase +} + +func RegisterRefillPublishInventoryHandler(runner *Runner, deps RefillPublishInventoryDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("refill_publish_inventory", func(ctx context.Context, step StepContext) error { + return runRefillPublishInventory(ctx, step, deps) + }) +} + +func runRefillPublishInventory(ctx context.Context, step StepContext, deps RefillPublishInventoryDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + accountID := stringField(payload, "account_id") + if tenantID == "" || ownerUID == "" || accountID == "" { + return fmt.Errorf("refill publish inventory payload missing tenant_id, owner_uid, or account_id") + } + if deps.PublishInventory == nil || deps.PublishQueue == nil { + return fmt.Errorf("publish inventory dependencies are not configured") + } + policy, err := deps.PublishInventory.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return err + } + if !policy.Enabled { + return nil + } + health, err := deps.PublishQueue.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 1) + if err != nil { + return err + } + need := policy.LowStockThreshold - int(health.Summary.PendingScheduled) + if requested := intField(payload, "count"); requested > 0 { + need = requested + } + if need <= 0 { + return nil + } + if need > 12 { + need = 12 + } + slots := make([]publishschedule.Slot, 0, len(policy.Slots)) + for _, slot := range policy.Slots { + slots = append(slots, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + times := publishschedule.BuildSchedule(0, policy.Timezone, slots, need) + for i := 0; i < need; i++ { + if err := step.Heartbeat(ctx); err != nil { + return err + } + ref := pickSourceRef(policy.SourceRefs, i) + text, personaID, missionID := refillText(ctx, deps, tenantID, ownerUID, ref, i) + createReq := pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, + PersonaID: personaID, CopyMissionID: missionID, Text: text, + } + if i < len(times) { + createReq.ScheduledAt = times[i] + } + if deps.CopyDraft != nil && personaID != "" { + draft, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, + CopyMissionID: missionID, DraftType: "inventory-refill", SortOrder: i + 1, + Text: text, Rationale: "自動補庫存產生", + }) + if err == nil && draft != nil { + createReq.CopyDraftID = draft.ID + } + } + item, err := deps.PublishQueue.Create(ctx, createReq) + if err != nil { + return err + } + if deps.CopyDraft != nil && createReq.CopyDraftID != "" { + _, _ = deps.CopyDraft.MarkScheduled(ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, DraftID: createReq.CopyDraftID, QueueID: item.ID, + }) + } + } + return nil +} + +func pickSourceRef(items []inventorydomain.SourceRef, idx int) inventorydomain.SourceRef { + if len(items) == 0 { + return inventorydomain.SourceRef{Type: "manual_seed", ManualSeed: "分享一個今天巡樓觀察到的重點"} + } + return items[idx%len(items)] +} + +func refillText(ctx context.Context, deps RefillPublishInventoryDeps, tenantID, ownerUID string, ref inventorydomain.SourceRef, idx int) (string, string, string) { + personaID := strings.TrimSpace(ref.PersonaID) + missionID := strings.TrimSpace(ref.CopyMissionID) + seed := strings.TrimSpace(ref.ManualSeed) + if missionID != "" && personaID != "" && deps.CopyMission != nil { + if mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); err == nil { + seed = strings.TrimSpace(firstNonEmpty(mission.Brief, mission.Label, mission.SeedQuery)) + } + } + if seed == "" { + seed = "分享一個今天巡樓觀察到的重點" + } + return fmt.Sprintf("%s\n\n巡樓筆記 #%d", seed, idx+1), personaID, missionID +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/web/index.html b/backend/web/index.html new file mode 100644 index 0000000..212d61b --- /dev/null +++ b/backend/web/index.html @@ -0,0 +1,26 @@ + + +
+ + +正在確認 Threads 帳號狀態...
++ 請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。 +
+ +把找題、寫文、排程、追成效串成一條線。
+{subtitle}
: null} +{message}
; +} + +export function CopyableId({ value }: { value: string }) { + return ( + + ); +} diff --git a/backend/web/src/index.css b/backend/web/src/index.css new file mode 100644 index 0000000..14bcbab --- /dev/null +++ b/backend/web/src/index.css @@ -0,0 +1,523 @@ +@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css"; +@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css"; + +:root { + color-scheme: light; + --font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-en: "Inter", system-ui, sans-serif; + --hx-canvas: #dfe8f1; + --hx-grass: #dcebdc; + --hx-surface: #fffdf8; + --hx-surface-muted: #f4f8f6; + --hx-ink: #182235; + --hx-ink-secondary: #344156; + --hx-muted: #5a6578; + --hx-subtle: #758198; + --hx-line: #d7dfeb; + --hx-brand: #2a9d8f; + --hx-brand-hover: #238b7e; + --hx-brand-soft: #e5f5f2; + --hx-glow: #b6e6dc; + --hx-success: #1f7a4d; + --hx-success-soft: #e9f8ef; + --hx-warning: #8a6418; + --hx-warning-soft: #fff6dd; + --hx-danger: #b73b32; + --hx-danger-soft: #fdebea; + --radius-sm: 0.75rem; + --radius-md: 1.25rem; + --radius-lg: 1.75rem; + --radius-xl: 2.25rem; + --radius-pill: 9999px; + --shadow-card: 0 18px 45px rgba(24, 34, 53, 0.08); + --shadow-soft: 0 16px 38px rgba(42, 157, 143, 0.18); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --hx-canvas: #172033; + --hx-grass: #132c2a; + --hx-surface: #202a3d; + --hx-surface-muted: #26334a; + --hx-ink: #f2f6fb; + --hx-ink-secondary: #d8e0ec; + --hx-muted: #b8c4d6; + --hx-subtle: #93a2b7; + --hx-line: #3a465b; + --hx-brand: #58c7b8; + --hx-brand-hover: #75d9cc; + --hx-brand-soft: rgba(88, 199, 184, 0.16); + --hx-glow: #245d59; + --hx-success: #78d99b; + --hx-success-soft: rgba(120, 217, 155, 0.14); + --hx-warning: #f0c66e; + --hx-warning-soft: rgba(240, 198, 110, 0.14); + --hx-danger: #ff938b; + --hx-danger-soft: rgba(255, 147, 139, 0.14); + --shadow-card: 0 18px 45px rgba(0, 0, 0, 0.28); + --shadow-soft: 0 16px 38px rgba(0, 0, 0, 0.24); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + color: var(--hx-ink); + background: var(--hx-canvas); + line-height: 1.7; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; +} + +a { + color: inherit; +} + +.display-en { + font-family: var(--font-en); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hx-scene { + min-height: 100vh; + background: + radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.55), transparent 26rem), + radial-gradient(circle at 85% 20%, var(--hx-glow), transparent 24rem), + linear-gradient(180deg, var(--hx-canvas) 0%, var(--hx-canvas) 52%, var(--hx-grass) 100%); + position: relative; + overflow: hidden; +} + +.hx-scene-deco { + position: fixed; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.auth-cloud { + position: absolute; + width: 9rem; + height: 3rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.55); + filter: blur(0.2px); +} + +.auth-cloud::before, +.auth-cloud::after { + content: ""; + position: absolute; + bottom: 0.55rem; + border-radius: 50%; + background: inherit; +} + +.auth-cloud::before { + left: 1.25rem; + width: 3rem; + height: 3rem; +} + +.auth-cloud::after { + right: 1.5rem; + width: 4rem; + height: 4rem; +} + +.auth-cloud--one { + top: 8%; + left: 7%; +} + +.auth-cloud--two { + top: 18%; + right: 10%; + transform: scale(0.75); +} + +.auth-cloud--three { + bottom: 16%; + left: 18%; + transform: scale(0.6); +} + +.auth-ticket { + background: color-mix(in srgb, var(--hx-surface) 94%, white); + border: 2px solid rgba(255, 255, 255, 0.72); + border-radius: 2rem; + box-shadow: var(--shadow-card); +} + +.auth-welcome { + display: flex; + gap: 0.85rem; + align-items: center; + padding-bottom: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--hx-line); +} + +.auth-ticket-icon { + width: 3rem; + height: 3rem; + display: grid; + place-items: center; + color: var(--hx-brand); + background: var(--hx-brand-soft); + border-radius: 1.2rem; + flex: 0 0 auto; +} + +.layout-main { + position: relative; + z-index: 1; + min-height: 100vh; + padding-bottom: 6rem; +} + +.ac-app-header { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem clamp(1rem, 3vw, 2rem); + background: color-mix(in srgb, var(--hx-surface) 86%, transparent); + border-bottom: 1px solid var(--hx-line); + backdrop-filter: blur(14px); +} + +.ac-app-shell { + display: grid; + grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); + gap: 1.25rem; + width: min(1440px, 100%); + margin: 0 auto; + padding: 1.25rem clamp(1rem, 3vw, 2rem) 2rem; +} + +.ac-pocket-device { + position: sticky; + top: 5.5rem; + align-self: start; + padding: 1rem; + border-radius: 2rem; + background: color-mix(in srgb, var(--hx-ink) 9%, var(--hx-surface)); + border: 1px solid color-mix(in srgb, var(--hx-line) 75%, transparent); + box-shadow: var(--shadow-card); +} + +.ac-pocket-screen { + border-radius: 1.5rem; + background: var(--hx-surface); + border: 1px solid var(--hx-line); + padding: 0.75rem; +} + +.ac-pocket-scroll { + display: grid; + gap: 0.5rem; +} + +.ac-app-tile { + width: 100%; + text-align: left; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 0.75rem; + color: var(--hx-ink-secondary); + background: transparent; + border-radius: 1rem; + cursor: pointer; +} + +.ac-app-tile:hover, +.ac-app-tile--active { + color: var(--hx-brand); + background: var(--hx-brand-soft); +} + +.ac-app-tile--locked { + opacity: 0.62; +} + +.ac-app-main-inner { + min-width: 0; + padding: clamp(1rem, 2.2vw, 1.5rem); +} + +.ac-title-bar { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.75rem; + color: white; + border-radius: var(--radius-pill); + background: linear-gradient(135deg, var(--hx-brand), var(--hx-brand-hover)); + box-shadow: var(--shadow-soft); +} + +.page-grid { + display: grid; + gap: 1rem; +} + +.grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.card { + background: var(--hx-surface); + border: 1px solid var(--hx-line); + border-radius: var(--radius-lg); + padding: 1rem; + box-shadow: var(--shadow-card); +} + +.field { + display: grid; + gap: 0.35rem; +} + +.field label { + color: var(--hx-ink-secondary); + font-size: 0.9rem; + font-weight: 700; +} + +.input, +.textarea, +.select { + width: 100%; + min-height: 2.7rem; + padding: 0.65rem 0.8rem; + color: var(--hx-ink); + background: var(--hx-surface); + border: 1px solid var(--hx-line); + border-radius: var(--radius-md); + font-size: 15px; +} + +.textarea { + min-height: 7rem; + resize: vertical; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; +} + +.ac-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.55rem; + padding: 0.65rem 1rem; + border-radius: var(--radius-pill); + font-weight: 800; + cursor: pointer; +} + +.ac-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.ac-btn-primary { + color: white; + background: var(--hx-brand); + box-shadow: var(--shadow-soft); +} + +.ac-btn-primary:hover { + background: var(--hx-brand-hover); +} + +.ac-btn-secondary, +.ac-btn-ghost, +.ac-btn-soft { + color: var(--hx-ink-secondary); + background: var(--hx-surface-muted); + border: 1px solid var(--hx-line); +} + +.ac-btn-danger { + color: var(--hx-danger); + background: var(--hx-danger-soft); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.22rem 0.55rem; + border-radius: var(--radius-pill); + font-size: 0.78rem; + font-weight: 800; +} + +.badge-brand, +.badge-sky { + color: var(--hx-brand); + background: var(--hx-brand-soft); +} + +.badge-success { + color: var(--hx-success); + background: var(--hx-success-soft); +} + +.badge-warning { + color: var(--hx-warning); + background: var(--hx-warning-soft); +} + +.badge-danger { + color: var(--hx-danger); + background: var(--hx-danger-soft); +} + +.badge-neutral { + color: var(--hx-ink-secondary); + background: var(--hx-surface-muted); +} + +.muted { + color: var(--hx-muted); +} + +.subtle { + color: var(--hx-subtle); +} + +.error-text { + color: var(--hx-danger); + font-weight: 700; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 44rem; +} + +th, +td { + text-align: left; + padding: 0.65rem; + border-bottom: 1px solid var(--hx-line); + vertical-align: top; +} + +th { + color: var(--hx-ink-secondary); + font-size: 0.85rem; +} + +.ac-dock { + position: fixed; + left: 50%; + bottom: calc(0.75rem + env(safe-area-inset-bottom)); + z-index: 30; + display: none; + width: min(26rem, calc(100% - 1rem)); + transform: translateX(-50%); + padding: 0.45rem; + background: color-mix(in srgb, var(--hx-surface) 92%, transparent); + border: 1px solid var(--hx-line); + border-radius: 1.5rem; + box-shadow: var(--shadow-card); + backdrop-filter: blur(14px); +} + +.ac-dock button { + flex: 1; + display: grid; + gap: 0.1rem; + place-items: center; + padding: 0.45rem; + border-radius: 1rem; + color: var(--hx-muted); + background: transparent; +} + +.ac-dock button.active { + color: var(--hx-brand); + background: var(--hx-brand-soft); +} + +.ac-dock button.locked { + opacity: 0.58; +} + +.auth-page { + position: relative; + z-index: 1; + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.25rem; +} + +.auth-shell-form { + width: min(34rem, 100%); + padding: 1.25rem; +} + +@media (max-width: 1023px) { + .ac-app-shell { + display: block; + padding: 1rem; + } + + .ac-pocket-device { + display: none; + } + + .ac-dock { + display: flex; + } + + .grid-2, + .grid-3 { + grid-template-columns: 1fr; + } +} diff --git a/backend/web/src/lib/acAssets.ts b/backend/web/src/lib/acAssets.ts new file mode 100644 index 0000000..ade0922 --- /dev/null +++ b/backend/web/src/lib/acAssets.ts @@ -0,0 +1,24 @@ +import type { AcIcon } from "../components/AcIcon"; + +type IconName = Parameters{message}+ API {account.api_connected ? "已連線" : "未連線"},token 到期 {formatNano(account.api_token_expires_at)} +
+還沒有 Threads 帳號。
: null} +使用 Meta Threads API 發文與讀成效,避免模擬登入風險。
+請先選擇帳號。
+ )} +需要 scopes:{diagnostics.scopes.join("、")}
+| 項目 | +Scope | +狀態 | +訊息 | +
|---|---|---|---|
| {item.name} | +{item.scope} | +{item.message} | +
選擇帳號後會顯示診斷結果。
+ )} +請先建立帳號。
+ )} ++ 近 7 天發布 {summary?.total_published_7d ?? health?.summary.published_7d ?? 0} 篇,累積 {summary?.total_likes_7d ?? health?.summary.total_likes_7d ?? 0} 讚、{summary?.total_replies_7d ?? health?.summary.total_replies_7d ?? 0} 則回覆。 +
+目前沒有任務。
: null} +| 帳號 | +待發 | +7D 發布 | +互動 | +推薦時段 | +狀態 | +
|---|---|---|---|---|---|
| {item.account_name} | +{item.pending_scheduled} | +{item.published_7d} | +讚 {item.total_likes_7d} / 回覆 {item.total_replies_7d} | +{item.best_slot || "資料不足"} | +
還沒有帳號 summary。
: null} +還沒有足夠的發文成效資料。
: null} +還沒有可追蹤的發文。
: null} +等待 publish analytics checkpoints 回填後會顯示趨勢。
; + } + const width = 420; + const height = 150; + const max = Math.max(...points.map((point) => point.value), 1); + const path = points + .map((point, index) => { + const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; + const y = height - (point.value / max) * (height - 24) - 12; + return `${index === 0 ? "M" : "L"}${x},${y}`; + }) + .join(" "); + + return ( +{item.desc}
++ 這版前端先把現有後端能力串成可操作 Console。下一輪後端再補「自動補庫存」與「智慧時段」, + 才會從一般排程工具升級成真正的 Threads 自動營運系統。 +
+| 任務 | +狀態 | +進度 | +更新 | +操作 | +
|---|---|---|---|---|
|
+ {job.template_type}
+ {job.id}
+ |
+ {job.progress?.percentage ?? 0}% {job.progress?.summary ? `- ${job.progress.summary}` : ""} | +{formatNano(job.update_at)} | +
+
+ {["running", "queued", "pending"].includes(job.status) ? : null}
+ {["failed", "cancelled"].includes(job.status) ? : null}
+
+ |
+
目前沒有任務。
: null} +| 模板 | +Cron | +時區 | +狀態 | +下一次 | +
|---|---|---|---|---|
| {schedule.template_type} | +{schedule.cron} | +{schedule.timezone} | +{formatNano(schedule.next_run_at)} | +
目前沒有週期排程。
: null} +{message}{selected.seed_query}
+已選 {selectedDraftIds.length} 篇草稿。
+ +{JSON.stringify(selected.research_map || {}, null, 2)}
+ 請選擇任務。
+ )} +{draft.text}
+{draft.rationale}
+尚未生成草稿。
: null} +{message}請先建立品牌。
+ )} +| 作者 / 內容 | +分數 | +狀態 | +操作 | +
|---|---|---|---|
|
+ {post.author}
+ {shortText(post.text, 150)} + {post.permalink ? 查看原文 : null} + |
+
+ |
+ + |
尚未有掃描結果。
: null} +{message}
{selected.style_profile} : null}
+ 請先建立或選擇人設。
+ )} +{shortText(preset.tone || preset.notes || "尚未填寫口吻", 90)}
+已選 {selectedDraftIds.length} 篇;會套用預設推薦時段排入 queue。
+ +目前沒有草稿,可從文案任務或 viral scan 產生。
: null} +{message}
時段:{inventory.slots?.map((slot) => `${slot.weekday}/${slot.time}`).join("、") || "尚未設定"}
+ +正在讀取設定...
} +正在讀取護欄...
} +目前沒有告警。
: null} +| 內容 | +時間 | +狀態 | +操作 | +
|---|---|---|---|
|
+ {shortText(item.text, 96)}
+ {item.error_message ? {item.error_message} : null}
+ {item.permalink ? : null}
+ |
+ {formatNano(item.scheduled_at)} | +
+
+ {item.status === "scheduled" ? : null}
+ {item.status === "scheduled" ? : null}
+ {item.status === "failed" || item.status === "cancelled" || item.status === "missed" ? : null}
+
+ {item.status !== "published" && item.status !== "publishing" ? : null}
+
+ |
+
目前沒有發文庫存。
: null} +未來一週沒有排程。
: null} +點選庫存項目的「事件」查看狀態轉移。
: null} + {selectedEventItem && events.length === 0 ?這個項目還沒有事件紀錄。
: null} +{shortText(item.text || "", 160)}
+輸入關鍵字後可抓 TOP 熱門貼文當靈感。
: null} +{reply.text}
+這篇目前沒有留言。
: null} +尚無 API 成效資料。
: null} +{message}
{JSON.stringify(member, null, 2)}
+ {JSON.stringify(permissions, null, 2)}
+