From 1c85024f6b9ea78f0c451b8c96b1fafc1472d6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Tue, 30 Jun 2026 17:10:23 +0800 Subject: [PATCH] fix swagger --- .dockerignore | 13 + .gitignore | 26 + Makefile | 38 + backend/Makefile | 10 +- backend/README.md | 15 + backend/docs/threads-automation-gap-plan.md | 133 ++ backend/generate/api/copy_mission.api | 23 + backend/generate/api/persona.api | 53 + backend/generate/api/threads_account.api | 225 +++ backend/go.mod | 2 +- backend/go.sum | 2 + .../schedule_copy_mission_drafts_handler.go | 32 + .../handler/crm/delete_crm_contact_handler.go | 25 - .../handler/crm/get_crm_contact_handler.go | 25 - .../handler/crm/list_crm_contacts_handler.go | 25 - .../handler/crm/update_crm_contact_handler.go | 25 - .../persona/delete_style_preset_handler.go | 32 + .../list_style_presets_handler.go} | 19 +- .../schedule_persona_drafts_handler.go | 32 + .../upsert_style_preset_handler.go} | 19 +- backend/internal/handler/routes.go | 95 + .../delete_threads_account_handler.go | 33 + .../get_publish_dashboard_summary_handler.go | 20 + .../get_publish_guard_policy_handler.go | 32 + .../get_publish_inventory_policy_handler.go | 32 + .../get_publish_slot_insights_handler.go | 32 + .../get_threads_diagnostics_handler.go | 32 + .../list_publish_alerts_handler.go | 32 + .../list_publish_queue_events_handler.go | 32 + .../list_publish_queue_range_handler.go | 32 + .../resume_publish_guard_handler.go | 32 + .../retry_publish_queue_item_handler.go | 32 + ...rt_publish_inventory_refill_job_handler.go | 32 + .../upsert_publish_guard_policy_handler.go | 32 + ...upsert_publish_inventory_policy_handler.go | 32 + .../internal/library/permmatch/match_test.go | 11 +- .../internal/library/publishschedule/slots.go | 88 + .../library/publishschedule/slots_test.go | 40 + backend/internal/logic/copy_mission/mapper.go | 1 + .../schedule_copy_mission_drafts_logic.go | 99 + backend/internal/logic/crm/actor.go | 17 - .../logic/crm/create_crm_contact_logic.go | 46 - .../logic/crm/delete_crm_contact_logic.go | 29 - .../logic/crm/get_crm_contact_logic.go | 34 - .../logic/crm/list_crm_contacts_logic.go | 54 - backend/internal/logic/crm/mapper.go | 48 - .../logic/crm/update_crm_contact_logic.go | 42 - .../persona/delete_style_preset_logic.go | 35 + .../generate_persona_copy_draft_logic.go | 32 +- .../persona/list_persona_copy_drafts_logic.go | 32 +- .../logic/persona/list_style_presets_logic.go | 43 + backend/internal/logic/persona/mapper.go | 9 + .../persona/schedule_persona_drafts_logic.go | 94 + .../update_persona_copy_draft_logic.go | 1 + .../persona/upsert_style_preset_logic.go | 44 + .../logic/scan_post/lookalike_logic.go | 126 -- .../delete_threads_account_logic.go | 36 + .../get_publish_dashboard_summary_logic.go | 72 + .../get_publish_guard_policy_logic.go | 39 + .../get_publish_inventory_policy_logic.go | 39 + .../get_publish_slot_insights_logic.go | 53 + .../get_threads_diagnostics_logic.go | 55 + .../list_publish_alerts_logic.go | 46 + .../list_publish_queue_events_logic.go | 39 + .../list_publish_queue_range_logic.go | 43 + .../internal/logic/threads_account/mapper.go | 85 +- .../resume_publish_guard_logic.go | 39 + .../retry_publish_queue_item_logic.go | 39 + ...tart_publish_inventory_refill_job_logic.go | 61 + .../upsert_publish_guard_policy_logic.go | 45 + .../upsert_publish_inventory_policy_logic.go | 55 + .../model/copy_draft/domain/entity/draft.go | 1 + .../copy_draft/domain/usecase/usecase.go | 10 + .../model/copy_draft/usecase/usecase.go | 26 +- .../internal/model/job/domain/usecase/job.go | 1 + backend/internal/model/job/usecase/usecase.go | 32 + .../model/permission/usecase/usecase.go | 40 +- .../domain/repository/repository.go | 1 + .../publish_analytics/repository/mongo.go | 33 +- .../publish_draft/domain/entity/draft.go | 2 +- .../domain/repository/repository.go | 2 +- .../publish_draft/domain/usecase/usecase.go | 2 +- .../publish_guard/domain/entity/policy.go | 18 + .../domain/repository/repository.go | 14 + .../publish_guard/domain/usecase/usecase.go | 51 + .../model/publish_guard/repository/mongo.go | 83 + .../model/publish_guard/usecase/usecase.go | 171 ++ .../publish_guard/usecase/usecase_test.go | 100 + .../publish_inventory/domain/entity/policy.go | 32 + .../domain/repository/repository.go | 15 + .../domain/usecase/usecase.go | 47 + .../publish_inventory/repository/mongo.go | 102 + .../publish_inventory/usecase/usecase.go | 174 ++ .../publish_queue/domain/entity/queue_item.go | 35 +- .../domain/repository/repository.go | 7 + .../publish_queue/domain/usecase/usecase.go | 98 +- .../model/publish_queue/repository/mongo.go | 108 + .../model/publish_queue/usecase/usecase.go | 352 +++- .../domain/entity/event.go | 27 + .../domain/repository/repository.go | 15 + .../domain/usecase/usecase.go | 31 + .../publish_queue_event/repository/mongo.go | 99 + .../publish_queue_event/usecase/usecase.go | 75 + .../style_preset/domain/entity/preset.go | 17 + .../domain/repository/repository.go | 15 + .../style_preset/domain/usecase/usecase.go | 34 + .../model/style_preset/repository/mongo.go | 109 + .../model/style_preset/usecase/usecase.go | 135 ++ .../domain/repository/repository.go | 2 + .../threads_account/domain/usecase/usecase.go | 1 + .../model/threads_account/repository/mongo.go | 17 + .../repository/secrets_mongo.go | 8 + .../model/threads_account/usecase/usecase.go | 73 + backend/internal/svc/service_context.go | 162 +- backend/internal/types/types.go | 266 ++- .../worker/job/publish_queue_sweeper.go | 6 + .../worker/job/refill_publish_inventory.go | 134 ++ backend/web/index.html | 26 + backend/web/package-lock.json | 1757 +++++++++++++++++ backend/web/package.json | 23 + backend/web/src/App.tsx | 130 ++ backend/web/src/api/client.ts | 135 ++ backend/web/src/api/haixun.ts | 479 +++++ backend/web/src/auth/AuthContext.tsx | 108 + backend/web/src/components/AcIcon.tsx | 32 + backend/web/src/components/AuthDecor.tsx | 22 + backend/web/src/components/AuthShell.tsx | 25 + backend/web/src/components/Layout.tsx | 175 ++ backend/web/src/components/ThemeToggle.tsx | 11 + backend/web/src/components/ui.tsx | 103 + backend/web/src/index.css | 523 +++++ backend/web/src/lib/acAssets.ts | 24 + backend/web/src/lib/format.ts | 30 + backend/web/src/lib/jobStatus.ts | 41 + backend/web/src/main.tsx | 16 + backend/web/src/pages/AccountsPage.tsx | 360 ++++ backend/web/src/pages/AuthPage.tsx | 96 + backend/web/src/pages/DashboardPage.tsx | 343 ++++ backend/web/src/pages/GapsPage.tsx | 62 + backend/web/src/pages/JobsPage.tsx | 109 + backend/web/src/pages/MissionsPage.tsx | 205 ++ backend/web/src/pages/PatrolPage.tsx | 154 ++ backend/web/src/pages/PersonasPage.tsx | 246 +++ backend/web/src/pages/PublishPage.tsx | 498 +++++ backend/web/src/pages/SettingsPage.tsx | 76 + backend/web/src/theme/ThemeContext.tsx | 46 + backend/web/tsconfig.app.json | 21 + backend/web/tsconfig.json | 7 + backend/web/tsconfig.node.json | 14 + backend/web/vite.config.ts | 16 + 150 files changed, 10970 insertions(+), 703 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 backend/docs/threads-automation-gap-plan.md create mode 100644 backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go delete mode 100644 backend/internal/handler/crm/delete_crm_contact_handler.go delete mode 100644 backend/internal/handler/crm/get_crm_contact_handler.go delete mode 100644 backend/internal/handler/crm/list_crm_contacts_handler.go delete mode 100644 backend/internal/handler/crm/update_crm_contact_handler.go create mode 100644 backend/internal/handler/persona/delete_style_preset_handler.go rename backend/internal/handler/{crm/create_crm_contact_handler.go => persona/list_style_presets_handler.go} (64%) create mode 100644 backend/internal/handler/persona/schedule_persona_drafts_handler.go rename backend/internal/handler/{scan_post/lookalike_handler.go => persona/upsert_style_preset_handler.go} (61%) create mode 100644 backend/internal/handler/threads_account/delete_threads_account_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_guard_policy_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go create mode 100644 backend/internal/handler/threads_account/get_publish_slot_insights_handler.go create mode 100644 backend/internal/handler/threads_account/get_threads_diagnostics_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_alerts_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_queue_events_handler.go create mode 100644 backend/internal/handler/threads_account/list_publish_queue_range_handler.go create mode 100644 backend/internal/handler/threads_account/resume_publish_guard_handler.go create mode 100644 backend/internal/handler/threads_account/retry_publish_queue_item_handler.go create mode 100644 backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go create mode 100644 backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go create mode 100644 backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go create mode 100644 backend/internal/library/publishschedule/slots.go create mode 100644 backend/internal/library/publishschedule/slots_test.go create mode 100644 backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go delete mode 100644 backend/internal/logic/crm/actor.go delete mode 100644 backend/internal/logic/crm/create_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/delete_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/get_crm_contact_logic.go delete mode 100644 backend/internal/logic/crm/list_crm_contacts_logic.go delete mode 100644 backend/internal/logic/crm/mapper.go delete mode 100644 backend/internal/logic/crm/update_crm_contact_logic.go create mode 100644 backend/internal/logic/persona/delete_style_preset_logic.go create mode 100644 backend/internal/logic/persona/list_style_presets_logic.go create mode 100644 backend/internal/logic/persona/schedule_persona_drafts_logic.go create mode 100644 backend/internal/logic/persona/upsert_style_preset_logic.go delete mode 100644 backend/internal/logic/scan_post/lookalike_logic.go create mode 100644 backend/internal/logic/threads_account/delete_threads_account_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_guard_policy_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go create mode 100644 backend/internal/logic/threads_account/get_publish_slot_insights_logic.go create mode 100644 backend/internal/logic/threads_account/get_threads_diagnostics_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_alerts_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_queue_events_logic.go create mode 100644 backend/internal/logic/threads_account/list_publish_queue_range_logic.go create mode 100644 backend/internal/logic/threads_account/resume_publish_guard_logic.go create mode 100644 backend/internal/logic/threads_account/retry_publish_queue_item_logic.go create mode 100644 backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go create mode 100644 backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go create mode 100644 backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go create mode 100644 backend/internal/model/publish_guard/domain/entity/policy.go create mode 100644 backend/internal/model/publish_guard/domain/repository/repository.go create mode 100644 backend/internal/model/publish_guard/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_guard/repository/mongo.go create mode 100644 backend/internal/model/publish_guard/usecase/usecase.go create mode 100644 backend/internal/model/publish_guard/usecase/usecase_test.go create mode 100644 backend/internal/model/publish_inventory/domain/entity/policy.go create mode 100644 backend/internal/model/publish_inventory/domain/repository/repository.go create mode 100644 backend/internal/model/publish_inventory/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_inventory/repository/mongo.go create mode 100644 backend/internal/model/publish_inventory/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue_event/domain/entity/event.go create mode 100644 backend/internal/model/publish_queue_event/domain/repository/repository.go create mode 100644 backend/internal/model/publish_queue_event/domain/usecase/usecase.go create mode 100644 backend/internal/model/publish_queue_event/repository/mongo.go create mode 100644 backend/internal/model/publish_queue_event/usecase/usecase.go create mode 100644 backend/internal/model/style_preset/domain/entity/preset.go create mode 100644 backend/internal/model/style_preset/domain/repository/repository.go create mode 100644 backend/internal/model/style_preset/domain/usecase/usecase.go create mode 100644 backend/internal/model/style_preset/repository/mongo.go create mode 100644 backend/internal/model/style_preset/usecase/usecase.go create mode 100644 backend/internal/worker/job/refill_publish_inventory.go create mode 100644 backend/web/index.html create mode 100644 backend/web/package-lock.json create mode 100644 backend/web/package.json create mode 100644 backend/web/src/App.tsx create mode 100644 backend/web/src/api/client.ts create mode 100644 backend/web/src/api/haixun.ts create mode 100644 backend/web/src/auth/AuthContext.tsx create mode 100644 backend/web/src/components/AcIcon.tsx create mode 100644 backend/web/src/components/AuthDecor.tsx create mode 100644 backend/web/src/components/AuthShell.tsx create mode 100644 backend/web/src/components/Layout.tsx create mode 100644 backend/web/src/components/ThemeToggle.tsx create mode 100644 backend/web/src/components/ui.tsx create mode 100644 backend/web/src/index.css create mode 100644 backend/web/src/lib/acAssets.ts create mode 100644 backend/web/src/lib/format.ts create mode 100644 backend/web/src/lib/jobStatus.ts create mode 100644 backend/web/src/main.tsx create mode 100644 backend/web/src/pages/AccountsPage.tsx create mode 100644 backend/web/src/pages/AuthPage.tsx create mode 100644 backend/web/src/pages/DashboardPage.tsx create mode 100644 backend/web/src/pages/GapsPage.tsx create mode 100644 backend/web/src/pages/JobsPage.tsx create mode 100644 backend/web/src/pages/MissionsPage.tsx create mode 100644 backend/web/src/pages/PatrolPage.tsx create mode 100644 backend/web/src/pages/PersonasPage.tsx create mode 100644 backend/web/src/pages/PublishPage.tsx create mode 100644 backend/web/src/pages/SettingsPage.tsx create mode 100644 backend/web/src/theme/ThemeContext.tsx create mode 100644 backend/web/tsconfig.app.json create mode 100644 backend/web/tsconfig.json create mode 100644 backend/web/tsconfig.node.json create mode 100644 backend/web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4609ae1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.run +.cursor +**/node_modules +backend/bin +backend/web/dist +backend/dev-console/dist +frontend/dist +**/*_test.go +**/.DS_Store +infra +*.md +!deploy/** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdb34d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# --- secrets / env --- +deploy/.env +deploy/.env.dev +infra/.env +*.env +!.env.example +!*.env.example + +# --- runtime / logs --- +.run/ +*.log + +# --- build artifacts --- +backend/bin/ +backend/web/dist/ +backend/dev-console/dist/ +frontend/dist/ +dist/ + +# --- dependencies --- +node_modules/ +**/node_modules/ + +# --- OS / editor --- +.DS_Store +.cursor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f677a7e --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +SHELL := /bin/bash + +BACKEND_DIR := backend +WEB_DIR := backend/web + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## 顯示可用指令 + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +.PHONY: gen-api +gen-api: ## 重新產生後端 API handler / logic / types + $(MAKE) -C $(BACKEND_DIR) gen-api + +.PHONY: fmt +fmt: ## 格式化後端 Go 程式 + $(MAKE) -C $(BACKEND_DIR) fmt + +.PHONY: test +test: ## 執行後端測試 + $(MAKE) -C $(BACKEND_DIR) test + +.PHONY: run +run: ## 啟動後端 API + $(MAKE) -C $(BACKEND_DIR) run + +.PHONY: web-dev +web-dev: ## 啟動正式前端 dev server + cd $(WEB_DIR) && npm install && npm run dev + +.PHONY: web-build +web-build: ## 建置正式前端 + cd $(WEB_DIR) && npm install && npm run build + +.PHONY: verify +verify: test web-build ## 後端測試與前端建置 diff --git a/backend/Makefile b/backend/Makefile index de32c17..56c15b3 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,4 +1,4 @@ -.PHONY: gen-api fmt test run +.PHONY: gen-api fmt test run web-dev web-build API_FILE := generate/api/gateway.api GOCTL_HOME := generate/goctl @@ -14,4 +14,10 @@ test: go test ./... run: - go run gateway.go -f etc/gateway.yaml \ No newline at end of file + go run gateway.go -f etc/gateway.yaml + +web-dev: + cd web && npm install && npm run dev + +web-build: + cd web && npm install && npm run build \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 89132d7..96879cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,6 +12,7 @@ - `auth`:native email/password 登入、JWT access/refresh token、logout revoke。 - `member`:目前登入會員的 profile 讀寫。 - `permission`:permission catalog 與目前會員權限查詢。 +- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。 暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。 @@ -23,6 +24,13 @@ go mod download make run ``` +正式前端: + +```bash +make web-dev # Vite dev server :5173,proxy 到 :8890 +make web-build # TypeScript + Vite build +``` + 預設服務: ```text @@ -72,6 +80,8 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高 haixun-backend/ gateway.go # go-zero server 入口 Makefile # gen-api / fmt / test / run + web/ # 正式巡樓 Console(Vite + React + TypeScript) + dev-console/ # 本機診斷用最小開發面板,不是產品 UI etc/ # runtime config generate/ api/ # goctl .api 定義 @@ -87,6 +97,11 @@ haixun-backend/ auth/ # JWT token issue/refresh/logout + Redis revoke store member/ # Native member profile + password hash permission/ # Permission catalog + role permission mapping + publish_queue/ # Threads 發文 queue、guarded transition、retry/missed + publish_inventory/ # 自動補庫存 policy + publish_guard/ # 發文頻率護欄與 pause/resume + publish_queue_event/ # Queue 狀態轉移與告警事件 + style_preset/ # 人設語調 preset、CTA、禁用詞 worker/ # 常駐背景 worker / scheduler / reaper library/ # 最小 runtime library response/ # 統一 JSON response envelope diff --git a/backend/docs/threads-automation-gap-plan.md b/backend/docs/threads-automation-gap-plan.md new file mode 100644 index 0000000..23cd0b9 --- /dev/null +++ b/backend/docs/threads-automation-gap-plan.md @@ -0,0 +1,133 @@ +# Threads 自動營運補功能計畫 + +## 目前狀態 + +P1/P2 已落地到後端 API、worker 與正式前端: + +- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。 +- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。 +- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。 +- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。 +- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。 +- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。 + +## 目標 + +把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。 + +## P1:自動補庫存 + +### 功能 + +- 每個 Threads account 可設定每日/每週目標篇數。 +- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。 +- 指定內容來源:persona、copy mission、brand scan posts、manual seed。 +- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。 + +### 後端草案 + +- 新增 model:`internal/model/publish_inventory/`。 +- 新增設定 entity: + - `account_id` + - `target_daily_count` + - `low_stock_threshold` + - `lookahead_days` + - `timezone` + - `slots` + - `source_refs` + - `enabled` +- 新增 job step:`refill_publish_inventory`。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy` + - `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy` + - `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs` + +## P1:智慧時段 + +### 功能 + +- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。 +- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。 +- 前端顯示「推薦時段」與「低成效時段」。 + +### 後端草案 + +- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-slot-insights` +- 回傳: + - `slots[]` + - `avg_likes_1h/24h/7d` + - `avg_replies_1h/24h/7d` + - `sample_size` + - `recommendation` + +## P1:草稿批量排程 + +### 功能 + +- 使用者在 copy drafts 中選多篇,一次排到未來 slots。 +- 支援根據智慧時段自動分配 `scheduled_at`。 +- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。 + +### 後端草案 + +- 補 API: + - `POST /api/v1/personas/:personaId/copy-drafts/schedule` + - `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule` +- request: + - `account_id` + - `draft_ids` + - `start_at` + - `timezone` + - `slots` + - `mode`: `manual` / `recommended` + +## P1:發文 retry / 告警 / 漏發偵測 + +### 功能 + +- failed queue item 可重試,保留失敗歷史。 +- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。 +- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。 + +### 後端草案 + +- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。 +- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。 +- 補 API: + - `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry` + - `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events` + - `GET /api/v1/threads-accounts/:accountId/publish-alerts` + +## P1:頻率護欄 + +### 功能 + +- 每帳號每日上限。 +- 最小發文間隔。 +- 連續失敗自動 pause。 +- 手動解除 pause。 + +### 後端草案 + +- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。 +- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。 +- 補 API: + - `GET /api/v1/threads-accounts/:accountId/publish-guard-policy` + - `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy` + - `POST /api/v1/threads-accounts/:accountId/publish-guard/resume` + +## P2 + +- 內容日曆:補 queue date range query,前端做週/月視圖。 +- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。 +- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。 +- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。 + +## 驗證策略 + +- 所有列表維持 `page/pageSize` 與 `pagination/list`。 +- 所有時間欄位寫入 unix nanoseconds。 +- queue/job 狀態轉移都使用 guarded update。 +- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。 diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api index 9e8ab69..b95fdb4 100644 --- a/backend/generate/api/copy_mission.api +++ b/backend/generate/api/copy_mission.api @@ -267,6 +267,26 @@ type ( Message string `json:"message"` } + ScheduleCopyDraftsReq { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` + } + + ScheduleCopyMissionDraftsHandlerReq { + CopyMissionPath + ScheduleCopyDraftsReq + } + + ScheduleCopyDraftsData { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` + } + CopyMissionInspirationSourceData { Query string `json:"query,omitempty"` Title string `json:"title,omitempty"` @@ -336,6 +356,9 @@ service gateway { @handler deleteCopyMissionMatrixDrafts post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData) + @handler scheduleCopyMissionDrafts + post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (ScheduleCopyDraftsData) + @handler getCopyMissionScanSchedule get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData) diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index 4b364a1..ca30be7 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -124,6 +124,7 @@ type ( ReferenceNotes string `json:"reference_notes,omitempty"` Sources []string `json:"sources,omitempty"` Status string `json:"status,omitempty"` + PublishQueueID string `json:"publish_queue_id,omitempty"` PublishedMediaID string `json:"published_media_id,omitempty"` PublishedPermalink string `json:"published_permalink,omitempty"` PublishedAt int64 `json:"published_at,omitempty"` @@ -184,6 +185,46 @@ type ( Message string `json:"message"` } + SchedulePersonaDraftsHandlerReq { + PersonaPath + ScheduleCopyDraftsReq + } + + StylePresetData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Tone string `json:"tone,omitempty"` + CTA []string `json:"cta,omitempty"` + BannedWords []string `json:"banned_words,omitempty"` + Notes string `json:"notes,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ListStylePresetsData { + List []StylePresetData `json:"list"` + } + + UpsertStylePresetReq { + Name string `json:"name" validate:"required"` + Tone string `json:"tone,optional"` + CTA []string `json:"cta,optional"` + BannedWords []string `json:"banned_words,optional"` + Notes string `json:"notes,optional"` + Apply bool `json:"apply,optional"` + } + + StylePresetPath { + ID string `path:"id" validate:"required"` + PresetID string `path:"presetId" validate:"required"` + } + + UpsertStylePresetHandlerReq { + StylePresetPath + UpsertStylePresetReq + } + DeleteCopyDraftData { DraftID string `json:"draft_id"` Message string `json:"message"` @@ -234,6 +275,18 @@ service gateway { @handler publishPersonaCopyDraft post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData) + @handler schedulePersonaDrafts + post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData) + @handler deletePersonaCopyDraft delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData) + + @handler listStylePresets + get /:id/style-presets (PersonaPath) returns (ListStylePresetsData) + + @handler upsertStylePreset + put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData) + + @handler deleteStylePreset + delete /:id/style-presets/:presetId (StylePresetPath) } \ No newline at end of file diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index 1cc04d0..b0ebba3 100644 --- a/backend/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -20,6 +20,12 @@ type ( ActiveAccountID string `json:"active_account_id"` } + DeleteThreadsAccountData { + DeletedID string `json:"deleted_id"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Message string `json:"message"` + } + CreateThreadsAccountReq { DisplayName string `json:"display_name,optional"` Activate *bool `json:"activate,optional"` @@ -310,6 +316,9 @@ type ( PublishQueueItemData { ID string `json:"id"` AccountID string `json:"account_id"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + CopyDraftID string `json:"copy_draft_id,omitempty"` Text string `json:"text"` ScheduledAt int64 `json:"scheduled_at"` Status string `json:"status"` @@ -317,6 +326,11 @@ type ( Permalink string `json:"permalink,optional"` PublishedAt int64 `json:"published_at,optional"` ErrorMessage string `json:"error_message,optional"` + RetryCount int `json:"retry_count,omitempty"` + LastAttemptAt int64 `json:"last_attempt_at,omitempty"` + NextAttemptAt int64 `json:"next_attempt_at,omitempty"` + MissedAt int64 `json:"missed_at,omitempty"` + PausedReason string `json:"paused_reason,omitempty"` CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` } @@ -373,6 +387,175 @@ type ( Pagination PaginationData `json:"pagination"` List []ThreadsPublishHealthItem `json:"list"` } + + PublishSlotData { + Weekday int `json:"weekday"` + Time string `json:"time"` + } + + PublishSourceRefData { + Type string `json:"type"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + ManualSeed string `json:"manual_seed,omitempty"` + } + + PublishInventoryPolicyData { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count"` + LowStockThreshold int `json:"low_stock_threshold"` + LookaheadDays int `json:"lookahead_days"` + Timezone string `json:"timezone"` + Slots []PublishSlotData `json:"slots"` + SourceRefs []PublishSourceRefData `json:"source_refs"` + UpdateAt int64 `json:"update_at"` + } + + UpsertPublishInventoryPolicyReq { + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count,optional"` + LowStockThreshold int `json:"low_stock_threshold,optional"` + LookaheadDays int `json:"lookahead_days,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + SourceRefs []PublishSourceRefData `json:"source_refs,optional"` + } + + UpsertPublishInventoryPolicyHandlerReq { + ThreadsAccountPath + UpsertPublishInventoryPolicyReq + } + + StartPublishInventoryRefillJobReq { + Count int `json:"count,optional"` + } + + StartPublishInventoryRefillJobHandlerReq { + ThreadsAccountPath + StartPublishInventoryRefillJobReq + } + + StartPublishInventoryRefillJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + Message string `json:"message"` + } + + PublishSlotInsightData { + Weekday int `json:"weekday"` + Time string `json:"time"` + AvgLikes1h int `json:"avg_likes_1h"` + AvgReplies1h int `json:"avg_replies_1h"` + AvgLikes24h int `json:"avg_likes_24h"` + AvgReplies24h int `json:"avg_replies_24h"` + AvgLikes7d int `json:"avg_likes_7d"` + AvgReplies7d int `json:"avg_replies_7d"` + SampleSize int `json:"sample_size"` + Recommendation string `json:"recommendation"` + } + + PublishSlotInsightsData { + AccountID string `json:"account_id"` + Timezone string `json:"timezone"` + Slots []PublishSlotInsightData `json:"slots"` + } + + PublishGuardPolicyData { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts"` + MinIntervalMinutes int `json:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"` + Paused bool `json:"paused"` + PausedReason string `json:"paused_reason,omitempty"` + UpdateAt int64 `json:"update_at"` + } + + UpsertPublishGuardPolicyReq { + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts,optional"` + MinIntervalMinutes int `json:"min_interval_minutes,optional"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"` + Paused *bool `json:"paused,optional"` + PausedReason string `json:"paused_reason,optional"` + } + + UpsertPublishGuardPolicyHandlerReq { + ThreadsAccountPath + UpsertPublishGuardPolicyReq + } + + PublishQueueEventData { + ID string `json:"id"` + QueueID string `json:"queue_id"` + EventType string `json:"event_type"` + FromStatus string `json:"from_status,omitempty"` + ToStatus string `json:"to_status,omitempty"` + Message string `json:"message,omitempty"` + CreateAt int64 `json:"create_at"` + } + + PublishQueueEventsData { + List []PublishQueueEventData `json:"list"` + } + + PublishAlertData { + Type string `json:"type"` + Severity string `json:"severity"` + Message string `json:"message"` + QueueID string `json:"queue_id,omitempty"` + AccountID string `json:"account_id"` + CreateAt int64 `json:"create_at"` + } + + PublishAlertsData { + List []PublishAlertData `json:"list"` + } + + PublishQueueRangeQuery { + StartAt int64 `form:"startAt,optional"` + EndAt int64 `form:"endAt,optional"` + Status string `form:"status,optional"` + } + + PublishQueueRangeHandlerReq { + ThreadsAccountPath + PublishQueueRangeQuery + } + + PublishDashboardAccountSummary { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + Paused bool `json:"paused"` + BestSlot string `json:"best_slot,omitempty"` + LowSlot string `json:"low_slot,omitempty"` + } + + PublishDashboardSummaryData { + List []PublishDashboardAccountSummary `json:"list"` + TotalPending int64 `json:"total_pending"` + TotalFailed int64 `json:"total_failed"` + TotalPublished7d int64 `json:"total_published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + } + + ThreadsDiagnosticsData { + AccountID string `json:"account_id"` + CheckedAt int64 `json:"checked_at"` + ApiConnected bool `json:"api_connected"` + TokenExpiresAt int64 `json:"token_expires_at,omitempty"` + Scopes []string `json:"scopes"` + Items []ThreadsAPISmokeTestItem `json:"items"` + Message string `json:"message"` + } ) @server( @@ -395,6 +578,9 @@ service gateway { @handler updateThreadsAccount patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData) + @handler deleteThreadsAccount + delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData) + @handler activateThreadsAccount post /:id/activate (ThreadsAccountPath) @@ -457,6 +643,45 @@ service gateway { @handler listThreadsPublishHealth get /:id/publish-health (ListThreadsPublishHealthHandlerReq) returns (ThreadsPublishHealthData) + + @handler getPublishInventoryPolicy + get /:id/publish-inventory-policy (ThreadsAccountPath) returns (PublishInventoryPolicyData) + + @handler upsertPublishInventoryPolicy + put /:id/publish-inventory-policy (UpsertPublishInventoryPolicyHandlerReq) returns (PublishInventoryPolicyData) + + @handler startPublishInventoryRefillJob + post /:id/publish-inventory/refill-jobs (StartPublishInventoryRefillJobHandlerReq) returns (StartPublishInventoryRefillJobData) + + @handler getPublishSlotInsights + get /:id/publish-slot-insights (ThreadsAccountPath) returns (PublishSlotInsightsData) + + @handler getPublishGuardPolicy + get /:id/publish-guard-policy (ThreadsAccountPath) returns (PublishGuardPolicyData) + + @handler upsertPublishGuardPolicy + put /:id/publish-guard-policy (UpsertPublishGuardPolicyHandlerReq) returns (PublishGuardPolicyData) + + @handler resumePublishGuard + post /:id/publish-guard/resume (ThreadsAccountPath) returns (PublishGuardPolicyData) + + @handler retryPublishQueueItem + post /:id/publish-queue/:qid/retry (PublishQueueItemPath) returns (PublishQueueItemData) + + @handler listPublishQueueEvents + get /:id/publish-queue/:qid/events (PublishQueueItemPath) returns (PublishQueueEventsData) + + @handler listPublishAlerts + get /:id/publish-alerts (ThreadsAccountPath) returns (PublishAlertsData) + + @handler listPublishQueueRange + get /:id/publish-calendar (PublishQueueRangeHandlerReq) returns (PublishQueueListData) + + @handler getThreadsDiagnostics + get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData) + + @handler getPublishDashboardSummary + get /publish-dashboard-summary returns (PublishDashboardSummaryData) } @server( diff --git a/backend/go.mod b/backend/go.mod index 288e93a..4bae144 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/go-playground/validator/v10 v10.27.0 + github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.14.0 @@ -27,7 +28,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect - github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang/snappy v1.0.0 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0f9b29a..6b31483 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go new file mode 100644 index 0000000..054fd6d --- /dev/null +++ b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package copy_mission + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/copy_mission" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScheduleCopyMissionDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx) + data, err := l.ScheduleCopyMissionDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go deleted file mode 100644 index c46723d..0000000 --- a/backend/internal/handler/crm/delete_crm_contact_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package crm - -import ( - "net/http" - - "haixun-backend/internal/logic/crm" - "haixun-backend/internal/response" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" -) - -func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req types.CrmContactIDPath - if err := httpx.Parse(r, &req); err != nil { - response.Write(r.Context(), w, nil, response.WrapRequestError(err)) - return - } - l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx) - err := l.DeleteCrmContact(&req) - response.Write(r.Context(), w, nil, err) - } -} diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/crm/get_crm_contact_handler.go deleted file mode 100644 index d4df08c..0000000 --- a/backend/internal/handler/crm/get_crm_contact_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package crm - -import ( - "net/http" - - "haixun-backend/internal/logic/crm" - "haixun-backend/internal/response" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" -) - -func GetCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req types.CrmContactIDPath - if err := httpx.Parse(r, &req); err != nil { - response.Write(r.Context(), w, nil, response.WrapRequestError(err)) - return - } - l := crm.NewGetCrmContactLogic(r.Context(), svcCtx) - data, err := l.GetCrmContact(&req) - response.Write(r.Context(), w, data, err) - } -} diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/crm/list_crm_contacts_handler.go deleted file mode 100644 index 6d74542..0000000 --- a/backend/internal/handler/crm/list_crm_contacts_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package crm - -import ( - "net/http" - - "haixun-backend/internal/logic/crm" - "haixun-backend/internal/response" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" -) - -func ListCrmContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req types.ListCrmContactsReq - if err := httpx.Parse(r, &req); err != nil { - response.Write(r.Context(), w, nil, response.WrapRequestError(err)) - return - } - l := crm.NewListCrmContactsLogic(r.Context(), svcCtx) - data, err := l.ListCrmContacts(&req) - response.Write(r.Context(), w, data, err) - } -} diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/crm/update_crm_contact_handler.go deleted file mode 100644 index 8d2746c..0000000 --- a/backend/internal/handler/crm/update_crm_contact_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package crm - -import ( - "net/http" - - "haixun-backend/internal/logic/crm" - "haixun-backend/internal/response" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" -) - -func UpdateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req types.UpdateCrmContactReq - if err := httpx.Parse(r, &req); err != nil { - response.Write(r.Context(), w, nil, response.WrapRequestError(err)) - return - } - l := crm.NewUpdateCrmContactLogic(r.Context(), svcCtx) - data, err := l.UpdateCrmContact(&req) - response.Write(r.Context(), w, data, err) - } -} diff --git a/backend/internal/handler/persona/delete_style_preset_handler.go b/backend/internal/handler/persona/delete_style_preset_handler.go new file mode 100644 index 0000000..c7e0873 --- /dev/null +++ b/backend/internal/handler/persona/delete_style_preset_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StylePresetPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx) + err := l.DeleteStylePreset(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/persona/list_style_presets_handler.go similarity index 64% rename from backend/internal/handler/crm/create_crm_contact_handler.go rename to backend/internal/handler/persona/list_style_presets_handler.go index 52d1aed..34cfa79 100644 --- a/backend/internal/handler/crm/create_crm_contact_handler.go +++ b/backend/internal/handler/persona/list_style_presets_handler.go @@ -1,19 +1,21 @@ -package crm +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona import ( "net/http" - "haixun-backend/internal/logic/crm" + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) -func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.CreateCrmContactReq + var req types.PersonaPath if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return @@ -22,8 +24,9 @@ func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := crm.NewCreateCrmContactLogic(r.Context(), svcCtx) - data, err := l.CreateCrmContact(&req) + + l := persona.NewListStylePresetsLogic(r.Context(), svcCtx) + data, err := l.ListStylePresets(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/persona/schedule_persona_drafts_handler.go b/backend/internal/handler/persona/schedule_persona_drafts_handler.go new file mode 100644 index 0000000..a8e9471 --- /dev/null +++ b/backend/internal/handler/persona/schedule_persona_drafts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SchedulePersonaDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx) + data, err := l.SchedulePersonaDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/persona/upsert_style_preset_handler.go similarity index 61% rename from backend/internal/handler/scan_post/lookalike_handler.go rename to backend/internal/handler/persona/upsert_style_preset_handler.go index 5123c62..cfc28d6 100644 --- a/backend/internal/handler/scan_post/lookalike_handler.go +++ b/backend/internal/handler/persona/upsert_style_preset_handler.go @@ -1,19 +1,21 @@ -package scan_post +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona import ( "net/http" - "haixun-backend/internal/logic/scan_post" + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) -func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { +func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.LookalikeReq + var req types.UpsertStylePresetHandlerReq if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return @@ -22,8 +24,9 @@ func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return } - l := scan_post.NewLookalikeLogic(r.Context(), svcCtx) - data, err := l.Lookalike(&req) + + l := persona.NewUpsertStylePresetLogic(r.Context(), svcCtx) + data, err := l.UpsertStylePreset(&req) response.Write(r.Context(), w, data, err) } } diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 9991560..7ee7e7c 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -276,6 +276,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:personaId/copy-missions/:id/copy-drafts", Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:personaId/copy-missions/:id/copy-drafts/schedule", + Handler: copy_mission.ScheduleCopyMissionDraftsHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:personaId/copy-missions/:id/knowledge-graph", @@ -597,11 +602,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/copy-drafts/generate", Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/copy-drafts/schedule", + Handler: persona.SchedulePersonaDraftsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/style-analysis", Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/style-presets", + Handler: persona.ListStylePresetsHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/style-presets/:presetId", + Handler: persona.UpsertStylePresetHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/style-presets/:presetId", + Handler: persona.DeleteStylePresetHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/viral-scan-jobs", @@ -774,6 +799,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id", Handler: threads_account.UpdateThreadsAccountHandler(serverCtx), }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: threads_account.DeleteThreadsAccountHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/activate", @@ -819,16 +849,61 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/connection", Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/diagnostics", + Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/post-performance", Handler: threads_account.ListThreadsPostPerformanceHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/publish-alerts", + Handler: threads_account.ListPublishAlertsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-calendar", + Handler: threads_account.ListPublishQueueRangeHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-guard-policy", + Handler: threads_account.GetPublishGuardPolicyHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/publish-guard-policy", + Handler: threads_account.UpsertPublishGuardPolicyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-guard/resume", + Handler: threads_account.ResumePublishGuardHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/publish-health", Handler: threads_account.ListThreadsPublishHealthHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/publish-inventory-policy", + Handler: threads_account.GetPublishInventoryPolicyHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/publish-inventory-policy", + Handler: threads_account.UpsertPublishInventoryPolicyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-inventory/refill-jobs", + Handler: threads_account.StartPublishInventoryRefillJobHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/publish-queue", @@ -859,6 +934,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/publish-queue/:qid/cancel", Handler: threads_account.CancelPublishQueueItemHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/publish-queue/:qid/events", + Handler: threads_account.ListPublishQueueEventsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/publish-queue/:qid/retry", + Handler: threads_account.RetryPublishQueueItemHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/publish-slot-insights", + Handler: threads_account.GetPublishSlotInsightsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/session/import", @@ -879,6 +969,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/oauth/start", Handler: threads_account.StartThreadsOauthHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/publish-dashboard-summary", + Handler: threads_account.GetPublishDashboardSummaryHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1/threads-accounts"), diff --git a/backend/internal/handler/threads_account/delete_threads_account_handler.go b/backend/internal/handler/threads_account/delete_threads_account_handler.go new file mode 100644 index 0000000..9029e7f --- /dev/null +++ b/backend/internal/handler/threads_account/delete_threads_account_handler.go @@ -0,0 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewDeleteThreadsAccountLogic(r.Context(), svcCtx) + data, err := l.DeleteThreadsAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go new file mode 100644 index 0000000..cc261ed --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go @@ -0,0 +1,20 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func GetPublishDashboardSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads_account.NewGetPublishDashboardSummaryLogic(r.Context(), svcCtx) + data, err := l.GetPublishDashboardSummary() + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go new file mode 100644 index 0000000..0f2171f --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishGuardPolicyLogic(r.Context(), svcCtx) + data, err := l.GetPublishGuardPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go new file mode 100644 index 0000000..6b0e387 --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishInventoryPolicyLogic(r.Context(), svcCtx) + data, err := l.GetPublishInventoryPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go new file mode 100644 index 0000000..d1d21ad --- /dev/null +++ b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPublishSlotInsightsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetPublishSlotInsightsLogic(r.Context(), svcCtx) + data, err := l.GetPublishSlotInsights(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go new file mode 100644 index 0000000..1053001 --- /dev/null +++ b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetThreadsDiagnosticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewGetThreadsDiagnosticsLogic(r.Context(), svcCtx) + data, err := l.GetThreadsDiagnostics(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_alerts_handler.go b/backend/internal/handler/threads_account/list_publish_alerts_handler.go new file mode 100644 index 0000000..793552b --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_alerts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishAlertsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishAlertsLogic(r.Context(), svcCtx) + data, err := l.ListPublishAlerts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_events_handler.go b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go new file mode 100644 index 0000000..7aed7d9 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishQueueEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishQueueEventsLogic(r.Context(), svcCtx) + data, err := l.ListPublishQueueEvents(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/list_publish_queue_range_handler.go b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go new file mode 100644 index 0000000..0c910b4 --- /dev/null +++ b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPublishQueueRangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueRangeHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewListPublishQueueRangeLogic(r.Context(), svcCtx) + data, err := l.ListPublishQueueRange(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/resume_publish_guard_handler.go b/backend/internal/handler/threads_account/resume_publish_guard_handler.go new file mode 100644 index 0000000..67e2dd3 --- /dev/null +++ b/backend/internal/handler/threads_account/resume_publish_guard_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ResumePublishGuardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewResumePublishGuardLogic(r.Context(), svcCtx) + data, err := l.ResumePublishGuard(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go new file mode 100644 index 0000000..c56893c --- /dev/null +++ b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func RetryPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishQueueItemPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewRetryPublishQueueItemLogic(r.Context(), svcCtx) + data, err := l.RetryPublishQueueItem(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go new file mode 100644 index 0000000..639f209 --- /dev/null +++ b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPublishInventoryRefillJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx) + data, err := l.StartPublishInventoryRefillJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go new file mode 100644 index 0000000..95eeb81 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertPublishGuardPolicyHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewUpsertPublishGuardPolicyLogic(r.Context(), svcCtx) + data, err := l.UpsertPublishGuardPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go new file mode 100644 index 0000000..9e55ad4 --- /dev/null +++ b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/threads_account" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertPublishInventoryPolicyHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads_account.NewUpsertPublishInventoryPolicyLogic(r.Context(), svcCtx) + data, err := l.UpsertPublishInventoryPolicy(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/library/permmatch/match_test.go b/backend/internal/library/permmatch/match_test.go index 64dbcbd..3bfbf18 100644 --- a/backend/internal/library/permmatch/match_test.go +++ b/backend/internal/library/permmatch/match_test.go @@ -25,8 +25,9 @@ func TestPathAllowed(t *testing.T) { func TestRequestAllowed(t *testing.T) { perms := map[string]string{ - "/api/v1/members/me": "GET|PATCH", - "/api/v1/jobs/*": "GET|POST", + "/api/v1/members/me": "GET|PATCH", + "/api/v1/jobs/*": "GET|POST", + "/api/v1/threads-accounts/*": "GET|POST|PUT|PATCH|DELETE", } if !RequestAllowed(perms, "GET", "/api/v1/members/me") { t.Fatal("expected member me") @@ -37,6 +38,12 @@ func TestRequestAllowed(t *testing.T) { if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") { t.Fatal("expected job cancel") } + if !RequestAllowed(perms, "DELETE", "/api/v1/threads-accounts/acc-1") { + t.Fatal("expected threads account delete") + } + if !RequestAllowed(perms, "POST", "/api/v1/threads-accounts/acc-1/activate") { + t.Fatal("expected threads account activate") + } } func TestPathAllowedListRoot(t *testing.T) { diff --git a/backend/internal/library/publishschedule/slots.go b/backend/internal/library/publishschedule/slots.go new file mode 100644 index 0000000..611582b --- /dev/null +++ b/backend/internal/library/publishschedule/slots.go @@ -0,0 +1,88 @@ +package publishschedule + +import ( + "sort" + "strings" + "time" +) + +type Slot struct { + Weekday int + Time string +} + +func BuildSchedule(startAt int64, timezone string, slots []Slot, count int) []int64 { + if count <= 0 { + return nil + } + loc := time.UTC + if strings.TrimSpace(timezone) != "" { + if loaded, err := time.LoadLocation(strings.TrimSpace(timezone)); err == nil { + loc = loaded + } + } + start := time.Now().In(loc) + if startAt > 0 { + start = time.Unix(0, startAt).In(loc) + } + normalized := normalizeSlots(slots) + if len(normalized) == 0 { + out := make([]int64, 0, count) + for i := 0; i < count; i++ { + out = append(out, start.Add(time.Duration(i)*2*time.Hour).UTC().UnixNano()) + } + return out + } + out := make([]int64, 0, count) + day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc) + for len(out) < count { + for _, slot := range normalized { + if int(day.Weekday()) != slot.Weekday { + continue + } + hour, minute, ok := parseHHMM(slot.Time) + if !ok { + continue + } + candidate := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc) + if candidate.Before(start) { + continue + } + out = append(out, candidate.UTC().UnixNano()) + if len(out) >= count { + break + } + } + day = day.AddDate(0, 0, 1) + } + return out +} + +func normalizeSlots(slots []Slot) []Slot { + out := make([]Slot, 0, len(slots)) + for _, slot := range slots { + if strings.TrimSpace(slot.Time) == "" { + continue + } + weekday := slot.Weekday + if weekday < 0 || weekday > 6 { + weekday = ((weekday % 7) + 7) % 7 + } + out = append(out, Slot{Weekday: weekday, Time: strings.TrimSpace(slot.Time)}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Weekday == out[j].Weekday { + return out[i].Time < out[j].Time + } + return out[i].Weekday < out[j].Weekday + }) + return out +} + +func parseHHMM(value string) (int, int, bool) { + parsed, err := time.Parse("15:04", strings.TrimSpace(value)) + if err != nil { + return 0, 0, false + } + return parsed.Hour(), parsed.Minute(), true +} diff --git a/backend/internal/library/publishschedule/slots_test.go b/backend/internal/library/publishschedule/slots_test.go new file mode 100644 index 0000000..5cc8d29 --- /dev/null +++ b/backend/internal/library/publishschedule/slots_test.go @@ -0,0 +1,40 @@ +package publishschedule + +import ( + "testing" + "time" +) + +func TestBuildScheduleUsesTimezoneSlotsAndReturnsUTCNano(t *testing.T) { + loc, err := time.LoadLocation("Asia/Taipei") + if err != nil { + t.Fatal(err) + } + start := time.Date(2026, 6, 29, 9, 0, 0, 0, loc) // Monday + got := BuildSchedule(start.UTC().UnixNano(), "Asia/Taipei", []Slot{ + {Weekday: 1, Time: "09:30"}, + {Weekday: 3, Time: "12:30"}, + }, 2) + if len(got) != 2 { + t.Fatalf("expected 2 timestamps, got %d", len(got)) + } + first := time.Unix(0, got[0]).In(loc) + if first.Weekday() != time.Monday || first.Hour() != 9 || first.Minute() != 30 { + t.Fatalf("unexpected first slot: %s", first) + } + second := time.Unix(0, got[1]).In(loc) + if second.Weekday() != time.Wednesday || second.Hour() != 12 || second.Minute() != 30 { + t.Fatalf("unexpected second slot: %s", second) + } +} + +func TestBuildScheduleFallsBackToIntervalsWhenNoSlots(t *testing.T) { + start := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC) + got := BuildSchedule(start.UnixNano(), "UTC", nil, 2) + if len(got) != 2 { + t.Fatalf("expected 2 timestamps, got %d", len(got)) + } + if got[1]-got[0] != int64(2*time.Hour) { + t.Fatalf("expected 2h interval, got %s", time.Duration(got[1]-got[0])) + } +} diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go index 8812738..e791184 100644 --- a/backend/internal/logic/copy_mission/mapper.go +++ b/backend/internal/logic/copy_mission/mapper.go @@ -291,6 +291,7 @@ func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData ReferenceNotes: item.ReferenceNotes, Sources: item.Sources, Status: item.Status, + PublishQueueID: item.PublishQueueID, PublishedMediaID: item.PublishedMediaID, PublishedPermalink: item.PublishedPermalink, PublishedAt: item.PublishedAt, diff --git a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go new file mode 100644 index 0000000..e0d9fcc --- /dev/null +++ b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go @@ -0,0 +1,99 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package copy_mission + +import ( + "context" + "fmt" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/publishschedule" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ScheduleCopyMissionDraftsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic { + return &ScheduleCopyMissionDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, req.PersonaID, req.ID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { + return nil, err + } + times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs)) + list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs)) + for idx, draftID := range req.DraftIDs { + draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.PersonaID, draftID) + if err != nil { + return nil, err + } + if draft.CopyMissionID != req.ID { + return nil, app.For(code.Persona).InputInvalidFormat("草稿不屬於此文案任務") + } + scheduledAt := int64(0) + if idx < len(times) { + scheduledAt = times[idx] + } + item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, + PersonaID: req.PersonaID, CopyMissionID: req.ID, CopyDraftID: draft.ID, + Text: draft.Text, ScheduledAt: scheduledAt, + }) + if err != nil { + return nil, err + } + _, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.PersonaID, DraftID: draft.ID, QueueID: item.ID, + }) + list = append(list, publishQueueData(item)) + } + return &types.ScheduleCopyDraftsData{ + Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)), + }, nil +} + +func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 { + converted := make([]publishschedule.Slot, 0, len(slots)) + for _, slot := range slots { + converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + return publishschedule.BuildSchedule(startAt, timezone, converted, count) +} + +func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData { + if item == nil { + return types.PublishQueueItemData{} + } + return types.PublishQueueItemData{ + ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID, + Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status, + MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason, + CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go deleted file mode 100644 index ff363fc..0000000 --- a/backend/internal/logic/crm/actor.go +++ /dev/null @@ -1,17 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/library/authctx" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" -) - -func actorFrom(ctx context.Context) (tenantID, uid string, err error) { - actor, ok := authctx.ActorFromContext(ctx) - if !ok { - return "", "", app.For(code.Auth).AuthUnauthorized("missing actor") - } - return actor.TenantID, actor.UID, nil -} diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go deleted file mode 100644 index 6cc276b..0000000 --- a/backend/internal/logic/crm/create_crm_contact_logic.go +++ /dev/null @@ -1,46 +0,0 @@ -package crm - -import ( - "context" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type CreateCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewCreateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCrmContactLogic { - return &CreateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *CreateCrmContactLogic) CreateCrmContact(req *types.CreateCrmContactReq) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - authorID := strings.TrimSpace(req.AuthorID) - authorName := strings.TrimSpace(req.AuthorName) - if authorID == "" { - return nil, app.For(code.Persona).InputMissingRequired("author_id is required") - } - if authorName == "" { - return nil, app.For(code.Persona).InputMissingRequired("author_name is required") - } - - result, err := l.svcCtx.CrmContact.Create(l.ctx, tenantID, uid, strings.TrimSpace(req.BrandID), authorID, authorName) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go deleted file mode 100644 index 669ebcf..0000000 --- a/backend/internal/logic/crm/delete_crm_contact_logic.go +++ /dev/null @@ -1,29 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type DeleteCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewDeleteCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCrmContactLogic { - return &DeleteCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *DeleteCrmContactLogic) DeleteCrmContact(req *types.CrmContactIDPath) error { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return err - } - - return l.svcCtx.CrmContact.Delete(l.ctx, tenantID, uid, req.ID) -} diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go deleted file mode 100644 index 943f6c6..0000000 --- a/backend/internal/logic/crm/get_crm_contact_logic.go +++ /dev/null @@ -1,34 +0,0 @@ -package crm - -import ( - "context" - - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type GetCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewGetCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmContactLogic { - return &GetCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *GetCrmContactLogic) GetCrmContact(req *types.CrmContactIDPath) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.CrmContact.Get(l.ctx, tenantID, uid, req.ID) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go deleted file mode 100644 index ce16203..0000000 --- a/backend/internal/logic/crm/list_crm_contacts_logic.go +++ /dev/null @@ -1,54 +0,0 @@ -package crm - -import ( - "context" - - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type ListCrmContactsLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewListCrmContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCrmContactsLogic { - return &ListCrmContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *ListCrmContactsLogic) ListCrmContacts(req *types.ListCrmContactsReq) (*types.CrmContactListData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - page := req.Page - pageSize := req.PageSize - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 20 - } - - listReq := crmusecase.ListRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.BrandID, - Status: req.Status, - Tag: req.Tag, - Page: page, - PageSize: pageSize, - } - - items, total, err := l.svcCtx.CrmContact.List(l.ctx, listReq) - if err != nil { - return nil, err - } - - return toCrmContactListData(items, total, page, pageSize), nil -} diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go deleted file mode 100644 index 48a9738..0000000 --- a/backend/internal/logic/crm/mapper.go +++ /dev/null @@ -1,48 +0,0 @@ -package crm - -import ( - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/types" -) - -func toCrmContactData(item *crmusecase.CRMContactSummary) *types.CrmContactData { - if item == nil { - return nil - } - return &types.CrmContactData{ - ID: item.ID, - BrandID: item.BrandID, - AuthorID: item.AuthorID, - AuthorName: item.AuthorName, - AuthorAvatar: item.AuthorAvatar, - AuthorFollowers: item.AuthorFollowers, - ScanPostID: item.ScanPostID, - Notes: item.Notes, - Tags: item.Tags, - Status: item.Status, - OutreachCount: item.OutreachCount, - LastContactedAt: item.LastContactedAt, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, - } -} - -func toCrmContactListData(items []crmusecase.CRMContactSummary, total, page, pageSize int) *types.CrmContactListData { - totalPages := 0 - if total > 0 { - totalPages = (total + pageSize - 1) / pageSize - } - data := make([]types.CrmContactData, 0, len(items)) - for _, item := range items { - data = append(data, *toCrmContactData(&item)) - } - return &types.CrmContactListData{ - List: data, - Pagination: types.PaginationData{ - Total: int64(total), - Page: int64(page), - PageSize: int64(pageSize), - TotalPages: int64(totalPages), - }, - } -} diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go deleted file mode 100644 index 853c5e4..0000000 --- a/backend/internal/logic/crm/update_crm_contact_logic.go +++ /dev/null @@ -1,42 +0,0 @@ -package crm - -import ( - "context" - - crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type UpdateCrmContactLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewUpdateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCrmContactLogic { - return &UpdateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *UpdateCrmContactLogic) UpdateCrmContact(req *types.UpdateCrmContactReq) (*types.CrmContactData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.CrmContact.Update(l.ctx, crmusecase.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - ContactID: req.ID, - Notes: req.Notes, - Tags: req.Tags, - Status: req.Status, - }) - if err != nil { - return nil, err - } - - return toCrmContactData(result), nil -} diff --git a/backend/internal/logic/persona/delete_style_preset_logic.go b/backend/internal/logic/persona/delete_style_preset_logic.go new file mode 100644 index 0000000..0327d5b --- /dev/null +++ b/backend/internal/logic/persona/delete_style_preset_logic.go @@ -0,0 +1,35 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteStylePresetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteStylePresetLogic { + return &DeleteStylePresetLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteStylePresetLogic) DeleteStylePreset(req *types.StylePresetPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + return l.svcCtx.StylePreset.Delete(l.ctx, tenantID, uid, req.ID, req.PresetID) +} diff --git a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go index fbfdf12..eb35709 100644 --- a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go @@ -154,20 +154,24 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( return &types.GeneratePersonaCopyDraftData{ Draft: types.CopyDraftData{ - ID: saved.ID, - PersonaID: saved.PersonaID, - CopyMissionID: saved.CopyMissionID, - ScanPostID: saved.ScanPostID, - DraftType: saved.DraftType, - SortOrder: saved.SortOrder, - Text: saved.Text, - Angle: saved.Angle, - Hook: saved.Hook, - Rationale: saved.Rationale, - ReferenceNotes: saved.ReferenceNotes, - Sources: saved.Sources, - Status: saved.Status, - CreateAt: saved.CreateAt, + ID: saved.ID, + PersonaID: saved.PersonaID, + CopyMissionID: saved.CopyMissionID, + ScanPostID: saved.ScanPostID, + DraftType: saved.DraftType, + SortOrder: saved.SortOrder, + Text: saved.Text, + Angle: saved.Angle, + Hook: saved.Hook, + Rationale: saved.Rationale, + ReferenceNotes: saved.ReferenceNotes, + Sources: saved.Sources, + Status: saved.Status, + PublishQueueID: saved.PublishQueueID, + PublishedMediaID: saved.PublishedMediaID, + PublishedPermalink: saved.PublishedPermalink, + PublishedAt: saved.PublishedAt, + CreateAt: saved.CreateAt, }, Message: "已產出仿寫草稿", }, nil diff --git a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go index de35a46..3288e38 100644 --- a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go +++ b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go @@ -34,20 +34,24 @@ func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPat list := make([]types.CopyDraftData, 0, len(drafts)) for _, item := range drafts { list = append(list, types.CopyDraftData{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - DraftType: item.DraftType, - SortOrder: item.SortOrder, - Text: item.Text, - Angle: item.Angle, - Hook: item.Hook, - Rationale: item.Rationale, - ReferenceNotes: item.ReferenceNotes, - Sources: item.Sources, - Status: item.Status, - CreateAt: item.CreateAt, + ID: item.ID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + Status: item.Status, + PublishQueueID: item.PublishQueueID, + PublishedMediaID: item.PublishedMediaID, + PublishedPermalink: item.PublishedPermalink, + PublishedAt: item.PublishedAt, + CreateAt: item.CreateAt, }) } return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil diff --git a/backend/internal/logic/persona/list_style_presets_logic.go b/backend/internal/logic/persona/list_style_presets_logic.go new file mode 100644 index 0000000..c190192 --- /dev/null +++ b/backend/internal/logic/persona/list_style_presets_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListStylePresetsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListStylePresetsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListStylePresetsLogic { + return &ListStylePresetsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListStylePresetsLogic) ListStylePresets(req *types.PersonaPath) (resp *types.ListStylePresetsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.StylePreset.List(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.StylePresetData, 0, len(items)) + for _, item := range items { + list = append(list, toStylePresetData(item)) + } + return &types.ListStylePresetsData{List: list}, nil +} diff --git a/backend/internal/logic/persona/mapper.go b/backend/internal/logic/persona/mapper.go index 0f852e7..3d3c6dc 100644 --- a/backend/internal/logic/persona/mapper.go +++ b/backend/internal/logic/persona/mapper.go @@ -2,6 +2,7 @@ package persona import ( domusecase "haixun-backend/internal/model/persona/domain/usecase" + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" "haixun-backend/internal/types" ) @@ -55,3 +56,11 @@ func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch { StyleBenchmark: req.StyleBenchmark, } } + +func toStylePresetData(item stylepresetdomain.PresetSummary) types.StylePresetData { + return types.StylePresetData{ + ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone, + CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...), + Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/backend/internal/logic/persona/schedule_persona_drafts_logic.go new file mode 100644 index 0000000..2f00096 --- /dev/null +++ b/backend/internal/logic/persona/schedule_persona_drafts_logic.go @@ -0,0 +1,94 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/library/publishschedule" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SchedulePersonaDraftsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSchedulePersonaDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaDraftsLogic { + return &SchedulePersonaDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePersonaDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { + return nil, err + } + times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs)) + list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs)) + for idx, draftID := range req.DraftIDs { + draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, draftID) + if err != nil { + return nil, err + } + scheduledAt := int64(0) + if idx < len(times) { + scheduledAt = times[idx] + } + item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, + PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID, + Text: draft.Text, ScheduledAt: scheduledAt, + }) + if err != nil { + return nil, err + } + _, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, + }) + list = append(list, publishQueueData(item)) + } + return &types.ScheduleCopyDraftsData{ + Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)), + }, nil +} + +func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 { + converted := make([]publishschedule.Slot, 0, len(slots)) + for _, slot := range slots { + converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + return publishschedule.BuildSchedule(startAt, timezone, converted, count) +} + +func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData { + if item == nil { + return types.PublishQueueItemData{} + } + return types.PublishQueueItemData{ + ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID, + Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status, + MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason, + CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} diff --git a/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go index 07c78cb..cbe3f2b 100644 --- a/backend/internal/logic/persona/update_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/update_persona_copy_draft_logic.go @@ -68,6 +68,7 @@ func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft( ReferenceNotes: updated.ReferenceNotes, Sources: updated.Sources, Status: updated.Status, + PublishQueueID: updated.PublishQueueID, PublishedMediaID: updated.PublishedMediaID, PublishedPermalink: updated.PublishedPermalink, PublishedAt: updated.PublishedAt, diff --git a/backend/internal/logic/persona/upsert_style_preset_logic.go b/backend/internal/logic/persona/upsert_style_preset_logic.go new file mode 100644 index 0000000..33ca67a --- /dev/null +++ b/backend/internal/logic/persona/upsert_style_preset_logic.go @@ -0,0 +1,44 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertStylePresetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertStylePresetLogic { + return &UpsertStylePresetLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertStylePresetLogic) UpsertStylePreset(req *types.UpsertStylePresetHandlerReq) (resp *types.StylePresetData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.StylePreset.Upsert(l.ctx, stylepresetdomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, PresetID: req.PresetID, + Name: req.Name, Tone: req.Tone, CTA: req.CTA, BannedWords: req.BannedWords, Notes: req.Notes, Apply: req.Apply, + }) + if err != nil { + return nil, err + } + data := toStylePresetData(*item) + return &data, nil +} diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go deleted file mode 100644 index 510b778..0000000 --- a/backend/internal/logic/scan_post/lookalike_logic.go +++ /dev/null @@ -1,126 +0,0 @@ -package scan_post - -import ( - "context" - "math" - "sort" - - "haixun-backend/internal/library/authctx" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/core/logx" -) - -type LookalikeLogic struct { - logx.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -func NewLookalikeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LookalikeLogic { - return &LookalikeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} -} - -func (l *LookalikeLogic) Lookalike(req *types.LookalikeReq) (*types.LookalikeData, error) { - actor, ok := authctx.ActorFromContext(l.ctx) - if !ok { - return nil, app.For(code.Auth).AuthUnauthorized("missing actor") - } - - source, err := l.svcCtx.ScanPost.Get(l.ctx, actor.TenantID, actor.UID, req.BrandID, req.SourcePostID) - if err != nil { - return nil, err - } - if len(source.Embedding) == 0 { - return &types.LookalikeData{List: nil}, nil - } - - brandID := req.BrandID - if brandID == "" { - brandID = source.BrandID - } - - limit := req.Limit - if limit < 1 { - limit = 10 - } - if limit > 50 { - limit = 50 - } - - candidates, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{ - TenantID: actor.TenantID, - OwnerUID: actor.UID, - BrandID: brandID, - Limit: 200, - }) - if err != nil { - return nil, err - } - - type scored struct { - post scanpostusecase.ScanPostSummary - sim float64 - } - var scoredList []scored - for _, c := range candidates { - if c.ID == req.SourcePostID { - continue - } - if len(c.Embedding) == 0 { - continue - } - sim := cosineSimilarity(source.Embedding, c.Embedding) - if sim < 0.3 { - continue - } - scoredList = append(scoredList, scored{post: c, sim: sim}) - } - - sort.Slice(scoredList, func(i, j int) bool { - return scoredList[i].sim > scoredList[j].sim - }) - - if len(scoredList) > limit { - scoredList = scoredList[:limit] - } - - out := make([]types.LookalikeItemData, 0, len(scoredList)) - for _, s := range scoredList { - out = append(out, types.LookalikeItemData{ - PostID: s.post.ID, - AuthorID: s.post.AuthorID, - AuthorName: s.post.Author, - AuthorAvatar: s.post.AuthorAvatar, - Permalink: s.post.Permalink, - Text: s.post.Text, - PlacementScore: s.post.PlacementScore, - SemanticScore: s.post.SemanticScore, - EngagementPredicted: s.post.EngagementPredicted, - AudienceQuality: s.post.AudienceQualityScore, - Similarity: math.Round(s.sim*1000) / 1000, - }) - } - - return &types.LookalikeData{List: out}, nil -} - -func cosineSimilarity(a, b []float32) float64 { - if len(a) == 0 || len(b) == 0 || len(a) != len(b) { - return 0 - } - var dot, na, nb float64 - for i := range a { - dot += float64(a[i]) * float64(b[i]) - na += float64(a[i]) * float64(a[i]) - nb += float64(b[i]) * float64(b[i]) - } - if na == 0 || nb == 0 { - return 0 - } - return dot / (math.Sqrt(na) * math.Sqrt(nb)) -} diff --git a/backend/internal/logic/threads_account/delete_threads_account_logic.go b/backend/internal/logic/threads_account/delete_threads_account_logic.go new file mode 100644 index 0000000..296aec4 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_threads_account_logic.go @@ -0,0 +1,36 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeleteThreadsAccountLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteThreadsAccountLogic { + return &DeleteThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteThreadsAccountLogic) DeleteThreadsAccount(req *types.ThreadsAccountPath) (*types.DeleteThreadsAccountData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.ThreadsAccount.Delete(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + return &types.DeleteThreadsAccountData{ + DeletedID: req.ID, + ActiveAccountID: accounts.ActiveAccountID, + Message: "帳號已刪除", + }, nil +} diff --git a/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go new file mode 100644 index 0000000..da4d205 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go @@ -0,0 +1,72 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishDashboardSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishDashboardSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishDashboardSummaryLogic { + return &GetPublishDashboardSummaryLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishDashboardSummaryLogic) GetPublishDashboardSummary() (resp *types.PublishDashboardSummaryData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + resp = &types.PublishDashboardSummaryData{List: []types.PublishDashboardAccountSummary{}} + for _, account := range accounts.List { + health, hErr := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, account.ID, 1, 10) + if hErr != nil { + continue + } + guard, _ := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, account.ID) + slots, _ := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, account.ID, "Asia/Taipei") + bestSlot, lowSlot := "", "" + for _, slot := range slots { + if bestSlot == "" && slot.Recommendation == "recommended" { + bestSlot = slot.Time + } + if lowSlot == "" && slot.Recommendation == "low" { + lowSlot = slot.Time + } + } + row := types.PublishDashboardAccountSummary{ + AccountID: account.ID, AccountName: firstNonEmpty(account.DisplayName, account.Username, account.ID), + PendingScheduled: health.Summary.PendingScheduled, FailedCount: health.Summary.FailedCount, + Published7d: health.Summary.Published7d, TotalLikes7d: health.Summary.TotalLikes7d, + TotalReplies7d: health.Summary.TotalReplies7d, BestSlot: bestSlot, LowSlot: lowSlot, + } + if guard != nil { + row.Paused = guard.Paused + } + resp.List = append(resp.List, row) + resp.TotalPending += row.PendingScheduled + resp.TotalFailed += row.FailedCount + resp.TotalPublished7d += row.Published7d + resp.TotalLikes7d += row.TotalLikes7d + resp.TotalReplies7d += row.TotalReplies7d + } + return resp, nil +} diff --git a/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go new file mode 100644 index 0000000..6e15f0b --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishGuardPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishGuardPolicyLogic { + return &GetPublishGuardPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishGuardPolicyLogic) GetPublishGuardPolicy(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go new file mode 100644 index 0000000..cde2e9e --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishInventoryPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishInventoryPolicyLogic { + return &GetPublishInventoryPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishInventoryPolicyLogic) GetPublishInventoryPolicy(req *types.ThreadsAccountPath) (resp *types.PublishInventoryPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toInventoryPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go new file mode 100644 index 0000000..7b84ba3 --- /dev/null +++ b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go @@ -0,0 +1,53 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPublishSlotInsightsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPublishSlotInsightsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishSlotInsightsLogic { + return &GetPublishSlotInsightsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPublishSlotInsightsLogic) GetPublishSlotInsights(req *types.ThreadsAccountPath) (resp *types.PublishSlotInsightsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + policy, _ := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID) + timezone := "Asia/Taipei" + if policy != nil && policy.Timezone != "" { + timezone = policy.Timezone + } + items, err := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, req.ID, timezone) + if err != nil { + return nil, err + } + list := make([]types.PublishSlotInsightData, 0, len(items)) + for _, item := range items { + list = append(list, types.PublishSlotInsightData{ + Weekday: item.Weekday, Time: item.Time, AvgLikes1h: item.AvgLikes1h, AvgReplies1h: item.AvgReplies1h, + AvgLikes24h: item.AvgLikes24h, AvgReplies24h: item.AvgReplies24h, + AvgLikes7d: item.AvgLikes7d, AvgReplies7d: item.AvgReplies7d, + SampleSize: item.SampleSize, Recommendation: item.Recommendation, + }) + } + return &types.PublishSlotInsightsData{AccountID: req.ID, Timezone: timezone, Slots: list}, nil +} diff --git a/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go new file mode 100644 index 0000000..c4a23d5 --- /dev/null +++ b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go @@ -0,0 +1,55 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/library/clock" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetThreadsDiagnosticsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetThreadsDiagnosticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsDiagnosticsLogic { + return &GetThreadsDiagnosticsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetThreadsDiagnosticsLogic) GetThreadsDiagnostics(req *types.ThreadsAccountPath) (resp *types.ThreadsDiagnosticsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + smoke, smokeErr := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID) + items := make([]types.ThreadsAPISmokeTestItem, 0, len(smoke)) + for _, item := range smoke { + items = append(items, types.ThreadsAPISmokeTestItem{ + Scope: item.Scope, Name: item.Name, Status: item.Status, Message: item.Message, Count: item.Count, + }) + } + message := "診斷完成" + if smokeErr != nil { + message = smokeErr.Error() + } + return &types.ThreadsDiagnosticsData{ + AccountID: req.ID, CheckedAt: clock.NowUnixNano(), ApiConnected: account.ApiConnected, + TokenExpiresAt: account.APITokenExpiresAt, Scopes: libthreads.DefaultOAuthScopes, Items: items, Message: message, + }, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_alerts_logic.go b/backend/internal/logic/threads_account/list_publish_alerts_logic.go new file mode 100644 index 0000000..1d239c2 --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_alerts_logic.go @@ -0,0 +1,46 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishAlertsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishAlertsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishAlertsLogic { + return &ListPublishAlertsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishAlertsLogic) ListPublishAlerts(req *types.ThreadsAccountPath) (resp *types.PublishAlertsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.PublishQueue.ListAlerts(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.PublishAlertData, 0, len(items)) + for _, item := range items { + list = append(list, types.PublishAlertData{ + Type: item.Type, Severity: item.Severity, Message: item.Message, + QueueID: item.QueueID, AccountID: item.AccountID, CreateAt: item.CreateAt, + }) + } + return &types.PublishAlertsData{List: list}, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_events_logic.go b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go new file mode 100644 index 0000000..327998a --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishQueueEventsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishQueueEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueEventsLogic { + return &ListPublishQueueEventsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishQueueEventsLogic) ListPublishQueueEvents(req *types.PublishQueueItemPath) (resp *types.PublishQueueEventsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.PublishQueueEvent.ListByQueue(l.ctx, tenantID, uid, req.ID, req.QID, 100) + if err != nil { + return nil, err + } + return &types.PublishQueueEventsData{List: toQueueEventData(items)}, nil +} diff --git a/backend/internal/logic/threads_account/list_publish_queue_range_logic.go b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go new file mode 100644 index 0000000..db34dbc --- /dev/null +++ b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPublishQueueRangeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPublishQueueRangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueRangeLogic { + return &ListPublishQueueRangeLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListPublishQueueRangeLogic) ListPublishQueueRange(req *types.PublishQueueRangeHandlerReq) (resp *types.PublishQueueListData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PublishQueue.ListRange(l.ctx, pqdomain.RangeRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, + Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, Page: 1, PageSize: 200, + }) + if err != nil { + return nil, err + } + return toPublishQueueListData(result), nil +} diff --git a/backend/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go index 4bd8606..9695d3d 100644 --- a/backend/internal/logic/threads_account/mapper.go +++ b/backend/internal/logic/threads_account/mapper.go @@ -1,7 +1,10 @@ package threads_account import ( + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" domusecase "haixun-backend/internal/model/threads_account/domain/usecase" "haixun-backend/internal/types" ) @@ -97,20 +100,80 @@ func toPublishQueueItemData(item *pqdomain.QueueItemSummary) *types.PublishQueue return nil } return &types.PublishQueueItemData{ - ID: item.ID, - AccountID: item.AccountID, - Text: item.Text, - ScheduledAt: item.ScheduledAt, - Status: item.Status, - MediaID: item.MediaID, - Permalink: item.Permalink, - PublishedAt: item.PublishedAt, - ErrorMessage: item.ErrorMessage, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, + ID: item.ID, + AccountID: item.AccountID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + CopyDraftID: item.CopyDraftID, + Text: item.Text, + ScheduledAt: item.ScheduledAt, + Status: item.Status, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, + RetryCount: item.RetryCount, + LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, + MissedAt: item.MissedAt, + PausedReason: item.PausedReason, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, } } +func toInventoryPolicyData(item *inventorydomain.PolicySummary) *types.PublishInventoryPolicyData { + if item == nil { + return nil + } + slots := make([]types.PublishSlotData, 0, len(item.Slots)) + for _, slot := range item.Slots { + slots = append(slots, types.PublishSlotData{Weekday: slot.Weekday, Time: slot.Time}) + } + refs := make([]types.PublishSourceRefData, 0, len(item.SourceRefs)) + for _, ref := range item.SourceRefs { + refs = append(refs, types.PublishSourceRefData{ + Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed, + }) + } + return &types.PublishInventoryPolicyData{ + AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount, + LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays, + Timezone: item.Timezone, Slots: slots, SourceRefs: refs, UpdateAt: item.UpdateAt, + } +} + +func toGuardPolicyData(item *guarddomain.PolicySummary) *types.PublishGuardPolicyData { + if item == nil { + return nil + } + return &types.PublishGuardPolicyData{ + AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts, + MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit, + Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt, + } +} + +func toQueueEventData(items []eventdomain.EventSummary) []types.PublishQueueEventData { + out := make([]types.PublishQueueEventData, 0, len(items)) + for _, item := range items { + out = append(out, types.PublishQueueEventData{ + ID: item.ID, QueueID: item.QueueID, EventType: item.EventType, + FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt, + }) + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func toPublishQueueListData(result *pqdomain.ListResult) *types.PublishQueueListData { if result == nil { return &types.PublishQueueListData{List: []types.PublishQueueItemData{}} diff --git a/backend/internal/logic/threads_account/resume_publish_guard_logic.go b/backend/internal/logic/threads_account/resume_publish_guard_logic.go new file mode 100644 index 0000000..8a52f9d --- /dev/null +++ b/backend/internal/logic/threads_account/resume_publish_guard_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ResumePublishGuardLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewResumePublishGuardLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResumePublishGuardLogic { + return &ResumePublishGuardLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ResumePublishGuardLogic) ResumePublishGuard(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Resume(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go new file mode 100644 index 0000000..bd0b8e1 --- /dev/null +++ b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RetryPublishQueueItemLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRetryPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryPublishQueueItemLogic { + return &RetryPublishQueueItemLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RetryPublishQueueItemLogic) RetryPublishQueueItem(req *types.PublishQueueItemPath) (resp *types.PublishQueueItemData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishQueue.Retry(l.ctx, tenantID, uid, req.ID, req.QID) + if err != nil { + return nil, err + } + return toPublishQueueItemData(item), nil +} diff --git a/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go new file mode 100644 index 0000000..dcda058 --- /dev/null +++ b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go @@ -0,0 +1,61 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPublishInventoryRefillJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic { + return &StartPublishInventoryRefillJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + count := req.Count + if count <= 0 { + count = 3 + } + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "refill-publish-inventory", + Scope: "threads_account", + ScopeID: req.ID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "account_id": req.ID, + "count": count, + }, + }) + if err != nil { + return nil, err + } + return &types.StartPublishInventoryRefillJobData{ + JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立", + }, nil +} diff --git a/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go new file mode 100644 index 0000000..8eb83ad --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go @@ -0,0 +1,45 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertPublishGuardPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishGuardPolicyLogic { + return &UpsertPublishGuardPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertPublishGuardPolicyLogic) UpsertPublishGuardPolicy(req *types.UpsertPublishGuardPolicyHandlerReq) (resp *types.PublishGuardPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PublishGuard.Upsert(l.ctx, guarddomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled, + MaxDailyPosts: req.MaxDailyPosts, MinIntervalMinutes: req.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: req.ConsecutiveFailurePauseLimit, + Paused: req.Paused, PausedReason: req.PausedReason, + }) + if err != nil { + return nil, err + } + return toGuardPolicyData(item), nil +} diff --git a/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go new file mode 100644 index 0000000..a46c585 --- /dev/null +++ b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go @@ -0,0 +1,55 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package threads_account + +import ( + "context" + + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertPublishInventoryPolicyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishInventoryPolicyLogic { + return &UpsertPublishInventoryPolicyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpsertPublishInventoryPolicyLogic) UpsertPublishInventoryPolicy(req *types.UpsertPublishInventoryPolicyHandlerReq) (resp *types.PublishInventoryPolicyData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + slots := make([]inventorydomain.Slot, 0, len(req.Slots)) + for _, slot := range req.Slots { + slots = append(slots, inventorydomain.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + refs := make([]inventorydomain.SourceRef, 0, len(req.SourceRefs)) + for _, ref := range req.SourceRefs { + refs = append(refs, inventorydomain.SourceRef{ + Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, + ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed, + }) + } + item, err := l.svcCtx.PublishInventory.Upsert(l.ctx, inventorydomain.UpsertRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled, + TargetDailyCount: req.TargetDailyCount, LowStockThreshold: req.LowStockThreshold, + LookaheadDays: req.LookaheadDays, Timezone: req.Timezone, Slots: slots, SourceRefs: refs, + }) + if err != nil { + return nil, err + } + return toInventoryPolicyData(item), nil +} diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index 75e6eb8..a192603 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -25,6 +25,7 @@ type CopyDraft struct { ReferenceNotes string `bson:"reference_notes,omitempty"` Sources []string `bson:"sources,omitempty"` Status string `bson:"status,omitempty"` + PublishQueueID string `bson:"publish_queue_id,omitempty"` PublishedMediaID string `bson:"published_media_id,omitempty"` PublishedPermalink string `bson:"published_permalink,omitempty"` PublishedAt int64 `bson:"published_at,omitempty"` diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index 386601f..2aac7e9 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -18,6 +18,7 @@ type CopyDraftSummary struct { ReferenceNotes string Sources []string Status string + PublishQueueID string PublishedMediaID string PublishedPermalink string PublishedAt int64 @@ -33,6 +34,14 @@ type MarkPublishedRequest struct { Permalink string } +type MarkScheduledRequest struct { + TenantID string + OwnerUID string + PersonaID string + DraftID string + QueueID string +} + type CreateRequest struct { TenantID string OwnerUID string @@ -86,6 +95,7 @@ type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*CopyDraftSummary, error) Update(ctx context.Context, req UpdateRequest) (*CopyDraftSummary, error) MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error) + MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error diff --git a/backend/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go index 55f6392..e376d71 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -151,8 +151,8 @@ func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequ } if req.Patch.Status != nil { status := strings.TrimSpace(*req.Patch.Status) - if status != "" && status != "pending" && status != "ready" { - return nil, app.For(code.Persona).InputMissingRequired("status must be pending or ready") + if status != "" && status != "pending" && status != "ready" && status != "scheduled" { + return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready or scheduled") } if status != "" { patch["status"] = status @@ -181,6 +181,7 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar now := clock.NowUnixNano() patch := map[string]interface{}{ "status": "published", + "publish_queue_id": "", "published_media_id": mediaID, "published_permalink": strings.TrimSpace(req.Permalink), "published_at": now, @@ -193,6 +194,26 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar return &summary, nil } +func (u *copyDraftUseCase) MarkScheduled(ctx context.Context, req domusecase.MarkScheduledRequest) (*domusecase.CopyDraftSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + queueID := strings.TrimSpace(req.QueueID) + if draftID == "" || queueID == "" { + return nil, app.For(code.Persona).InputMissingRequired("draft_id and queue_id are required") + } + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, map[string]interface{}{ + "status": "scheduled", + "publish_queue_id": queueID, + }) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + func (u *copyDraftUseCase) ReplaceMissionMatrix( ctx context.Context, tenantID, ownerUID, personaID, missionID string, @@ -388,6 +409,7 @@ func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary { ReferenceNotes: item.ReferenceNotes, Sources: item.Sources, Status: item.Status, + PublishQueueID: item.PublishQueueID, PublishedMediaID: item.PublishedMediaID, PublishedPermalink: item.PublishedPermalink, PublishedAt: item.PublishedAt, diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 1b45e59..739647b 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -102,6 +102,7 @@ type UseCase interface { EnsureGenerateCopyDraftTemplate(ctx context.Context) error EnsureRefreshThreadsTokenTemplate(ctx context.Context) error EnsurePublishAnalyticsTemplate(ctx context.Context) error + EnsureRefillPublishInventoryTemplate(ctx context.Context) error CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error) GetRun(ctx context.Context, jobID string) (*entity.Run, error) diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 8145ff3..73af24e 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -25,6 +25,7 @@ const ( generateCopyDraftTemplateType = "generate-copy-draft" refreshThreadsTokenTemplateType = "refresh-threads-token" publishAnalyticsTemplateType = "publish-analytics" + refillPublishInventoryTemplateType = "refill-publish-inventory" style8DWorkerType = "node" ) @@ -117,6 +118,11 @@ func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error { return err } +func (u *jobUseCase) EnsureRefillPublishInventoryTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, refillPublishInventoryTemplate()) + return err +} + func expandCopyMissionGraphTemplate() *entity.Template { return &entity.Template{ Type: expandCopyMissionGraphTemplateType, @@ -269,6 +275,32 @@ func publishAnalyticsTemplate() *entity.Template { } } +func refillPublishInventoryTemplate() *entity.Template { + return &entity.Template{ + Type: refillPublishInventoryTemplateType, + Version: 1, + Name: "Refill Publish Inventory", + Description: "Generate and schedule drafts when Threads publish inventory is low", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), + DedupeKeys: []string{"account_id"}, + TimeoutSeconds: 900, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "refill_publish_inventory", Name: "Refill publish inventory", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 900, Cancelable: true}, + }, + } +} + func scanViralTemplate() *entity.Template { return &entity.Template{ Type: scanViralTemplateType, diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go index 8fa0cd8..ad73322 100644 --- a/backend/internal/model/permission/usecase/usecase.go +++ b/backend/internal/model/permission/usecase/usecase.go @@ -95,14 +95,14 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member, if err != nil { return nil, err } - if len(perms) == 0 { - perms, err = u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen}) - if err != nil { - return nil, err - } - if !hasRole(roles, "admin") { - perms = filterPermissionsByName(perms, defaultUserPermissionNames()) - } + openPerms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen}) + if err != nil { + return nil, err + } + if hasRole(roles, "admin") { + perms = mergePermissions(perms, openPerms) + } else { + perms = mergePermissions(perms, filterPermissionsByName(openPerms, defaultUserPermissionNames())) } nodes := permissionNodes(perms) result := &domusecase.MePermissions{ @@ -169,6 +169,28 @@ func filterPermissionsByName(items []*entity.Permission, names []string) []*enti return out } +func mergePermissions(base []*entity.Permission, extras []*entity.Permission) []*entity.Permission { + seen := map[string]struct{}{} + out := make([]*entity.Permission, 0, len(base)+len(extras)) + for _, group := range [][]*entity.Permission{base, extras} { + for _, item := range group { + if item == nil { + continue + } + key := item.Name + if key == "" { + key = item.ID.Hex() + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + } + return out +} + func defaultUserPermissionNames() []string { return []string{ "auth.logout", @@ -251,7 +273,7 @@ func defaultPermissions() []*entity.Permission { {Name: "brand.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/brands/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "persona.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/personas/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "placement_topic.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/placement/topics/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, - {Name: "threads_account.manage", HTTPMethods: "GET|POST|PUT|PATCH", HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, + {Name: "threads_account.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "job.manage", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "job.schedule.manage", HTTPMethods: "GET|POST|PUT|DELETE", HTTPPath: "/api/v1/job/schedules/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go index dcd2ced..b54dd32 100644 --- a/backend/internal/model/publish_analytics/domain/repository/repository.go +++ b/backend/internal/model/publish_analytics/domain/repository/repository.go @@ -12,4 +12,5 @@ type Repository interface { Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) + DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) } diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go index 6a5862d..29d78ae 100644 --- a/backend/internal/model/publish_analytics/repository/mongo.go +++ b/backend/internal/model/publish_analytics/repository/mongo.go @@ -115,7 +115,9 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA return nil, app.For(code.AI).InputMissingRequired("analytics id is required") } - filter := bson.M{"_id": analytics.ID} + filter := actorFilter(analytics.TenantID, analytics.OwnerUID) + filter["media_id"] = strings.TrimSpace(analytics.MediaID) + filter["checkpoint"] = strings.TrimSpace(analytics.Checkpoint) opts := options.Replace().SetUpsert(true) _, err := r.collection.ReplaceOne(ctx, filter, analytics, opts) if err != nil { @@ -123,3 +125,32 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA } return analytics, nil } + +func (r *mongoRepository) DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.AI).DBUnavailable("Mongo is not configured") + } + ids := make([]string, 0, len(mediaIDs)) + seen := map[string]struct{}{} + for _, mediaID := range mediaIDs { + trimmed := strings.TrimSpace(mediaID) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + ids = append(ids, trimmed) + } + if len(ids) == 0 { + return 0, nil + } + filter := actorFilter(tenantID, ownerUID) + filter["media_id"] = bson.M{"$in": ids} + res, err := r.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} diff --git a/backend/internal/model/publish_draft/domain/entity/draft.go b/backend/internal/model/publish_draft/domain/entity/draft.go index 7ace8d6..c3071f8 100644 --- a/backend/internal/model/publish_draft/domain/entity/draft.go +++ b/backend/internal/model/publish_draft/domain/entity/draft.go @@ -12,4 +12,4 @@ type Draft struct { Tags []string `bson:"tags,omitempty"` CreateAt int64 `bson:"create_at"` UpdateAt int64 `bson:"update_at"` -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_draft/domain/repository/repository.go b/backend/internal/model/publish_draft/domain/repository/repository.go index 61a8f49..0011df8 100644 --- a/backend/internal/model/publish_draft/domain/repository/repository.go +++ b/backend/internal/model/publish_draft/domain/repository/repository.go @@ -18,4 +18,4 @@ type Repository interface { List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error) Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_draft/domain/usecase/usecase.go b/backend/internal/model/publish_draft/domain/usecase/usecase.go index c0db6bc..e62e87d 100644 --- a/backend/internal/model/publish_draft/domain/usecase/usecase.go +++ b/backend/internal/model/publish_draft/domain/usecase/usecase.go @@ -45,4 +45,4 @@ type UseCase interface { List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error) Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error -} \ No newline at end of file +} diff --git a/backend/internal/model/publish_guard/domain/entity/policy.go b/backend/internal/model/publish_guard/domain/entity/policy.go new file mode 100644 index 0000000..5e703e6 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/entity/policy.go @@ -0,0 +1,18 @@ +package entity + +const CollectionName = "publish_guard_policies" + +type Policy struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Enabled bool `bson:"enabled"` + MaxDailyPosts int `bson:"max_daily_posts"` + MinIntervalMinutes int `bson:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `bson:"consecutive_failure_pause_limit"` + Paused bool `bson:"paused"` + PausedReason string `bson:"paused_reason,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_guard/domain/repository/repository.go b/backend/internal/model/publish_guard/domain/repository/repository.go new file mode 100644 index 0000000..a958381 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/repository/repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_guard/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) + Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_guard/domain/usecase/usecase.go b/backend/internal/model/publish_guard/domain/usecase/usecase.go new file mode 100644 index 0000000..4ec89b7 --- /dev/null +++ b/backend/internal/model/publish_guard/domain/usecase/usecase.go @@ -0,0 +1,51 @@ +package usecase + +import "context" + +type PolicySummary struct { + AccountID string + Enabled bool + MaxDailyPosts int + MinIntervalMinutes int + ConsecutiveFailurePauseLimit int + Paused bool + PausedReason string + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + AccountID string + Enabled bool + MaxDailyPosts int + MinIntervalMinutes int + ConsecutiveFailurePauseLimit int + Paused *bool + PausedReason string +} + +type CheckRequest struct { + TenantID string + OwnerUID string + AccountID string + Now int64 + PublishedToday int64 + LastPublishedAt int64 + FailureStreak int +} + +type CheckResult struct { + Allowed bool + DelayUntil int64 + Reason string + ShouldPause bool +} + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error) + Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Check(ctx context.Context, req CheckRequest) (*CheckResult, error) + Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*PolicySummary, error) +} diff --git a/backend/internal/model/publish_guard/repository/mongo.go b/backend/internal/model/publish_guard/repository/mongo.go new file mode 100644 index 0000000..dcc262e --- /dev/null +++ b/backend/internal/model/publish_guard/repository/mongo.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_guard/domain/entity" + domrepo "haixun-backend/internal/model/publish_guard/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Policy + err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish guard policy not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if policy == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required") + } + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID), policy, opts) + if err != nil { + return nil, err + } + return policy, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} diff --git a/backend/internal/model/publish_guard/usecase/usecase.go b/backend/internal/model/publish_guard/usecase/usecase.go new file mode 100644 index 0000000..e7777b6 --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase.go @@ -0,0 +1,171 @@ +package usecase + +import ( + "context" + "strings" + "time" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_guard/domain/entity" + domrepo "haixun-backend/internal/model/publish_guard/domain/repository" + domusecase "haixun-backend/internal/model/publish_guard/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +type guardUseCase struct { + repo domrepo.Repository + threadsAccount threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase { + return &guardUseCase{repo: repo, threadsAccount: threadsAccount} +} + +func (u *guardUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + item = defaultPolicy(tenantID, ownerUID, accountID) + } else { + return nil, err + } + } + out := toSummary(item) + return &out, nil +} + +func (u *guardUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID) + item.Enabled = req.Enabled + if req.MaxDailyPosts > 0 { + item.MaxDailyPosts = req.MaxDailyPosts + } + if req.MinIntervalMinutes > 0 { + item.MinIntervalMinutes = req.MinIntervalMinutes + } + if req.ConsecutiveFailurePauseLimit > 0 { + item.ConsecutiveFailurePauseLimit = req.ConsecutiveFailurePauseLimit + } + if req.Paused != nil { + item.Paused = *req.Paused + } + item.PausedReason = strings.TrimSpace(req.PausedReason) + item.UpdateAt = clock.NowUnixNano() + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + out := toSummary(saved) + return &out, nil +} + +func (u *guardUseCase) Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + policy, err := u.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + paused := false + return u.Upsert(ctx, domusecase.UpsertRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled, + MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit, + Paused: &paused, + }) +} + +func (u *guardUseCase) Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*domusecase.PolicySummary, error) { + policy, err := u.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + paused := true + return u.Upsert(ctx, domusecase.UpsertRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled, + MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes, + ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit, + Paused: &paused, PausedReason: reason, + }) +} + +func (u *guardUseCase) Check(ctx context.Context, req domusecase.CheckRequest) (*domusecase.CheckResult, error) { + policy, err := u.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID) + if err != nil { + return nil, err + } + if !policy.Enabled { + return &domusecase.CheckResult{Allowed: true}, nil + } + now := req.Now + if now <= 0 { + now = clock.NowUnixNano() + } + if policy.Paused { + return &domusecase.CheckResult{Allowed: false, Reason: firstNonEmpty(policy.PausedReason, "發文已暫停")}, nil + } + if policy.MaxDailyPosts > 0 && req.PublishedToday >= int64(policy.MaxDailyPosts) { + return &domusecase.CheckResult{Allowed: false, DelayUntil: now + int64(time.Hour), Reason: "已達每日發文上限"}, nil + } + if policy.MinIntervalMinutes > 0 && req.LastPublishedAt > 0 { + minNext := req.LastPublishedAt + int64(time.Duration(policy.MinIntervalMinutes)*time.Minute) + if minNext > now { + return &domusecase.CheckResult{Allowed: false, DelayUntil: minNext, Reason: "未達最小發文間隔"}, nil + } + } + if policy.ConsecutiveFailurePauseLimit > 0 && req.FailureStreak >= policy.ConsecutiveFailurePauseLimit { + return &domusecase.CheckResult{Allowed: false, Reason: "連續失敗達上限,已暫停帳號發文", ShouldPause: true}, nil + } + return &domusecase.CheckResult{Allowed: true}, nil +} + +func (u *guardUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if u.threadsAccount != nil { + _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID) + return err + } + return nil +} + +func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy { + now := clock.NowUnixNano() + return &entity.Policy{ + ID: uuid.NewString(), TenantID: strings.TrimSpace(tenantID), OwnerUID: strings.TrimSpace(ownerUID), AccountID: strings.TrimSpace(accountID), + Enabled: true, MaxDailyPosts: 6, MinIntervalMinutes: 90, ConsecutiveFailurePauseLimit: 3, + CreateAt: now, UpdateAt: now, + } +} + +func toSummary(item *entity.Policy) domusecase.PolicySummary { + if item == nil { + return domusecase.PolicySummary{} + } + return domusecase.PolicySummary{ + AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts, + MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit, + Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/model/publish_guard/usecase/usecase_test.go b/backend/internal/model/publish_guard/usecase/usecase_test.go new file mode 100644 index 0000000..6c829ad --- /dev/null +++ b/backend/internal/model/publish_guard/usecase/usecase_test.go @@ -0,0 +1,100 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "haixun-backend/internal/model/publish_guard/domain/entity" + domusecase "haixun-backend/internal/model/publish_guard/domain/usecase" +) + +type guardRepoStub struct { + policy *entity.Policy +} + +func (r *guardRepoStub) EnsureIndexes(ctx context.Context) error { return nil } +func (r *guardRepoStub) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.policy == nil { + r.policy = defaultPolicy(tenantID, ownerUID, accountID) + } + return r.policy, nil +} +func (r *guardRepoStub) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + r.policy = policy + return policy, nil +} +func (r *guardRepoStub) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + r.policy = nil + return nil +} + +func TestGuardCheckBlocksPausedPolicy(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.Paused = true + repo.policy.PausedReason = "token error" + uc := NewUseCase(repo, nil) + + result, err := uc.Check(context.Background(), checkReq()) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.Reason != "token error" { + t.Fatalf("expected paused block, got %#v", result) + } +} + +func TestGuardCheckDelaysWhenDailyLimitReached(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.MaxDailyPosts = 1 + uc := NewUseCase(repo, nil) + + result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) { + req.publishedToday = 1 + })) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.DelayUntil == 0 { + t.Fatalf("expected delayed daily limit, got %#v", result) + } +} + +func TestGuardCheckDelaysWhenIntervalNotReached(t *testing.T) { + repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")} + repo.policy.MinIntervalMinutes = 90 + uc := NewUseCase(repo, nil) + + now := time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano() + result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) { + req.now = now + req.lastPublishedAt = now - int64(30*time.Minute) + })) + if err != nil { + t.Fatal(err) + } + if result.Allowed || result.DelayUntil <= now { + t.Fatalf("expected interval delay, got %#v", result) + } +} + +type CheckRequestBuilder struct { + now int64 + publishedToday int64 + lastPublishedAt int64 +} + +func checkReq() domusecase.CheckRequest { + return checkReqWith(nil) +} + +func checkReqWith(update func(*CheckRequestBuilder)) domusecase.CheckRequest { + builder := &CheckRequestBuilder{now: time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()} + if update != nil { + update(builder) + } + return domusecase.CheckRequest{ + TenantID: "t", OwnerUID: "u", AccountID: "a", + Now: builder.now, PublishedToday: builder.publishedToday, LastPublishedAt: builder.lastPublishedAt, + } +} diff --git a/backend/internal/model/publish_inventory/domain/entity/policy.go b/backend/internal/model/publish_inventory/domain/entity/policy.go new file mode 100644 index 0000000..053d97e --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/entity/policy.go @@ -0,0 +1,32 @@ +package entity + +const CollectionName = "publish_inventory_policies" + +type Slot struct { + Weekday int `bson:"weekday" json:"weekday"` + Time string `bson:"time" json:"time"` +} + +type SourceRef struct { + Type string `bson:"type" json:"type"` + PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty" json:"copy_mission_id,omitempty"` + ScanPostID string `bson:"scan_post_id,omitempty" json:"scan_post_id,omitempty"` + ManualSeed string `bson:"manual_seed,omitempty" json:"manual_seed,omitempty"` +} + +type Policy struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Enabled bool `bson:"enabled"` + TargetDailyCount int `bson:"target_daily_count"` + LowStockThreshold int `bson:"low_stock_threshold"` + LookaheadDays int `bson:"lookahead_days"` + Timezone string `bson:"timezone"` + Slots []Slot `bson:"slots,omitempty"` + SourceRefs []SourceRef `bson:"source_refs,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/publish_inventory/domain/repository/repository.go b/backend/internal/model/publish_inventory/domain/repository/repository.go new file mode 100644 index 0000000..e55a79b --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_inventory/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) + Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) + ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_inventory/domain/usecase/usecase.go b/backend/internal/model/publish_inventory/domain/usecase/usecase.go new file mode 100644 index 0000000..68525d3 --- /dev/null +++ b/backend/internal/model/publish_inventory/domain/usecase/usecase.go @@ -0,0 +1,47 @@ +package usecase + +import "context" + +type Slot struct { + Weekday int + Time string +} + +type SourceRef struct { + Type string + PersonaID string + CopyMissionID string + ScanPostID string + ManualSeed string +} + +type PolicySummary struct { + AccountID string + Enabled bool + TargetDailyCount int + LowStockThreshold int + LookaheadDays int + Timezone string + Slots []Slot + SourceRefs []SourceRef + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + AccountID string + Enabled bool + TargetDailyCount int + LowStockThreshold int + LookaheadDays int + Timezone string + Slots []Slot + SourceRefs []SourceRef +} + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error) + ListEnabled(ctx context.Context, limit int) ([]PolicySummary, error) +} diff --git a/backend/internal/model/publish_inventory/repository/mongo.go b/backend/internal/model/publish_inventory/repository/mongo.go new file mode 100644 index 0000000..3edcef1 --- /dev/null +++ b/backend/internal/model/publish_inventory/repository/mongo.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_inventory/domain/entity" + domrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "enabled", Value: 1}}}, + }) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Policy + err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("publish inventory policy not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if policy == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required") + } + filter := actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, policy, opts); err != nil { + return nil, err + } + return policy, nil +} + +func (r *mongoRepository) ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 100 + } + cur, err := r.collection.Find(ctx, bson.M{"enabled": true}, options.Find().SetLimit(int64(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Policy + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} diff --git a/backend/internal/model/publish_inventory/usecase/usecase.go b/backend/internal/model/publish_inventory/usecase/usecase.go new file mode 100644 index 0000000..497c412 --- /dev/null +++ b/backend/internal/model/publish_inventory/usecase/usecase.go @@ -0,0 +1,174 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_inventory/domain/entity" + domrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + domusecase "haixun-backend/internal/model/publish_inventory/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" + + "github.com/google/uuid" +) + +type inventoryUseCase struct { + repo domrepo.Repository + threadsAccount threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase { + return &inventoryUseCase{repo: repo, threadsAccount: threadsAccount} +} + +func (u *inventoryUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + item = defaultPolicy(tenantID, ownerUID, accountID) + } else { + return nil, err + } + } + out := toSummary(item) + return &out, nil +} + +func (u *inventoryUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID) + item.Enabled = req.Enabled + if req.TargetDailyCount > 0 { + item.TargetDailyCount = req.TargetDailyCount + } + if req.LowStockThreshold > 0 { + item.LowStockThreshold = req.LowStockThreshold + } + if req.LookaheadDays > 0 { + item.LookaheadDays = req.LookaheadDays + } + if strings.TrimSpace(req.Timezone) != "" { + item.Timezone = strings.TrimSpace(req.Timezone) + } + item.Slots = slotsToEntity(req.Slots) + item.SourceRefs = sourceRefsToEntity(req.SourceRefs) + now := clock.NowUnixNano() + item.UpdateAt = now + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + out := toSummary(saved) + return &out, nil +} + +func (u *inventoryUseCase) ListEnabled(ctx context.Context, limit int) ([]domusecase.PolicySummary, error) { + items, err := u.repo.ListEnabled(ctx, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.PolicySummary, 0, len(items)) + for _, item := range items { + summary := toSummary(&item) + out = append(out, summary) + } + return out, nil +} + +func (u *inventoryUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if u.threadsAccount != nil { + _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID) + return err + } + return nil +} + +func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy { + now := clock.NowUnixNano() + return &entity.Policy{ + ID: uuid.NewString(), + TenantID: strings.TrimSpace(tenantID), + OwnerUID: strings.TrimSpace(ownerUID), + AccountID: strings.TrimSpace(accountID), + Enabled: false, + TargetDailyCount: 2, + LowStockThreshold: 3, + LookaheadDays: 7, + Timezone: "Asia/Taipei", + Slots: []entity.Slot{ + {Weekday: 1, Time: "09:30"}, + {Weekday: 3, Time: "12:30"}, + {Weekday: 5, Time: "18:30"}, + }, + CreateAt: now, + UpdateAt: now, + } +} + +func toSummary(item *entity.Policy) domusecase.PolicySummary { + if item == nil { + return domusecase.PolicySummary{} + } + return domusecase.PolicySummary{ + AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount, + LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays, + Timezone: item.Timezone, Slots: slotsFromEntity(item.Slots), SourceRefs: sourceRefsFromEntity(item.SourceRefs), UpdateAt: item.UpdateAt, + } +} + +func slotsToEntity(items []domusecase.Slot) []entity.Slot { + out := make([]entity.Slot, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Time) == "" { + continue + } + out = append(out, entity.Slot{Weekday: item.Weekday, Time: strings.TrimSpace(item.Time)}) + } + return out +} + +func slotsFromEntity(items []entity.Slot) []domusecase.Slot { + out := make([]domusecase.Slot, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.Slot{Weekday: item.Weekday, Time: item.Time}) + } + return out +} + +func sourceRefsToEntity(items []domusecase.SourceRef) []entity.SourceRef { + out := make([]entity.SourceRef, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Type) == "" { + continue + } + out = append(out, entity.SourceRef{ + Type: strings.TrimSpace(item.Type), PersonaID: strings.TrimSpace(item.PersonaID), + CopyMissionID: strings.TrimSpace(item.CopyMissionID), ScanPostID: strings.TrimSpace(item.ScanPostID), ManualSeed: strings.TrimSpace(item.ManualSeed), + }) + } + return out +} + +func sourceRefsFromEntity(items []entity.SourceRef) []domusecase.SourceRef { + out := make([]domusecase.SourceRef, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.SourceRef{ + Type: item.Type, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, ManualSeed: item.ManualSeed, + }) + } + return out +} diff --git a/backend/internal/model/publish_queue/domain/entity/queue_item.go b/backend/internal/model/publish_queue/domain/entity/queue_item.go index 4791f42..a648a15 100644 --- a/backend/internal/model/publish_queue/domain/entity/queue_item.go +++ b/backend/internal/model/publish_queue/domain/entity/queue_item.go @@ -8,20 +8,29 @@ const ( StatusPublished = "published" StatusFailed = "failed" StatusCancelled = "cancelled" + StatusMissed = "missed" ) type QueueItem struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - AccountID string `bson:"account_id"` - Text string `bson:"text"` - ScheduledAt int64 `bson:"scheduled_at"` - Status string `bson:"status"` - MediaID string `bson:"media_id,omitempty"` - Permalink string `bson:"permalink,omitempty"` - PublishedAt int64 `bson:"published_at,omitempty"` - ErrorMessage string `bson:"error_message,omitempty"` - CreateAt int64 `bson:"create_at"` - UpdateAt int64 `bson:"update_at"` + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + PersonaID string `bson:"persona_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` + CopyDraftID string `bson:"copy_draft_id,omitempty"` + Text string `bson:"text"` + ScheduledAt int64 `bson:"scheduled_at"` + Status string `bson:"status"` + MediaID string `bson:"media_id,omitempty"` + Permalink string `bson:"permalink,omitempty"` + PublishedAt int64 `bson:"published_at,omitempty"` + ErrorMessage string `bson:"error_message,omitempty"` + RetryCount int `bson:"retry_count,omitempty"` + LastAttemptAt int64 `bson:"last_attempt_at,omitempty"` + NextAttemptAt int64 `bson:"next_attempt_at,omitempty"` + MissedAt int64 `bson:"missed_at,omitempty"` + PausedReason string `bson:"paused_reason,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` } diff --git a/backend/internal/model/publish_queue/domain/repository/repository.go b/backend/internal/model/publish_queue/domain/repository/repository.go index 6333ff1..8f23c1b 100644 --- a/backend/internal/model/publish_queue/domain/repository/repository.go +++ b/backend/internal/model/publish_queue/domain/repository/repository.go @@ -11,6 +11,8 @@ type ListFilter struct { OwnerUID string AccountID string Status string + StartAt int64 + EndAt int64 } type ListResult struct { @@ -24,8 +26,13 @@ type Repository interface { Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error) ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) + ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error) CountByStatus(ctx context.Context, tenantID, ownerUID, accountID string, status string) (int64, error) + CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) + LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error + ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) } diff --git a/backend/internal/model/publish_queue/domain/usecase/usecase.go b/backend/internal/model/publish_queue/domain/usecase/usecase.go index 66fb5bf..1565cce 100644 --- a/backend/internal/model/publish_queue/domain/usecase/usecase.go +++ b/backend/internal/model/publish_queue/domain/usecase/usecase.go @@ -3,25 +3,36 @@ package usecase import "context" type QueueItemSummary struct { - ID string - AccountID string - Text string - ScheduledAt int64 - Status string - MediaID string - Permalink string - PublishedAt int64 - ErrorMessage string - CreateAt int64 - UpdateAt int64 + ID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + ScheduledAt int64 + Status string + MediaID string + Permalink string + PublishedAt int64 + ErrorMessage string + RetryCount int + LastAttemptAt int64 + NextAttemptAt int64 + MissedAt int64 + PausedReason string + CreateAt int64 + UpdateAt int64 } type CreateRequest struct { - TenantID string - OwnerUID string - AccountID string - Text string - ScheduledAt int64 + TenantID string + OwnerUID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + ScheduledAt int64 } type UpdateRequest struct { @@ -42,6 +53,17 @@ type ListRequest struct { PageSize int } +type RangeRequest struct { + TenantID string + OwnerUID string + AccountID string + Status string + StartAt int64 + EndAt int64 + Page int + PageSize int +} + type ListResult struct { Items []QueueItemSummary Total int64 @@ -92,13 +114,38 @@ type PublishHealthResult struct { } type RecordPublishedRequest struct { - TenantID string - OwnerUID string - AccountID string - Text string - MediaID string - Permalink string - PublishedAt int64 + TenantID string + OwnerUID string + AccountID string + PersonaID string + CopyMissionID string + CopyDraftID string + Text string + MediaID string + Permalink string + PublishedAt int64 +} + +type SlotInsight struct { + Weekday int + Time string + AvgLikes1h int + AvgReplies1h int + AvgLikes24h int + AvgReplies24h int + AvgLikes7d int + AvgReplies7d int + SampleSize int + Recommendation string +} + +type Alert struct { + Type string + Severity string + Message string + QueueID string + AccountID string + CreateAt int64 } type UseCase interface { @@ -106,9 +153,14 @@ type UseCase interface { RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) List(ctx context.Context, req ListRequest) (*ListResult, error) + ListRange(ctx context.Context, req RangeRequest) (*ListResult, error) Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) + Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error DispatchDue(ctx context.Context, limit int) (int, error) + MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) + ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]Alert, error) + ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]SlotInsight, error) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*PublishHealthResult, error) } diff --git a/backend/internal/model/publish_queue/repository/mongo.go b/backend/internal/model/publish_queue/repository/mongo.go index ad5aa51..dc3f056 100644 --- a/backend/internal/model/publish_queue/repository/mongo.go +++ b/backend/internal/model/publish_queue/repository/mongo.go @@ -108,6 +108,16 @@ func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, p if status := strings.TrimSpace(filter.Status); status != "" { mongoFilter["status"] = status } + if filter.StartAt > 0 || filter.EndAt > 0 { + rangeFilter := bson.M{} + if filter.StartAt > 0 { + rangeFilter["$gte"] = filter.StartAt + } + if filter.EndAt > 0 { + rangeFilter["$lte"] = filter.EndAt + } + mongoFilter["scheduled_at"] = rangeFilter + } total, err := r.collection.CountDocuments(ctx, mongoFilter) if err != nil { return nil, err @@ -154,6 +164,32 @@ func (r *mongoRepository) ListDue(ctx context.Context, now int64, limit int) ([] return items, nil } +func (r *mongoRepository) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 20 + } + filter := bson.M{ + "status": entity.StatusScheduled, + "scheduled_at": bson.M{"$lte": before}, + } + opts := options.Find(). + SetSort(bson.D{{Key: "scheduled_at", Value: 1}}). + SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.QueueItem + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + func (r *mongoRepository) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) { if r.collection == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") @@ -221,3 +257,75 @@ func (r *mongoRepository) CountByStatus(ctx context.Context, tenantID, ownerUID, filter["status"] = strings.TrimSpace(status) return r.collection.CountDocuments(ctx, filter) } + +func (r *mongoRepository) CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["status"] = entity.StatusPublished + filter["published_at"] = bson.M{"$gte": since} + return r.collection.CountDocuments(ctx, filter) +} + +func (r *mongoRepository) LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["status"] = entity.StatusPublished + opts := options.FindOne().SetSort(bson.D{{Key: "published_at", Value: -1}}) + var out entity.QueueItem + err := r.collection.FindOne(ctx, filter, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return 0, nil + } + return 0, err + } + return out.PublishedAt, nil +} + +func (r *mongoRepository) ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["media_id"] = bson.M{"$ne": ""} + cur, err := r.collection.Find(ctx, filter, options.Find().SetProjection(bson.M{"media_id": 1})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + seen := map[string]struct{}{} + var ids []string + for cur.Next(ctx) { + var row struct { + MediaID string `bson:"media_id"` + } + if err := cur.Decode(&row); err != nil { + return nil, err + } + if id := strings.TrimSpace(row.MediaID); id != "" { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + ids = append(ids, id) + } + } + } + if err := cur.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID)) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} diff --git a/backend/internal/model/publish_queue/usecase/usecase.go b/backend/internal/model/publish_queue/usecase/usecase.go index 4281f11..6139158 100644 --- a/backend/internal/model/publish_queue/usecase/usecase.go +++ b/backend/internal/model/publish_queue/usecase/usecase.go @@ -10,10 +10,14 @@ import ( "haixun-backend/internal/library/errors/code" libthreads "haixun-backend/internal/library/threadsapi" "haixun-backend/internal/library/threadspost" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" padomain "haixun-backend/internal/model/publish_analytics/domain/usecase" + guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" "haixun-backend/internal/model/publish_queue/domain/entity" domrepo "haixun-backend/internal/model/publish_queue/domain/repository" domusecase "haixun-backend/internal/model/publish_queue/domain/usecase" + evententity "haixun-backend/internal/model/publish_queue_event/domain/entity" + eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" "github.com/google/uuid" @@ -23,17 +27,26 @@ type publishQueueUseCase struct { repo domrepo.Repository threadsAccount threadsaccountdomain.UseCase publishAnalytics padomain.UseCase + guard guarddomain.UseCase + events eventdomain.UseCase + copyDraft copydraftdomain.UseCase } func NewUseCase( repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase, publishAnalytics padomain.UseCase, + guard guarddomain.UseCase, + events eventdomain.UseCase, + copyDraft copydraftdomain.UseCase, ) domusecase.UseCase { return &publishQueueUseCase{ repo: repo, threadsAccount: threadsAccount, publishAnalytics: publishAnalytics, + guard: guard, + events: events, + copyDraft: copyDraft, } } @@ -65,19 +78,23 @@ func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateR } item := &entity.QueueItem{ - ID: uuid.NewString(), - TenantID: tenantID, - OwnerUID: ownerUID, - AccountID: accountID, - Text: text, - ScheduledAt: scheduledAt, - Status: entity.StatusScheduled, - CreateAt: now, - UpdateAt: now, + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + PersonaID: strings.TrimSpace(req.PersonaID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + CopyDraftID: strings.TrimSpace(req.CopyDraftID), + Text: text, + ScheduledAt: scheduledAt, + Status: entity.StatusScheduled, + CreateAt: now, + UpdateAt: now, } if err := u.repo.Create(ctx, item); err != nil { return nil, err } + u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存") if scheduledAt <= now { _, _ = u.DispatchDue(ctx, 1) @@ -112,22 +129,26 @@ func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecas publishedAt = now } item := &entity.QueueItem{ - ID: uuid.NewString(), - TenantID: tenantID, - OwnerUID: ownerUID, - AccountID: accountID, - Text: strings.TrimSpace(req.Text), - ScheduledAt: publishedAt, - Status: entity.StatusPublished, - MediaID: mediaID, - Permalink: strings.TrimSpace(req.Permalink), - PublishedAt: publishedAt, - CreateAt: now, - UpdateAt: now, + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + PersonaID: strings.TrimSpace(req.PersonaID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + CopyDraftID: strings.TrimSpace(req.CopyDraftID), + Text: strings.TrimSpace(req.Text), + ScheduledAt: publishedAt, + Status: entity.StatusPublished, + MediaID: mediaID, + Permalink: strings.TrimSpace(req.Permalink), + PublishedAt: publishedAt, + CreateAt: now, + UpdateAt: now, } if err := u.repo.Create(ctx, item); err != nil { return nil, err } + u.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果") _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt, @@ -188,6 +209,38 @@ func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListReque }, nil } +func (u *publishQueueUseCase) ListRange(ctx context.Context, req domusecase.RangeRequest) (*domusecase.ListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + page, pageSize := req.Page, req.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + result, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID, + Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, + }, page, pageSize) + if err != nil { + return nil, err + } + items := make([]domusecase.QueueItemSummary, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, toSummary(item)) + } + totalPages := 0 + if pageSize > 0 { + totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize)) + } + return &domusecase.ListResult{Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil +} + func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err @@ -238,6 +291,39 @@ func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, ac if err != nil { return nil, err } + u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程") + out := toSummary(*updated) + return &out, nil +} + +func (u *publishQueueUseCase) Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) + if err != nil { + return nil, err + } + if item.Status != entity.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed { + return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試") + } + from := item.Status + item.Status = entity.StatusScheduled + item.ErrorMessage = "" + item.PausedReason = "" + item.MissedAt = 0 + item.RetryCount++ + item.NextAttemptAt = 0 + now := clock.NowUnixNano() + item.UpdateAt = now + if item.ScheduledAt < now { + item.ScheduledAt = now + } + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed}) + if err != nil { + return nil, err + } + u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程") out := toSummary(*updated) return &out, nil } @@ -283,12 +369,33 @@ func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, } func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error { + if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed { + if result.ShouldPause && u.guard != nil { + _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason) + } + item.PausedReason = result.Reason + if result.DelayUntil > 0 { + item.ScheduledAt = result.DelayUntil + } else { + item.Status = entity.StatusFailed + item.ErrorMessage = result.Reason + } + item.UpdateAt = clock.NowUnixNano() + updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) + if updateErr != nil { + return updateErr + } + u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason) + return nil + } item.Status = entity.StatusPublishing + item.LastAttemptAt = clock.NowUnixNano() item.UpdateAt = clock.NowUnixNano() claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) if err != nil { return err } + u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布") account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID) if err != nil { @@ -322,6 +429,13 @@ func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.Queu if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil { return err } + u.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成") + if claimed.CopyDraftID != "" && u.copyDraft != nil { + _, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{ + TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID, + DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink, + }) + } _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID, MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now, @@ -332,8 +446,16 @@ func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.Queu func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error { item.Status = entity.StatusFailed item.ErrorMessage = message + item.LastAttemptAt = clock.NowUnixNano() item.UpdateAt = clock.NowUnixNano() - _, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled}) + updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled}) + if err == nil { + u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message) + failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID) + if u.guard != nil && failureStreak >= 3 { + _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號") + } + } return err } @@ -439,6 +561,162 @@ func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, o }, nil } +func (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) { + if graceNs <= 0 { + graceNs = int64(10 * time.Minute) + } + before := clock.NowUnixNano() - graceNs + items, err := u.repo.ListMissedCandidates(ctx, before, limit) + if err != nil { + return 0, err + } + count := 0 + for _, item := range items { + item.Status = entity.StatusMissed + item.MissedAt = clock.NowUnixNano() + item.UpdateAt = item.MissedAt + updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled}) + if err != nil { + continue + } + u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布") + count++ + } + return count, nil +} + +func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50) + if err != nil { + return nil, err + } + alerts := make([]domusecase.Alert, 0) + for _, item := range result.Items { + switch item.Status { + case entity.StatusFailed: + alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt}) + case entity.StatusMissed: + alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt}) + } + if item.PausedReason != "" { + alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt}) + } + } + return alerts, nil +} + +func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30) + if err != nil { + return nil, err + } + type agg struct { + likes7d int + replies7d int + samples int + } + slots := map[string]*agg{} + for _, item := range health.Items { + t := time.Unix(0, item.PublishedAt).UTC() + key := t.Format("15:04") + if _, ok := slots[key]; !ok { + slots[key] = &agg{} + } + slots[key].likes7d += item.LikeCount + slots[key].replies7d += item.ReplyCount + slots[key].samples++ + } + out := make([]domusecase.SlotInsight, 0, len(slots)) + for key, item := range slots { + avgLikes := 0 + avgReplies := 0 + if item.samples > 0 { + avgLikes = item.likes7d / item.samples + avgReplies = item.replies7d / item.samples + } + recommendation := "neutral" + if avgLikes+avgReplies >= 5 { + recommendation = "recommended" + } else if item.samples >= 2 { + recommendation = "low" + } + out = append(out, domusecase.SlotInsight{ + Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies, + AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies, + SampleSize: item.samples, Recommendation: recommendation, + }) + } + if len(out) == 0 { + out = []domusecase.SlotInsight{ + {Weekday: 1, Time: "09:30", Recommendation: "recommended"}, + {Weekday: 3, Time: "12:30", Recommendation: "neutral"}, + {Weekday: 5, Time: "18:30", Recommendation: "recommended"}, + } + } + return out, nil +} + +func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) { + if u.guard == nil || item == nil { + return &guarddomain.CheckResult{Allowed: true}, nil + } + now := clock.NowUnixNano() + publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now)) + lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID) + return u.guard.Check(ctx, guarddomain.CheckRequest{ + TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, + Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID), + }) +} + +func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int { + result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10) + if err != nil { + return 0 + } + streak := 0 + for _, item := range result.Items { + if item.Status == entity.StatusFailed { + streak++ + continue + } + if item.Status == entity.StatusPublished { + break + } + } + return streak +} + +func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) { + if u.events == nil || item == nil { + return + } + _ = u.events.Record(ctx, eventdomain.RecordRequest{ + TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID, + EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message, + }) +} + +func startOfUTCDay(ns int64) int64 { + t := time.Unix(0, ns).UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano() +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func requireActor(tenantID, ownerUID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Auth).AuthUnauthorized("missing actor") @@ -448,16 +726,24 @@ func requireActor(tenantID, ownerUID string) error { func toSummary(item entity.QueueItem) domusecase.QueueItemSummary { return domusecase.QueueItemSummary{ - ID: item.ID, - AccountID: item.AccountID, - Text: item.Text, - ScheduledAt: item.ScheduledAt, - Status: item.Status, - MediaID: item.MediaID, - Permalink: item.Permalink, - PublishedAt: item.PublishedAt, - ErrorMessage: item.ErrorMessage, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, + ID: item.ID, + AccountID: item.AccountID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + CopyDraftID: item.CopyDraftID, + Text: item.Text, + ScheduledAt: item.ScheduledAt, + Status: item.Status, + MediaID: item.MediaID, + Permalink: item.Permalink, + PublishedAt: item.PublishedAt, + ErrorMessage: item.ErrorMessage, + RetryCount: item.RetryCount, + LastAttemptAt: item.LastAttemptAt, + NextAttemptAt: item.NextAttemptAt, + MissedAt: item.MissedAt, + PausedReason: item.PausedReason, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, } } diff --git a/backend/internal/model/publish_queue_event/domain/entity/event.go b/backend/internal/model/publish_queue_event/domain/entity/event.go new file mode 100644 index 0000000..9a8c938 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/entity/event.go @@ -0,0 +1,27 @@ +package entity + +const CollectionName = "publish_queue_events" + +const ( + EventCreated = "created" + EventRetry = "retry" + EventPublishing = "publishing" + EventPublished = "published" + EventFailed = "failed" + EventCancelled = "cancelled" + EventMissed = "missed" + EventGuardDelayed = "guard_delayed" +) + +type Event struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + QueueID string `bson:"queue_id"` + EventType string `bson:"event_type"` + FromStatus string `bson:"from_status,omitempty"` + ToStatus string `bson:"to_status,omitempty"` + Message string `bson:"message,omitempty"` + CreateAt int64 `bson:"create_at"` +} diff --git a/backend/internal/model/publish_queue_event/domain/repository/repository.go b/backend/internal/model/publish_queue_event/domain/repository/repository.go new file mode 100644 index 0000000..ec04c8d --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/publish_queue_event/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, event *entity.Event) error + ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) + ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) + DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error +} diff --git a/backend/internal/model/publish_queue_event/domain/usecase/usecase.go b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go new file mode 100644 index 0000000..704e271 --- /dev/null +++ b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go @@ -0,0 +1,31 @@ +package usecase + +import "context" + +type EventSummary struct { + ID string + QueueID string + AccountID string + EventType string + FromStatus string + ToStatus string + Message string + CreateAt int64 +} + +type RecordRequest struct { + TenantID string + OwnerUID string + AccountID string + QueueID string + EventType string + FromStatus string + ToStatus string + Message string +} + +type UseCase interface { + Record(ctx context.Context, req RecordRequest) error + ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]EventSummary, error) + ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]EventSummary, error) +} diff --git a/backend/internal/model/publish_queue_event/repository/mongo.go b/backend/internal/model/publish_queue_event/repository/mongo.go new file mode 100644 index 0000000..310513a --- /dev/null +++ b/backend/internal/model/publish_queue_event/repository/mongo.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "queue_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, event *entity.Event) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if event == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("event is required") + } + _, err := r.collection.InsertOne(ctx, event) + return err +} + +func (r *mongoRepository) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) { + filter := actorFilter(tenantID, ownerUID, accountID) + filter["queue_id"] = strings.TrimSpace(queueID) + return r.list(ctx, filter, limit) +} + +func (r *mongoRepository) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) { + return r.list(ctx, actorFilter(tenantID, ownerUID, accountID), limit) +} + +func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID)) + return err +} + +func (r *mongoRepository) list(ctx context.Context, filter bson.M, limit int) ([]entity.Event, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + opts := options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(int64(limit)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Event + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + filter := bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + } + if accountID = strings.TrimSpace(accountID); accountID != "" { + filter["account_id"] = accountID + } + return filter +} diff --git a/backend/internal/model/publish_queue_event/usecase/usecase.go b/backend/internal/model/publish_queue_event/usecase/usecase.go new file mode 100644 index 0000000..b109522 --- /dev/null +++ b/backend/internal/model/publish_queue_event/usecase/usecase.go @@ -0,0 +1,75 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/publish_queue_event/domain/entity" + domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" + domusecase "haixun-backend/internal/model/publish_queue_event/domain/usecase" + + "github.com/google/uuid" +) + +type eventUseCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &eventUseCase{repo: repo} +} + +func (u *eventUseCase) Record(ctx context.Context, req domusecase.RecordRequest) error { + if strings.TrimSpace(req.TenantID) == "" || strings.TrimSpace(req.OwnerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(req.AccountID) == "" || strings.TrimSpace(req.QueueID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id and queue_id are required") + } + eventType := strings.TrimSpace(req.EventType) + if eventType == "" { + eventType = entity.EventCreated + } + return u.repo.Create(ctx, &entity.Event{ + ID: uuid.NewString(), + TenantID: strings.TrimSpace(req.TenantID), + OwnerUID: strings.TrimSpace(req.OwnerUID), + AccountID: strings.TrimSpace(req.AccountID), + QueueID: strings.TrimSpace(req.QueueID), + EventType: eventType, + FromStatus: strings.TrimSpace(req.FromStatus), + ToStatus: strings.TrimSpace(req.ToStatus), + Message: strings.TrimSpace(req.Message), + CreateAt: clock.NowUnixNano(), + }) +} + +func (u *eventUseCase) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListByQueue(ctx, tenantID, ownerUID, accountID, queueID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func (u *eventUseCase) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]domusecase.EventSummary, error) { + items, err := u.repo.ListRecentByAccount(ctx, tenantID, ownerUID, accountID, limit) + if err != nil { + return nil, err + } + return toSummaries(items), nil +} + +func toSummaries(items []entity.Event) []domusecase.EventSummary { + out := make([]domusecase.EventSummary, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.EventSummary{ + ID: item.ID, QueueID: item.QueueID, AccountID: item.AccountID, EventType: item.EventType, + FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt, + }) + } + return out +} diff --git a/backend/internal/model/style_preset/domain/entity/preset.go b/backend/internal/model/style_preset/domain/entity/preset.go new file mode 100644 index 0000000..43fc367 --- /dev/null +++ b/backend/internal/model/style_preset/domain/entity/preset.go @@ -0,0 +1,17 @@ +package entity + +const CollectionName = "style_presets" + +type Preset struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Name string `bson:"name"` + Tone string `bson:"tone,omitempty"` + CTA []string `bson:"cta,omitempty"` + BannedWords []string `bson:"banned_words,omitempty"` + Notes string `bson:"notes,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} diff --git a/backend/internal/model/style_preset/domain/repository/repository.go b/backend/internal/model/style_preset/domain/repository/repository.go new file mode 100644 index 0000000..7cb0dcd --- /dev/null +++ b/backend/internal/model/style_preset/domain/repository/repository.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/style_preset/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) + Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) + Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/domain/usecase/usecase.go b/backend/internal/model/style_preset/domain/usecase/usecase.go new file mode 100644 index 0000000..7486c20 --- /dev/null +++ b/backend/internal/model/style_preset/domain/usecase/usecase.go @@ -0,0 +1,34 @@ +package usecase + +import "context" + +type PresetSummary struct { + ID string + PersonaID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + CreateAt int64 + UpdateAt int64 +} + +type UpsertRequest struct { + TenantID string + OwnerUID string + PersonaID string + PresetID string + Name string + Tone string + CTA []string + BannedWords []string + Notes string + Apply bool +} + +type UseCase interface { + List(ctx context.Context, tenantID, ownerUID, personaID string) ([]PresetSummary, error) + Upsert(ctx context.Context, req UpsertRequest) (*PresetSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error +} diff --git a/backend/internal/model/style_preset/repository/mongo.go b/backend/internal/model/style_preset/repository/mongo.go new file mode 100644 index 0000000..63385a6 --- /dev/null +++ b/backend/internal/model/style_preset/repository/mongo.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, + }) + return err +} + +func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Preset + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + var out entity.Preset + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("style preset not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if preset == nil { + return nil, app.For(code.Persona).InputMissingRequired("preset is required") + } + filter := personaFilter(preset.TenantID, preset.OwnerUID, preset.PersonaID) + filter["_id"] = preset.ID + _, err := r.collection.ReplaceOne(ctx, filter, preset, options.Replace().SetUpsert(true)) + if err != nil { + return nil, err + } + return preset, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(presetID) + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.Persona).ResNotFound("style preset not found") + } + return nil +} + +func personaFilter(tenantID, ownerUID, personaID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "persona_id": strings.TrimSpace(personaID), + } +} diff --git a/backend/internal/model/style_preset/usecase/usecase.go b/backend/internal/model/style_preset/usecase/usecase.go new file mode 100644 index 0000000..94a7893 --- /dev/null +++ b/backend/internal/model/style_preset/usecase/usecase.go @@ -0,0 +1,135 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + "haixun-backend/internal/model/style_preset/domain/entity" + domrepo "haixun-backend/internal/model/style_preset/domain/repository" + domusecase "haixun-backend/internal/model/style_preset/domain/usecase" + + "github.com/google/uuid" +) + +type stylePresetUseCase struct { + repo domrepo.Repository + persona personadomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, persona personadomain.UseCase) domusecase.UseCase { + return &stylePresetUseCase{repo: repo, persona: persona} +} + +func (u *stylePresetUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]domusecase.PresetSummary, error) { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return nil, err + } + items, err := u.repo.List(ctx, tenantID, ownerUID, personaID) + if err != nil { + return nil, err + } + out := make([]domusecase.PresetSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + +func (u *stylePresetUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PresetSummary, error) { + if err := u.require(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, app.For(code.Persona).InputMissingRequired("name is required") + } + now := clock.NowUnixNano() + id := strings.TrimSpace(req.PresetID) + item := &entity.Preset{ID: id} + if id == "" || id == "new" { + item.ID = uuid.NewString() + item.CreateAt = now + } else if existing, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.PersonaID, id); err == nil { + item = existing + } + item.TenantID = req.TenantID + item.OwnerUID = req.OwnerUID + item.PersonaID = req.PersonaID + item.Name = name + item.Tone = strings.TrimSpace(req.Tone) + item.CTA = cleanList(req.CTA) + item.BannedWords = cleanList(req.BannedWords) + item.Notes = strings.TrimSpace(req.Notes) + item.UpdateAt = now + if item.CreateAt == 0 { + item.CreateAt = now + } + saved, err := u.repo.Upsert(ctx, item) + if err != nil { + return nil, err + } + if req.Apply && u.persona != nil { + profile := formatPresetProfile(saved) + _, _ = u.persona.Update(ctx, personadomain.UpdateRequest{ + TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, + Patch: personadomain.PersonaPatch{StyleProfile: &profile}, + }) + } + out := toSummary(*saved) + return &out, nil +} + +func (u *stylePresetUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error { + if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil { + return err + } + return u.repo.Delete(ctx, tenantID, ownerUID, personaID, presetID) +} + +func (u *stylePresetUseCase) require(ctx context.Context, tenantID, ownerUID, personaID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(personaID) == "" { + return app.For(code.Persona).InputMissingRequired("persona_id is required") + } + if u.persona != nil { + _, err := u.persona.Get(ctx, tenantID, ownerUID, personaID) + return err + } + return nil +} + +func toSummary(item entity.Preset) domusecase.PresetSummary { + return domusecase.PresetSummary{ + ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone, + CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...), + Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, + } +} + +func cleanList(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func formatPresetProfile(item *entity.Preset) string { + return fmt.Sprintf("語調:%s\nCTA:%s\n禁用詞:%s\n備註:%s", item.Tone, strings.Join(item.CTA, "、"), strings.Join(item.BannedWords, "、"), item.Notes) +} diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index 9337973..b50ca8a 100644 --- a/backend/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -17,6 +17,7 @@ type Repository interface { UpdateAPIProfile(ctx context.Context, tenantID, ownerUID, accountID, username, threadsUserID, displayName string) (*entity.Account, error) ClearAPIProfile(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error } @@ -26,6 +27,7 @@ type SecretsRepository interface { SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) ClearAPIAccessToken(ctx context.Context, accountID string) error + DeleteByAccountID(ctx context.Context, accountID string) error ListAPIAccessTokensExpiringBefore(ctx context.Context, beforeNs int64) ([]*entity.Secrets, error) } diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 870dfaa..ca21737 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -259,6 +259,7 @@ type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error) Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error + Delete(ctx context.Context, tenantID, ownerUID, accountID string) error GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error) UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error) ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error) diff --git a/backend/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go index 79ca588..9336b67 100644 --- a/backend/internal/model/threads_account/repository/mongo.go +++ b/backend/internal/model/threads_account/repository/mongo.go @@ -205,6 +205,23 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, ac return nil } +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.DeleteOne( + ctx, + bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID}, + ) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return nil +} + func (r *mongoRepository) SetKnownAccounts( ctx context.Context, tenantID, ownerUID, accountID string, diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index 63b6f31..dc527a9 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -199,3 +199,11 @@ func (r *secretsMongoRepository) ClearAPIAccessToken(ctx context.Context, accoun } return nil } + +func (r *secretsMongoRepository) DeleteByAccountID(ctx context.Context, accountID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": accountID}) + return err +} diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index e64af0e..29ec525 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -14,6 +14,11 @@ import ( libthreads "haixun-backend/internal/library/threadsapi" memberdomain "haixun-backend/internal/model/member/domain/usecase" personadomain "haixun-backend/internal/model/persona/domain/usecase" + analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository" + guardrepo "haixun-backend/internal/model/publish_guard/domain/repository" + inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository" + queuerepo "haixun-backend/internal/model/publish_queue/domain/repository" + eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository" settingdomain "haixun-backend/internal/model/setting/domain/usecase" "haixun-backend/internal/model/threads_account/domain/entity" domrepo "haixun-backend/internal/model/threads_account/domain/repository" @@ -37,6 +42,11 @@ type threadsAccountUseCase struct { members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase + queueRepo queuerepo.Repository + analytics analyticsrepo.Repository + inventory inventoryrepo.Repository + guard guardrepo.Repository + queueEvents eventrepo.Repository cipher *crypto.Cipher oauth libthreads.OAuthConfig } @@ -48,6 +58,11 @@ func NewUseCase( members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, + queueRepo queuerepo.Repository, + analytics analyticsrepo.Repository, + inventory inventoryrepo.Repository, + guard guardrepo.Repository, + queueEvents eventrepo.Repository, cipher *crypto.Cipher, oauth libthreads.OAuthConfig, ) domusecase.UseCase { @@ -58,6 +73,11 @@ func NewUseCase( members: members, settings: settings, personas: personas, + queueRepo: queueRepo, + analytics: analytics, + inventory: inventory, + guard: guard, + queueEvents: queueEvents, cipher: cipher, oauth: oauth, } @@ -159,6 +179,59 @@ func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID) } +func (u *threadsAccountUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error { + if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + mediaIDs := []string{} + if u.queueRepo != nil { + if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil { + mediaIDs = ids + } + } + if u.analytics != nil { + _, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs) + } + if u.queueEvents != nil { + _ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.queueRepo != nil { + _, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.inventory != nil { + _ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.guard != nil { + _ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID) + } + if u.secretsRepo != nil { + _ = u.secretsRepo.DeleteByAccountID(ctx, accountID) + } + if u.settings != nil { + settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200) + if err == nil { + for _, item := range settings { + if item == nil { + continue + } + _ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key) + } + } + } + if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil { + return err + } + remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return err + } + nextActiveID := "" + if len(remaining) > 0 && remaining[0] != nil { + nextActiveID = remaining[0].ID + } + return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID) +} + func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index 4d15a7a..5ea9766 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -58,14 +58,26 @@ import ( publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase" publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase" + publishguarddomain "haixun-backend/internal/model/publish_guard/domain/usecase" + publishguardrepo "haixun-backend/internal/model/publish_guard/repository" + publishguardusecase "haixun-backend/internal/model/publish_guard/usecase" + publishinventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository" + publishinventoryusecase "haixun-backend/internal/model/publish_inventory/usecase" publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase" publishqueuerepo "haixun-backend/internal/model/publish_queue/repository" publishqueueusecase "haixun-backend/internal/model/publish_queue/usecase" + publishqueueeventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase" + publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository" + publishqueueeventusecase "haixun-backend/internal/model/publish_queue_event/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" settingrepo "haixun-backend/internal/model/setting/repository" settingusecase "haixun-backend/internal/model/setting/usecase" + stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase" + stylepresetrepo "haixun-backend/internal/model/style_preset/repository" + stylepresetusecase "haixun-backend/internal/model/style_preset/usecase" threadsaccountrepodomain "haixun-backend/internal/model/threads_account/domain/repository" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" @@ -82,26 +94,30 @@ type ServiceContext struct { Mongo *libmongo.Client Redis *goredis.Client - Setting settingusecase.UseCase - AI aisettings.UseCase - Job jobusecase.UseCase - AuthToken authdomain.TokenUseCase - Member memberdomain.UseCase - Permission permissiondomain.UseCase - Persona personadomain.UseCase - CopyMission copymissiondomain.UseCase - Brand branddomain.UseCase - PlacementTopic placementtopicdomain.UseCase - KnowledgeGraph kgdomain.UseCase - Placement placementusecase.UseCase - ScanPost scanpostdomain.UseCase - OutreachDraft outreachdraftdomain.UseCase - ContentMatrix cmatrixdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - CrmContact crmcontactdomain.UseCase - PublishAnalytics publishanalyticsdomain.UseCase - PublishQueue publishqueuedomain.UseCase + Setting settingusecase.UseCase + AI aisettings.UseCase + Job jobusecase.UseCase + AuthToken authdomain.TokenUseCase + Member memberdomain.UseCase + Permission permissiondomain.UseCase + Persona personadomain.UseCase + CopyMission copymissiondomain.UseCase + Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase + KnowledgeGraph kgdomain.UseCase + Placement placementusecase.UseCase + ScanPost scanpostdomain.UseCase + OutreachDraft outreachdraftdomain.UseCase + ContentMatrix cmatrixdomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + CrmContact crmcontactdomain.UseCase + PublishAnalytics publishanalyticsdomain.UseCase + PublishQueue publishqueuedomain.UseCase + PublishInventory publishinventorydomain.UseCase + PublishGuard publishguarddomain.UseCase + PublishQueueEvent publishqueueeventdomain.UseCase + StylePreset stylepresetdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware @@ -214,6 +230,9 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureRefillPublishInventoryTemplate(ctx); err != nil { + panic(err) + } copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { @@ -274,6 +293,12 @@ func NewServiceContext(c config.Config) *ServiceContext { ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher) + publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) + publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database()) + publishInventoryRepository := publishinventoryrepo.NewMongoRepository(mongoClient.Database()) + publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database()) + publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database()) + stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database()) var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore if redisClient != nil { threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient) @@ -290,6 +315,24 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil { panic(err) } + if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishInventoryRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishGuardRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := publishQueueEventRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := stylePresetRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } threadsAccountUseCase := threadsaccountusecase.NewUseCase( threadsAccountRepository, threadsAccountSecretsRepository, @@ -297,6 +340,11 @@ func NewServiceContext(c config.Config) *ServiceContext { memberUseCase, settingUseCase, personaUseCase, + publishQueueRepository, + publishAnalyticsRepository, + publishInventoryRepository, + publishGuardRepository, + publishQueueEventRepository, secretsCipher, threadsOAuthConfig, ) @@ -309,47 +357,50 @@ func NewServiceContext(c config.Config) *ServiceContext { } crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository) - publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database()) - if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil { - panic(err) - } publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository, jobUseCase) + publishQueueEventUseCase := publishqueueeventusecase.NewUseCase(publishQueueEventRepository) + publishInventoryUseCase := publishinventoryusecase.NewUseCase(publishInventoryRepository, threadsAccountUseCase) + publishGuardUseCase := publishguardusecase.NewUseCase(publishGuardRepository, threadsAccountUseCase) + stylePresetUseCase := stylepresetusecase.NewUseCase(stylePresetRepository, personaUseCase) - publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database()) - if err := publishQueueRepository.EnsureIndexes(ctx); err != nil { - panic(err) - } publishQueueUseCase := publishqueueusecase.NewUseCase( publishQueueRepository, threadsAccountUseCase, publishAnalyticsUseCase, + publishGuardUseCase, + publishQueueEventUseCase, + copyDraftUseCase, ) sc := &ServiceContext{ - Config: c, - Validator: validate.New(), - Mongo: mongoClient, - Redis: redisClient, - Setting: settingUseCase, - AI: aisettings.NewUseCase(), - Job: jobUseCase, - AuthToken: authTokenUseCase, - Member: memberUseCase, - Permission: permissionUseCase, - Persona: personaUseCase, - CopyMission: copyMissionUseCase, - Brand: brandUseCase, - PlacementTopic: placementTopicUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - Placement: placementUseCase, - ScanPost: scanPostUseCase, - OutreachDraft: outreachDraftUseCase, - ContentMatrix: contentMatrixUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, - CrmContact: crmContactUseCase, - PublishAnalytics: publishAnalyticsUseCase, - PublishQueue: publishQueueUseCase, + Config: c, + Validator: validate.New(), + Mongo: mongoClient, + Redis: redisClient, + Setting: settingUseCase, + AI: aisettings.NewUseCase(), + Job: jobUseCase, + AuthToken: authTokenUseCase, + Member: memberUseCase, + Permission: permissionUseCase, + Persona: personaUseCase, + CopyMission: copyMissionUseCase, + Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + Placement: placementUseCase, + ScanPost: scanPostUseCase, + OutreachDraft: outreachDraftUseCase, + ContentMatrix: contentMatrixUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + CrmContact: crmContactUseCase, + PublishAnalytics: publishAnalyticsUseCase, + PublishQueue: publishQueueUseCase, + PublishInventory: publishInventoryUseCase, + PublishGuard: publishGuardUseCase, + PublishQueueEvent: publishQueueEventUseCase, + StylePreset: stylePresetUseCase, } hostname, _ := os.Hostname() @@ -438,6 +489,13 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, PublishAnalytics: publishAnalyticsUseCase, }) + jobworker.RegisterRefillPublishInventoryHandler(runner, jobworker.RefillPublishInventoryDeps{ + Jobs: jobUseCase, + PublishInventory: publishInventoryUseCase, + PublishQueue: publishQueueUseCase, + CopyMission: copyMissionUseCase, + CopyDraft: copyDraftUseCase, + }) go runner.Start(workerCtx) } diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index c5e4b1a..8a06643 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -216,6 +216,7 @@ type CopyDraftData struct { ReferenceNotes string `json:"reference_notes,omitempty"` Sources []string `json:"sources,omitempty"` Status string `json:"status,omitempty"` + PublishQueueID string `json:"publish_queue_id,omitempty"` PublishedMediaID string `json:"published_media_id,omitempty"` PublishedPermalink string `json:"published_permalink,omitempty"` PublishedAt int64 `json:"published_at,omitempty"` @@ -445,6 +446,12 @@ type DeletePlacementTopicScanPostHandlerReq struct { PostID string `path:"postId" validate:"required"` } +type DeleteThreadsAccountData struct { + DeletedID string `json:"deleted_id"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Message string `json:"message"` +} + type ErrorDetail struct { BizCode string `json:"biz_code,optional"` Scope int64 `json:"scope,optional"` @@ -857,6 +864,10 @@ type ListPublishQueueQuery struct { PageSize int `form:"pageSize,optional"` } +type ListStylePresetsData struct { + List []StylePresetData `json:"list"` +} + type ListThreadsAccountAiProviderModelsReq struct { ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key } @@ -1083,6 +1094,19 @@ type PlacementTopicPath struct { ID string `path:"id" validate:"required"` } +type PublishAlertData struct { + Type string `json:"type"` + Severity string `json:"severity"` + Message string `json:"message"` + QueueID string `json:"queue_id,omitempty"` + AccountID string `json:"account_id"` + CreateAt int64 `json:"create_at"` +} + +type PublishAlertsData struct { + List []PublishAlertData `json:"list"` +} + type PublishCopyDraftData struct { DraftID string `json:"draft_id"` MediaID string `json:"media_id"` @@ -1101,6 +1125,51 @@ type PublishCopyDraftReq struct { Confirm bool `json:"confirm"` } +type PublishDashboardAccountSummary struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PendingScheduled int64 `json:"pending_scheduled"` + FailedCount int64 `json:"failed_count"` + Published7d int64 `json:"published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` + Paused bool `json:"paused"` + BestSlot string `json:"best_slot,omitempty"` + LowSlot string `json:"low_slot,omitempty"` +} + +type PublishDashboardSummaryData struct { + List []PublishDashboardAccountSummary `json:"list"` + TotalPending int64 `json:"total_pending"` + TotalFailed int64 `json:"total_failed"` + TotalPublished7d int64 `json:"total_published_7d"` + TotalLikes7d int `json:"total_likes_7d"` + TotalReplies7d int `json:"total_replies_7d"` +} + +type PublishGuardPolicyData struct { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts"` + MinIntervalMinutes int `json:"min_interval_minutes"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"` + Paused bool `json:"paused"` + PausedReason string `json:"paused_reason,omitempty"` + UpdateAt int64 `json:"update_at"` +} + +type PublishInventoryPolicyData struct { + AccountID string `json:"account_id"` + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count"` + LowStockThreshold int `json:"low_stock_threshold"` + LookaheadDays int `json:"lookahead_days"` + Timezone string `json:"timezone"` + Slots []PublishSlotData `json:"slots"` + SourceRefs []PublishSourceRefData `json:"source_refs"` + UpdateAt int64 `json:"update_at"` +} + type PublishOutreachDraftData struct { ScanPostID string `json:"scan_post_id"` ReplyID string `json:"reply_id"` @@ -1126,18 +1195,40 @@ type PublishPlacementTopicOutreachDraftHandlerReq struct { PublishOutreachDraftReq } +type PublishQueueEventData struct { + ID string `json:"id"` + QueueID string `json:"queue_id"` + EventType string `json:"event_type"` + FromStatus string `json:"from_status,omitempty"` + ToStatus string `json:"to_status,omitempty"` + Message string `json:"message,omitempty"` + CreateAt int64 `json:"create_at"` +} + +type PublishQueueEventsData struct { + List []PublishQueueEventData `json:"list"` +} + type PublishQueueItemData struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Text string `json:"text"` - ScheduledAt int64 `json:"scheduled_at"` - Status string `json:"status"` - MediaID string `json:"media_id,optional"` - Permalink string `json:"permalink,optional"` - PublishedAt int64 `json:"published_at,optional"` - ErrorMessage string `json:"error_message,optional"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + CopyDraftID string `json:"copy_draft_id,omitempty"` + Text string `json:"text"` + ScheduledAt int64 `json:"scheduled_at"` + Status string `json:"status"` + MediaID string `json:"media_id,optional"` + Permalink string `json:"permalink,optional"` + PublishedAt int64 `json:"published_at,optional"` + ErrorMessage string `json:"error_message,optional"` + RetryCount int `json:"retry_count,omitempty"` + LastAttemptAt int64 `json:"last_attempt_at,omitempty"` + NextAttemptAt int64 `json:"next_attempt_at,omitempty"` + MissedAt int64 `json:"missed_at,omitempty"` + PausedReason string `json:"paused_reason,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` } type PublishQueueItemPath struct { @@ -1150,6 +1241,49 @@ type PublishQueueListData struct { List []PublishQueueItemData `json:"list"` } +type PublishQueueRangeHandlerReq struct { + ThreadsAccountPath + PublishQueueRangeQuery +} + +type PublishQueueRangeQuery struct { + StartAt int64 `form:"startAt,optional"` + EndAt int64 `form:"endAt,optional"` + Status string `form:"status,optional"` +} + +type PublishSlotData struct { + Weekday int `json:"weekday"` + Time string `json:"time"` +} + +type PublishSlotInsightData struct { + Weekday int `json:"weekday"` + Time string `json:"time"` + AvgLikes1h int `json:"avg_likes_1h"` + AvgReplies1h int `json:"avg_replies_1h"` + AvgLikes24h int `json:"avg_likes_24h"` + AvgReplies24h int `json:"avg_replies_24h"` + AvgLikes7d int `json:"avg_likes_7d"` + AvgReplies7d int `json:"avg_replies_7d"` + SampleSize int `json:"sample_size"` + Recommendation string `json:"recommendation"` +} + +type PublishSlotInsightsData struct { + AccountID string `json:"account_id"` + Timezone string `json:"timezone"` + Slots []PublishSlotInsightData `json:"slots"` +} + +type PublishSourceRefData struct { + Type string `json:"type"` + PersonaID string `json:"persona_id,omitempty"` + CopyMissionID string `json:"copy_mission_id,omitempty"` + ScanPostID string `json:"scan_post_id,omitempty"` + ManualSeed string `json:"manual_seed,omitempty"` +} + type ResearchItemData struct { Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` @@ -1229,6 +1363,31 @@ type ScanReplyData struct { PostedAt string `json:"posted_at,omitempty"` } +type ScheduleCopyDraftsData struct { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` +} + +type ScheduleCopyDraftsReq struct { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` +} + +type ScheduleCopyMissionDraftsHandlerReq struct { + CopyMissionPath + ScheduleCopyDraftsReq +} + +type SchedulePersonaDraftsHandlerReq struct { + PersonaPath + ScheduleCopyDraftsReq +} + type SettingData struct { ID string `json:"id"` Scope string `json:"scope"` @@ -1362,6 +1521,21 @@ type StartPlacementTopicScanJobHandlerReq struct { StartBrandScanJobReq } +type StartPublishInventoryRefillJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Message string `json:"message"` +} + +type StartPublishInventoryRefillJobHandlerReq struct { + ThreadsAccountPath + StartPublishInventoryRefillJobReq +} + +type StartPublishInventoryRefillJobReq struct { + Count int `json:"count,optional"` +} + type StartThreadsOAuthData struct { AuthorizeURL string `json:"authorize_url"` State string `json:"state"` @@ -1392,6 +1566,23 @@ type StorePersonaStyleProfileReq struct { StyleBenchmark string `json:"style_benchmark,optional"` } +type StylePresetData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Tone string `json:"tone,omitempty"` + CTA []string `json:"cta,omitempty"` + BannedWords []string `json:"banned_words,omitempty"` + Notes string `json:"notes,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type StylePresetPath struct { + ID string `path:"id" validate:"required"` + PresetID string `path:"presetId" validate:"required"` +} + type ThreadsAPIPlaygroundData struct { Action string `json:"action"` Ok bool `json:"ok"` @@ -1479,6 +1670,16 @@ type ThreadsAccountPath struct { ID string `path:"id" validate:"required"` } +type ThreadsDiagnosticsData struct { + AccountID string `json:"account_id"` + CheckedAt int64 `json:"checked_at"` + ApiConnected bool `json:"api_connected"` + TokenExpiresAt int64 `json:"token_expires_at,omitempty"` + Scopes []string `json:"scopes"` + Items []ThreadsAPISmokeTestItem `json:"items"` + Message string `json:"message"` +} + type ThreadsInsightMetricItem struct { Name string `json:"name"` Value int `json:"value"` @@ -1784,6 +1985,49 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct { UpsertBrandScanScheduleReq } +type UpsertPublishGuardPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishGuardPolicyReq +} + +type UpsertPublishGuardPolicyReq struct { + Enabled bool `json:"enabled"` + MaxDailyPosts int `json:"max_daily_posts,optional"` + MinIntervalMinutes int `json:"min_interval_minutes,optional"` + ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"` + Paused *bool `json:"paused,optional"` + PausedReason string `json:"paused_reason,optional"` +} + +type UpsertPublishInventoryPolicyHandlerReq struct { + ThreadsAccountPath + UpsertPublishInventoryPolicyReq +} + +type UpsertPublishInventoryPolicyReq struct { + Enabled bool `json:"enabled"` + TargetDailyCount int `json:"target_daily_count,optional"` + LowStockThreshold int `json:"low_stock_threshold,optional"` + LookaheadDays int `json:"lookahead_days,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + SourceRefs []PublishSourceRefData `json:"source_refs,optional"` +} + +type UpsertStylePresetHandlerReq struct { + StylePresetPath + UpsertStylePresetReq +} + +type UpsertStylePresetReq struct { + Name string `json:"name" validate:"required"` + Tone string `json:"tone,optional"` + CTA []string `json:"cta,optional"` + BannedWords []string `json:"banned_words,optional"` + Notes string `json:"notes,optional"` + Apply bool `json:"apply,optional"` +} + type ViralScanPostData struct { ID string `json:"id"` SearchTag string `json:"search_tag"` diff --git a/backend/internal/worker/job/publish_queue_sweeper.go b/backend/internal/worker/job/publish_queue_sweeper.go index 3d3c0d2..746ccbb 100644 --- a/backend/internal/worker/job/publish_queue_sweeper.go +++ b/backend/internal/worker/job/publish_queue_sweeper.go @@ -37,6 +37,12 @@ func (s *PublishQueueSweeper) Start(ctx context.Context, interval time.Duration) log.Printf("publish queue sweeper stopped") return case <-ticker.C: + missed, err := s.queue.MarkMissed(ctx, int64(10*time.Minute), s.batchSize) + if err != nil { + log.Printf("publish queue missed sweep error: %v", err) + } else if missed > 0 { + log.Printf("publish queue marked %d missed item(s)", missed) + } dispatched, err := s.queue.DispatchDue(ctx, s.batchSize) if err != nil { log.Printf("publish queue sweeper tick error: %v", err) diff --git a/backend/internal/worker/job/refill_publish_inventory.go b/backend/internal/worker/job/refill_publish_inventory.go new file mode 100644 index 0000000..d80a16d --- /dev/null +++ b/backend/internal/worker/job/refill_publish_inventory.go @@ -0,0 +1,134 @@ +package job + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/publishschedule" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" +) + +type RefillPublishInventoryDeps struct { + Jobs jobdom.UseCase + PublishInventory inventorydomain.UseCase + PublishQueue pqdomain.UseCase + CopyMission missiondomain.UseCase + CopyDraft copydraftdomain.UseCase +} + +func RegisterRefillPublishInventoryHandler(runner *Runner, deps RefillPublishInventoryDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("refill_publish_inventory", func(ctx context.Context, step StepContext) error { + return runRefillPublishInventory(ctx, step, deps) + }) +} + +func runRefillPublishInventory(ctx context.Context, step StepContext, deps RefillPublishInventoryDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + accountID := stringField(payload, "account_id") + if tenantID == "" || ownerUID == "" || accountID == "" { + return fmt.Errorf("refill publish inventory payload missing tenant_id, owner_uid, or account_id") + } + if deps.PublishInventory == nil || deps.PublishQueue == nil { + return fmt.Errorf("publish inventory dependencies are not configured") + } + policy, err := deps.PublishInventory.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return err + } + if !policy.Enabled { + return nil + } + health, err := deps.PublishQueue.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 1) + if err != nil { + return err + } + need := policy.LowStockThreshold - int(health.Summary.PendingScheduled) + if requested := intField(payload, "count"); requested > 0 { + need = requested + } + if need <= 0 { + return nil + } + if need > 12 { + need = 12 + } + slots := make([]publishschedule.Slot, 0, len(policy.Slots)) + for _, slot := range policy.Slots { + slots = append(slots, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) + } + times := publishschedule.BuildSchedule(0, policy.Timezone, slots, need) + for i := 0; i < need; i++ { + if err := step.Heartbeat(ctx); err != nil { + return err + } + ref := pickSourceRef(policy.SourceRefs, i) + text, personaID, missionID := refillText(ctx, deps, tenantID, ownerUID, ref, i) + createReq := pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, + PersonaID: personaID, CopyMissionID: missionID, Text: text, + } + if i < len(times) { + createReq.ScheduledAt = times[i] + } + if deps.CopyDraft != nil && personaID != "" { + draft, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, + CopyMissionID: missionID, DraftType: "inventory-refill", SortOrder: i + 1, + Text: text, Rationale: "自動補庫存產生", + }) + if err == nil && draft != nil { + createReq.CopyDraftID = draft.ID + } + } + item, err := deps.PublishQueue.Create(ctx, createReq) + if err != nil { + return err + } + if deps.CopyDraft != nil && createReq.CopyDraftID != "" { + _, _ = deps.CopyDraft.MarkScheduled(ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, DraftID: createReq.CopyDraftID, QueueID: item.ID, + }) + } + } + return nil +} + +func pickSourceRef(items []inventorydomain.SourceRef, idx int) inventorydomain.SourceRef { + if len(items) == 0 { + return inventorydomain.SourceRef{Type: "manual_seed", ManualSeed: "分享一個今天巡樓觀察到的重點"} + } + return items[idx%len(items)] +} + +func refillText(ctx context.Context, deps RefillPublishInventoryDeps, tenantID, ownerUID string, ref inventorydomain.SourceRef, idx int) (string, string, string) { + personaID := strings.TrimSpace(ref.PersonaID) + missionID := strings.TrimSpace(ref.CopyMissionID) + seed := strings.TrimSpace(ref.ManualSeed) + if missionID != "" && personaID != "" && deps.CopyMission != nil { + if mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); err == nil { + seed = strings.TrimSpace(firstNonEmpty(mission.Brief, mission.Label, mission.SeedQuery)) + } + } + if seed == "" { + seed = "分享一個今天巡樓觀察到的重點" + } + return fmt.Sprintf("%s\n\n巡樓筆記 #%d", seed, idx+1), personaID, missionID +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/web/index.html b/backend/web/index.html new file mode 100644 index 0000000..212d61b --- /dev/null +++ b/backend/web/index.html @@ -0,0 +1,26 @@ + + + + + + 巡樓 Console + + + + + + +
+ + + diff --git a/backend/web/package-lock.json b/backend/web/package-lock.json new file mode 100644 index 0000000..14065b0 --- /dev/null +++ b/backend/web/package-lock.json @@ -0,0 +1,1757 @@ +{ + "name": "haixun-console-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haixun-console-web", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "taipei-sans-tc": "^0.1.1", + "typescript": "^5.8.2", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/taipei-sans-tc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz", + "integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/backend/web/package.json b/backend/web/package.json new file mode 100644 index 0000000..686eb95 --- /dev/null +++ b/backend/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "haixun-console-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.2.0", + "typescript": "^5.8.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "taipei-sans-tc": "^0.1.1" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4" + } +} diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx new file mode 100644 index 0000000..eaaa280 --- /dev/null +++ b/backend/web/src/App.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { api } from "./api/haixun"; +import { useAuth } from "./auth/AuthContext"; +import { Layout } from "./components/Layout"; +import { Button, Card, PageTitle } from "./components/ui"; +import { AuthPage } from "./pages/AuthPage"; +import { AccountsPage } from "./pages/AccountsPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { GapsPage } from "./pages/GapsPage"; +import { JobsPage } from "./pages/JobsPage"; +import { MissionsPage } from "./pages/MissionsPage"; +import { PatrolPage } from "./pages/PatrolPage"; +import { PersonasPage } from "./pages/PersonasPage"; +import { PublishPage } from "./pages/PublishPage"; +import { SettingsPage } from "./pages/SettingsPage"; + +const validPages = new Set(["dashboard", "accounts", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); +const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol", "jobs"]); + +export function App() { + const { member, loading } = useAuth(); + const [page, setPage] = useState(() => normalizePage(location.hash.replace("#", ""))); + const [accountVersion, setAccountVersion] = useState(0); + const [accountCount, setAccountCount] = useState(null); + + useEffect(() => { + const onHash = () => setPage(normalizePage(location.hash.replace("#", ""))); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + useEffect(() => { + const onAccountChanged = () => setAccountVersion((value) => value + 1); + window.addEventListener("haixun:active-account-changed", onAccountChanged); + return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged); + }, []); + + useEffect(() => { + if (!member) { + setAccountCount(null); + return; + } + let alive = true; + async function loadAccounts() { + try { + const data = await api.threadsAccounts(); + if (alive) setAccountCount(data.list?.length || 0); + } catch { + if (alive) setAccountCount(0); + } + } + void loadAccounts(); + return () => { + alive = false; + }; + }, [member, accountVersion]); + + function navigate(next: string) { + const normalized = normalizePage(next); + window.location.hash = normalized; + setPage(normalized); + } + + if (loading) { + return
載入中...
; + } + + if (!member) { + return ; + } + + return ( + +
{renderPage(page, navigate, accountCount)}
+
+ ); +} + +function normalizePage(raw: string) { + return validPages.has(raw) ? raw : "dashboard"; +} + +function renderPage(page: string, navigate: (key: string) => void, accountCount: number | null) { + if (accountRequiredPages.has(page)) { + if (accountCount === null) { + return ( + +

正在確認 Threads 帳號狀態...

+
+ ); + } + if (accountCount === 0) { + return ; + } + } + switch (page) { + case "accounts": + return ; + case "publish": + return ; + case "personas": + return ; + case "missions": + return ; + case "patrol": + return ; + case "jobs": + return ; + case "settings": + return ; + case "gaps": + return ; + default: + return ; + } +} + +function AccountRequiredPage({ navigate }: { navigate: (key: string) => void }) { + return ( +
+ + +

+ 請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。 +

+ +
+
+ ); +} diff --git a/backend/web/src/api/client.ts b/backend/web/src/api/client.ts new file mode 100644 index 0000000..ff721ac --- /dev/null +++ b/backend/web/src/api/client.ts @@ -0,0 +1,135 @@ +export type ApiEnvelope = { + code: number; + message: string; + data?: T; + error?: { + biz_code?: string; + scope?: number; + category?: number; + detail?: number; + }; +}; + +export class ApiError extends Error { + status: number; + code?: number; + bizCode?: string; + + constructor(message: string, status: number, code?: number, bizCode?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.bizCode = bizCode; + } +} + +const accessTokenKey = "haixun.access_token"; +const refreshTokenKey = "haixun.refresh_token"; + +let onUnauthorized: (() => void) | undefined; + +export function setUnauthorizedHandler(handler: (() => void) | undefined) { + onUnauthorized = handler; +} + +export function getAccessToken() { + return localStorage.getItem(accessTokenKey) || ""; +} + +export function getRefreshToken() { + return localStorage.getItem(refreshTokenKey) || ""; +} + +export function setTokens(accessToken: string, refreshToken: string) { + localStorage.setItem(accessTokenKey, accessToken); + localStorage.setItem(refreshTokenKey, refreshToken); +} + +export function clearTokens() { + localStorage.removeItem(accessTokenKey); + localStorage.removeItem(refreshTokenKey); +} + +type RequestOptions = { + method?: string; + body?: unknown; + auth?: boolean; + memberAuth?: boolean; + providerToken?: string; + headers?: Record; + retryOnUnauthorized?: boolean; +}; + +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + const response = await fetch(path, buildRequestInit(options)); + + if (response.status === 401 && options.auth && options.retryOnUnauthorized !== false) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return apiRequest(path, { ...options, retryOnUnauthorized: false }); + } + onUnauthorized?.(); + } + + const envelope = (await response.json().catch(() => null)) as ApiEnvelope | null; + if (!response.ok || !envelope || envelope.code !== 102000) { + throw new ApiError( + envelope?.message || `API request failed (${response.status})`, + response.status, + envelope?.code, + envelope?.error?.biz_code + ); + } + return envelope.data as T; +} + +function buildRequestInit(options: RequestOptions): RequestInit { + const headers: Record = { + Accept: "application/json", + ...options.headers + }; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (options.auth) { + const token = getAccessToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + } + if (options.memberAuth) { + const token = getAccessToken(); + if (token) { + headers["X-Member-Authorization"] = `Bearer ${token}`; + } + } + if (options.providerToken) { + headers.Authorization = `Bearer ${options.providerToken}`; + } + return { + method: options.method || (options.body === undefined ? "GET" : "POST"), + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }; +} + +async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + clearTokens(); + return false; + } + try { + const data = await apiRequest<{ access_token: string; refresh_token: string }>("/api/v1/auth/refresh", { + method: "POST", + body: { refresh_token: refreshToken }, + retryOnUnauthorized: false + }); + setTokens(data.access_token, data.refresh_token); + return true; + } catch { + clearTokens(); + return false; + } +} diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts new file mode 100644 index 0000000..96307a2 --- /dev/null +++ b/backend/web/src/api/haixun.ts @@ -0,0 +1,479 @@ +import { apiRequest } from "./client"; + +export type Pagination = { + total: number; + page: number; + pageSize: number; + totalPages: number; +}; + +export type CapabilityData = { + discover_ready: boolean; + ai_ready: boolean; + publish_ready: boolean; + discover_hint?: string; + ai_hint?: string; + publish_hint?: string; + active_threads_account_id?: string; +}; + +export type ThreadsAccount = { + id: string; + display_name?: string; + username?: string; + threads_user_id?: string; + browser_connected: boolean; + api_connected: boolean; + api_token_expires_at?: number; + status: string; +}; + +export type ThreadsAccountList = { + list: ThreadsAccount[]; + active_account_id?: string; +}; + +export type DeleteThreadsAccountResult = { + deleted_id: string; + active_account_id?: string; + message: string; +}; + +export type ThreadsConnection = { + account_id: string; + account_name: string; + username?: string; + browser_connected: boolean; + api_connected: boolean; + prefs: Record; +}; + +export type ThreadsAiSettings = { + account_id: string; + provider: string; + model: string; + research_provider?: string; + research_model?: string; + api_keys?: Record; + api_keys_configured?: Record; +}; + +export type ThreadsAccountAiProviderModels = { + id: string; + label: string; + models: string[]; + streams: boolean; + error?: string; +}; + +export type PublishQueueItem = { + id: string; + account_id: string; + persona_id?: string; + copy_mission_id?: string; + copy_draft_id?: string; + text: string; + scheduled_at: number; + status: string; + media_id?: string; + permalink?: string; + published_at?: number; + error_message?: string; + retry_count?: number; + last_attempt_at?: number; + next_attempt_at?: number; + missed_at?: number; + paused_reason?: string; + create_at: number; + update_at: number; +}; + +export type PublishQueueList = { + pagination: Pagination; + list: PublishQueueItem[]; +}; + +export type PublishHealth = { + summary: { + pending_scheduled: number; + failed_count: number; + published_7d: number; + total_likes_7d: number; + total_replies_7d: number; + }; + pagination: Pagination; + list: Array<{ + media_id: string; + permalink?: string; + text?: string; + published_at: number; + like_count: number; + reply_count: number; + repost_count?: number; + quote_count?: number; + views?: number; + shares?: number; + checkpoints?: Array<{ checkpoint: string; like_count: number; reply_count: number; checked_at: number }>; + }>; +}; + +export type PostPerformance = { + account_id: string; + username?: string; + fetched_at: number; + posts: Array<{ + media_id: string; + text?: string; + permalink?: string; + like_count: number; + reply_count: number; + views?: number; + insights_status?: string; + }>; +}; + +export type Persona = { + id: string; + display_name?: string; + persona?: string; + brief?: string; + style_profile?: string; + style_benchmark?: string; + create_at: number; + update_at: number; +}; + +export type CopyDraft = { + id: string; + persona_id: string; + copy_mission_id?: string; + scan_post_id?: string; + draft_type: string; + text: string; + angle?: string; + hook?: string; + rationale?: string; + status?: string; + publish_queue_id?: string; + published_permalink?: string; +}; + +export type PublishSlot = { + weekday: number; + time: string; +}; + +export type PublishSourceRef = { + type: string; + persona_id?: string; + copy_mission_id?: string; + scan_post_id?: string; + manual_seed?: string; +}; + +export type PublishInventoryPolicy = { + account_id: string; + enabled: boolean; + target_daily_count: number; + low_stock_threshold: number; + lookahead_days: number; + timezone: string; + slots: PublishSlot[]; + source_refs: PublishSourceRef[]; + update_at: number; +}; + +export type PublishGuardPolicy = { + account_id: string; + enabled: boolean; + max_daily_posts: number; + min_interval_minutes: number; + consecutive_failure_pause_limit: number; + paused: boolean; + paused_reason?: string; + update_at: number; +}; + +export type PublishSlotInsights = { + account_id: string; + timezone: string; + slots: Array; +}; + +export type PublishQueueEvent = { + id: string; + queue_id: string; + event_type: string; + from_status?: string; + to_status?: string; + message?: string; + create_at: number; +}; + +export type PublishAlert = { + type: string; + severity: string; + message: string; + queue_id?: string; + account_id: string; + create_at: number; +}; + +export type PublishDashboardSummary = { + list: Array<{ + account_id: string; + account_name: string; + pending_scheduled: number; + failed_count: number; + published_7d: number; + total_likes_7d: number; + total_replies_7d: number; + paused: boolean; + best_slot?: string; + low_slot?: string; + }>; + total_pending: number; + total_failed: number; + total_published_7d: number; + total_likes_7d: number; + total_replies_7d: number; +}; + +export type ThreadsDiagnostics = { + account_id: string; + checked_at: number; + api_connected: boolean; + token_expires_at?: number; + scopes: string[]; + items: Array<{ scope: string; name: string; status: string; message: string; count: number }>; + message: string; +}; + +export type ThreadsPlaygroundResult = { + action: string; + ok: boolean; + message: string; + data: string; +}; + +export type ThreadsReply = { + id: string; + text?: string; + username?: string; + permalink?: string; + timestamp?: string; + like_count?: number; + parent_id?: string; +}; + +export type ThreadsInspirationPost = { + id: string; + text?: string; + username?: string; + permalink?: string; + timestamp?: string; + like_count?: number; + reply_count?: number; +}; + +export type StylePreset = { + id: string; + persona_id: string; + name: string; + tone?: string; + cta?: string[]; + banned_words?: string[]; + notes?: string; + create_at: number; + update_at: number; +}; + +export type CopyMission = { + id: string; + persona_id: string; + label?: string; + seed_query?: string; + brief?: string; + status?: string; + selected_tags?: string[]; + research_map?: Record; +}; + +export type Brand = { + id: string; + display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_context?: string; + target_audience?: string; + goals?: string; +}; + +export type ScanPost = { + id: string; + author: string; + text: string; + permalink?: string; + priority?: string; + placement_score?: number; + product_fit_score?: number; + outreach_status?: string; +}; + +export type JobRun = { + id: string; + template_type: string; + status: string; + phase?: string; + progress?: { percentage?: number; summary?: string }; + create_at: number; + update_at: number; +}; + +export type JobList = { + pagination: Pagination; + list: JobRun[]; +}; + +export type JobSchedule = { + id: string; + template_type: string; + cron: string; + timezone: string; + enabled: boolean; + next_run_at?: number; + last_run_at?: number; +}; + +export type SettingValue = { + scope: string; + scope_id: string; + key: string; + value: unknown; + version?: number; +}; + +export const api = { + capabilities: () => apiRequest("/api/v1/members/me/capabilities", { auth: true }), + memberMe: () => apiRequest("/api/v1/members/me", { auth: true }), + placementSettings: () => apiRequest("/api/v1/members/me/placement-settings", { auth: true }), + updatePlacementSettings: (body: Record) => + apiRequest("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }), + + threadsAccounts: () => apiRequest("/api/v1/threads-accounts/", { auth: true }), + createThreadsAccount: (body: { display_name?: string; activate?: boolean }) => + apiRequest("/api/v1/threads-accounts/", { method: "POST", body, auth: true }), + deleteThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}`, { method: "DELETE", auth: true }), + activateThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/activate`, { method: "POST", auth: true }), + getThreadsConnection: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { auth: true }), + updateThreadsConnection: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/connection`, { method: "PATCH", body, auth: true }), + threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), + updateThreadsAiSettings: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), + threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) => + apiRequest(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, { + method: "POST", + body: apiKey ? { api_key: apiKey } : {}, + auth: true + }), + oauthConfig: () => apiRequest<{ enabled: boolean; redirect_uri: string; success_redirect: string; requires_https: boolean }>("/api/v1/threads-accounts/oauth/config", { auth: true }), + oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }), + startOauth: (accountId?: string) => + apiRequest<{ authorize_url: string }>(`/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`, { auth: true }), + smokeTest: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }), + threadsDiagnostics: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }), + publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }), + threadsPlayground: (id: string, body: Record) => + apiRequest(`/api/v1/threads-accounts/${id}/api/playground`, { method: "POST", body, auth: true }), + + publishQueue: (accountId: string, status = "", page = 1) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }), + createPublishQueue: (accountId: string, body: { text: string; scheduled_at?: number }) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue`, { method: "POST", body, auth: true }), + patchPublishQueue: (accountId: string, qid: string, body: { text?: string; scheduled_at?: number }) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "PATCH", body, auth: true }), + cancelPublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/cancel`, { method: "POST", auth: true }), + deletePublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "DELETE", auth: true }), + retryPublishQueue: (accountId: string, qid: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/retry`, { method: "POST", auth: true }), + publishQueueEvents: (accountId: string, qid: string) => + apiRequest<{ list: PublishQueueEvent[] }>(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/events`, { auth: true }), + publishAlerts: (accountId: string) => apiRequest<{ list: PublishAlert[] }>(`/api/v1/threads-accounts/${accountId}/publish-alerts`, { auth: true }), + publishCalendar: (accountId: string, startAt: number, endAt: number, status = "") => + apiRequest( + `/api/v1/threads-accounts/${accountId}/publish-calendar?startAt=${startAt}&endAt=${endAt}${status ? `&status=${status}` : ""}`, + { auth: true } + ), + publishInventoryPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { auth: true }), + updatePublishInventoryPolicy: (accountId: string, body: Partial) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { method: "PUT", body, auth: true }), + startPublishInventoryRefill: (accountId: string, count?: number) => + apiRequest<{ job_id: string; status: string; message: string }>(`/api/v1/threads-accounts/${accountId}/publish-inventory/refill-jobs`, { method: "POST", body: { count }, auth: true }), + publishSlotInsights: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-slot-insights`, { auth: true }), + publishGuardPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { auth: true }), + updatePublishGuardPolicy: (accountId: string, body: Partial) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { method: "PUT", body, auth: true }), + resumePublishGuard: (accountId: string) => + apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard/resume`, { method: "POST", auth: true }), + publishHealth: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-health?page=1&pageSize=10`, { auth: true }), + postPerformance: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=15`, { auth: true }), + + personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }), + createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }), + updatePersona: (id: string, body: Record) => apiRequest(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }), + startStyleAnalysis: (id: string, benchmark_username: string) => + apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }), + personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }), + updateDraft: (personaId: string, draftId: string, body: Record) => + apiRequest(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "PATCH", body, auth: true }), + schedulePersonaDrafts: (personaId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) => + apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/schedule`, { method: "POST", body, auth: true }), + stylePresets: (personaId: string) => apiRequest<{ list: StylePreset[] }>(`/api/v1/personas/${personaId}/style-presets`, { auth: true }), + upsertStylePreset: (personaId: string, presetId: string, body: { name: string; tone?: string; cta?: string[]; banned_words?: string[]; notes?: string; apply?: boolean }) => + apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "PUT", body, auth: true }), + deleteStylePreset: (personaId: string, presetId: string) => + apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "DELETE", auth: true }), + + copyMissions: (personaId: string) => apiRequest<{ list: CopyMission[] }>(`/api/v1/personas/${personaId}/copy-missions`, { auth: true }), + createCopyMission: (personaId: string, body: { label: string; seed_query: string; brief: string }) => + apiRequest(`/api/v1/personas/${personaId}/copy-missions`, { method: "POST", body, auth: true }), + inspireCopyMission: (personaId: string, keyword: string) => + apiRequest>(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body: { keyword }, auth: true }), + startCopyMissionAnalyze: (personaId: string, missionId: string) => + apiRequest<{ job_id: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/analyze-jobs`, { method: "POST", auth: true }), + startCopyMissionMatrix: (personaId: string, missionId: string, count: number) => + apiRequest<{ job_id: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/matrix-jobs`, { method: "POST", body: { count }, auth: true }), + missionDrafts: (personaId: string, missionId: string) => + apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/copy-drafts`, { auth: true }), + scheduleMissionDrafts: (personaId: string, missionId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) => + apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/copy-drafts/schedule`, { method: "POST", body, auth: true }), + + brands: () => apiRequest<{ list: Brand[] }>("/api/v1/brands/", { auth: true }), + createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }), + updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }), + startBrandScan: (id: string, body: Record) => + apiRequest<{ job_id: string; status: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }), + brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }), + generateOutreachDrafts: (id: string, body: Record) => + apiRequest>(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + + jobs: (page = 1) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=20`, { auth: true }), + cancelJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/cancel`, { method: "POST", auth: true }), + retryJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/retry`, { method: "POST", auth: true }), + jobSchedules: () => apiRequest<{ list: JobSchedule[]; pagination: Pagination }>("/api/v1/job/schedules?page=1&pageSize=50", { auth: true }), + settings: (scope: string, scopeId: string) => apiRequest<{ list: SettingValue[]; pagination: Pagination }>(`/api/v1/settings/${scope}/${scopeId}`, { auth: true }), + permissions: () => apiRequest("/api/v1/permissions/me?include_tree=true", { auth: true }) +}; diff --git a/backend/web/src/auth/AuthContext.tsx b/backend/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..b755f5e --- /dev/null +++ b/backend/web/src/auth/AuthContext.tsx @@ -0,0 +1,108 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { apiRequest, clearTokens, getAccessToken, setTokens, setUnauthorizedHandler } from "../api/client"; + +export type MemberMe = { + tenant_id: string; + uid: string; + email: string; + display_name?: string; + status: string; + roles?: string[]; +}; + +type AuthContextValue = { + member: MemberMe | null; + loading: boolean; + login: (tenantId: string, email: string, password: string) => Promise; + register: (tenantId: string, email: string, password: string, displayName?: string) => Promise; + logout: () => Promise; + reloadMember: () => Promise; +}; + +const AuthContext = createContext(null); + +type TokenPair = { + access_token: string; + refresh_token: string; +}; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [member, setMember] = useState(null); + const [loading, setLoading] = useState(true); + + const reloadMember = useCallback(async () => { + if (!getAccessToken()) { + setMember(null); + setLoading(false); + return; + } + setLoading(true); + try { + const data = await apiRequest("/api/v1/members/me", { auth: true }); + setMember(data); + } catch { + clearTokens(); + setMember(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + setUnauthorizedHandler(() => { + clearTokens(); + setMember(null); + }); + void reloadMember(); + return () => setUnauthorizedHandler(undefined); + }, [reloadMember]); + + const login = useCallback( + async (tenantId: string, email: string, password: string) => { + const data = await apiRequest("/api/v1/auth/login", { + method: "POST", + body: { tenant_id: tenantId, email, password } + }); + setTokens(data.access_token, data.refresh_token); + await reloadMember(); + }, + [reloadMember] + ); + + const register = useCallback( + async (tenantId: string, email: string, password: string, displayName?: string) => { + const data = await apiRequest("/api/v1/auth/register", { + method: "POST", + body: { tenant_id: tenantId, email, password, display_name: displayName } + }); + setTokens(data.access_token, data.refresh_token); + await reloadMember(); + }, + [reloadMember] + ); + + const logout = useCallback(async () => { + try { + await apiRequest("/api/v1/auth/logout", { method: "POST", auth: true }); + } catch { + // Local logout still needs to complete if the server token is already invalid. + } + clearTokens(); + setMember(null); + }, []); + + const value = useMemo( + () => ({ member, loading, login, register, logout, reloadMember }), + [member, loading, login, register, logout, reloadMember] + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/backend/web/src/components/AcIcon.tsx b/backend/web/src/components/AcIcon.tsx new file mode 100644 index 0000000..c35708a --- /dev/null +++ b/backend/web/src/components/AcIcon.tsx @@ -0,0 +1,32 @@ +type IconName = + | "home" + | "account" + | "publish" + | "persona" + | "mission" + | "patrol" + | "job" + | "settings" + | "spark" + | "more"; + +const paths: Record = { + home: "M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z", + account: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0H5Z", + publish: "M4 19V5l16 7-16 7Zm3-4.4 7.9-2.6L7 9.4v3.1l5 1.5-5 .6Z", + persona: "M7 5h10v4H7V5Zm-2 7h14v7H5v-7Zm3 2v3h2v-3H8Zm6 0v3h2v-3h-2Z", + mission: "M5 5h14v14H5V5Zm3 4h8V7H8v2Zm0 4h8v-2H8v2Zm0 4h5v-2H8v2Z", + patrol: "M12 3 4 7v6c0 4 3.3 7 8 8 4.7-1 8-4 8-8V7l-8-4Zm0 4 4 2v4c0 2.2-1.5 4-4 5-2.5-1-4-2.8-4-5V9l4-2Z", + job: "M6 4h12v4H6V4Zm0 6h12v4H6v-4Zm0 6h12v4H6v-4Z", + settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.5 5.5v-3l-2.2-.4a7.7 7.7 0 0 0-.7-1.6l1.3-1.9-2.1-2.1-1.9 1.3c-.5-.3-1.1-.5-1.7-.7L12.8 3h-3l-.4 2.1c-.6.2-1.1.4-1.7.7L5.9 4.5 3.8 6.6l1.3 1.9c-.3.5-.5 1.1-.7 1.6l-2.2.4v3l2.2.4c.2.6.4 1.1.7 1.6l-1.3 1.9 2.1 2.1 1.9-1.3c.5.3 1.1.5 1.7.7l.4 2.1h3l.4-2.1c.6-.2 1.1-.4 1.7-.7l1.9 1.3 2.1-2.1-1.3-1.9c.3-.5.5-1.1.7-1.6l2.1-.4Z", + spark: "M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8L12 2Zm6 13 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z", + more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z" +}; + +export function AcIcon({ name, size = 22 }: { name: IconName; size?: number }) { + return ( + + ); +} diff --git a/backend/web/src/components/AuthDecor.tsx b/backend/web/src/components/AuthDecor.tsx new file mode 100644 index 0000000..e5f2eff --- /dev/null +++ b/backend/web/src/components/AuthDecor.tsx @@ -0,0 +1,22 @@ +export function SceneDecor() { + return ( +