fix swagger

This commit is contained in:
王性驊 2026-06-30 17:10:23 +08:00
parent 5e9c8c915d
commit 1c85024f6b
150 changed files with 10970 additions and 703 deletions

13
.dockerignore Normal file
View File

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

26
.gitignore vendored Normal file
View File

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

38
Makefile Normal file
View File

@ -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 ## 後端測試與前端建置

View File

@ -1,4 +1,4 @@
.PHONY: gen-api fmt test run
.PHONY: gen-api fmt test run web-dev web-build
API_FILE := generate/api/gateway.api
GOCTL_HOME := generate/goctl
@ -14,4 +14,10 @@ test:
go test ./...
run:
go run gateway.go -f etc/gateway.yaml
go run gateway.go -f etc/gateway.yaml
web-dev:
cd web && npm install && npm run dev
web-build:
cd web && npm install && npm run build

View File

@ -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 :5173proxy 到 :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/ # 正式巡樓 ConsoleVite + 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

View File

@ -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 會標記 missedfailed/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 與 timezonetimezone 僅用於 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 語意。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,4 +12,4 @@ type Draft struct {
Tags []string `bson:"tags,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
package repository
import (
"context"
"haixun-backend/internal/model/publish_queue_event/domain/entity"
)
type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, event *entity.Event) error
ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error)
ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error)
DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
}

Some files were not shown because too many files have changed in this diff Show More