feat/rebone #3

Merged
daniel.w merged 8 commits from feat/rebone into main 2026-07-06 06:13:18 +00:00
848 changed files with 53447 additions and 45668 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -2,7 +2,11 @@
.run
.cursor
**/node_modules
**/.env
backend/bin
backend/web/dist
backend/web/public/downloads/*.zip
backend/dev-console/dist
frontend/dist
**/*_test.go
**/.DS_Store

34
.gitignore vendored
View File

@ -1,13 +1,29 @@
# --- secrets / env(不要 commit 實值)---
# --- secrets / env ---
deploy/.env
deploy/.env.dev
deploy/.env.prod
infra/.env
*.env
**/.env
!.env.example
!*.env.example
# --- build 產物 ---
backend/bin/
frontend/dist/
# --- 依賴 ---
node_modules/
# --- 其他 ---
# --- runtime / logs ---
.run/
*.log
# --- build artifacts ---
backend/bin/
backend/web/dist/
backend/dev-console/dist/
frontend/dist/
dist/
backend/web/public/downloads/*.zip
# --- dependencies ---
node_modules/
**/node_modules/
# --- OS / editor ---
.DS_Store
.cursor/

View File

@ -1 +0,0 @@
72708

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
> haixun-web@0.1.0 dev
> vite
VITE v6.4.3 ready in 187 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
72713

View File

@ -1 +0,0 @@
72714

View File

@ -22,6 +22,7 @@
## 設計文件
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
- `docs/scan-placement-plan.md`:海巡獲客(流程 B— 知識圖譜、Brave 擴展、雙軌爬取7 天重點 / 30 天補充)、產品匹配、島民交接。
## 新增 API 流程

292
Makefile
View File

@ -1,200 +1,128 @@
# 巡樓 monorepo Makefile
# 兩種模式:
# dev - 本機開發docker 起 Mongo/Redisgo run / vite dev 在本機跑)
# docker - 全棧一鍵啟動Mongo/Redis/gateway/worker/node-worker/web 全在 docker
#
# 常用:
# make up # 一鍵build + 啟動整套含前後端、worker、DB
# make down # 停掉整套
# make logs # 追全部日誌
# make dev-infra # 本機開發:只起 Mongo/Redis
# make dev-backend # 本機開發go run gateway (:8890)
# make dev-frontend # 本機開發vite dev (:5173)
SHELL := /bin/bash
# --- 路徑 ---
BACKEND_DIR := backend
FRONTEND_DIR := frontend
WEB_DIR := backend/web
INFRA_DIR := infra
DEPLOY_DIR := deploy
# --- docker composeprod 全棧)---
COMPOSE_FILE := deploy/docker-compose.yml
ENV_FILE := deploy/.env
COMPOSE := docker compose -f $(COMPOSE_FILE) --env-file $(ENV_FILE)
# --- docker compose本地測試base + dev override + dev env---
DEV_ENV_FILE := deploy/.env.dev
DEV_COMPOSE := docker compose -f $(COMPOSE_FILE) -f deploy/docker-compose.dev.yml --env-file $(DEV_ENV_FILE)
# go worker 用 gateway 內嵌dev 不啟動獨立 worker 容器
DEV_SERVICES := mongo redis init gateway node-worker web
# --- dev 用 worker secret對應 etc/gateway.yaml 的 InternalWorker.Secret---
DEV_WORKER_SECRET := haixun-dev-worker-secret
DEV_BACKEND_URL := http://127.0.0.1:8890
INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml
INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml
DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml
.DEFAULT_GOAL := help
.PHONY: help
help: ## 顯示可用指令
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
$(ENV_FILE):
@test -f $(ENV_FILE) || (cp deploy/.env.example $(ENV_FILE) && echo "已從 deploy/.env.example 建立 $(ENV_FILE),請填入密碼與 secret 後再 make up")
# ============================================================
# DOCKER全棧一鍵
# ============================================================
.PHONY: up
up: $(ENV_FILE) ## [docker] 一鍵啟動整套build + up背景
$(COMPOSE) up -d --build
@echo ""
@echo "=== 啟動中 ==="
@echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)"
@echo " 健康檢查: curl http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)/api/v1/health"
@echo " 看狀態: make ps 看日誌: make logs"
.PHONY: down
down: ## [docker] 停掉整套(保留資料 volume
$(COMPOSE) down
.PHONY: down-clean
down-clean: ## [docker] 停掉整套並刪除資料 volume清空 Mongo/Redis
$(COMPOSE) down -v
.PHONY: build
build: $(ENV_FILE) ## [docker] 只 build image不啟動
$(COMPOSE) build
.PHONY: rebuild
rebuild: $(ENV_FILE) ## [docker] 無快取重新 build 並重啟
$(COMPOSE) build --no-cache
$(COMPOSE) up -d
.PHONY: restart
restart: ## [docker] 重啟所有服務
$(COMPOSE) restart
.PHONY: ps
ps: ## [docker] 看服務狀態
$(COMPOSE) ps
.PHONY: logs
logs: ## [docker] 追全部日誌Ctrl-C 離開)
$(COMPOSE) logs -f
.PHONY: logs-gateway
logs-gateway: ## [docker] 只看 gateway 日誌
$(COMPOSE) logs -f gateway
.PHONY: init
init: $(ENV_FILE) ## [docker] 重跑一次 DB 初始化(索引/權限/admin冪等
$(COMPOSE) run --rm init
.PHONY: config
config: $(ENV_FILE) ## [docker] 驗證 compose 設定
$(COMPOSE) config >/dev/null && echo "docker compose config OK"
# ============================================================
# DEV本地測試全走 docker
# ============================================================
.PHONY: dev-up
dev-up: ## [dev] 一鍵:本地 docker 測試gateway 開 :8890web 開 :8080go worker 內嵌)
$(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
@echo ""
@echo "=== 本地測試啟動中 ==="
@echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(DEV_ENV_FILE) | cut -d= -f2)"
@echo " API http://localhost:8890/api/v1/health"
@echo " DevHeaderFallback 已開(可用 X-Tenant-ID / X-UID 模擬登入)"
.PHONY: dev-down
dev-down: ## [dev] 停掉本地 docker保留資料
$(DEV_COMPOSE) down
.PHONY: dev-down-clean
dev-down-clean: ## [dev] 停掉本地 docker 並清空資料 volume
$(DEV_COMPOSE) down -v
.PHONY: dev-rebuild
dev-rebuild: ## [dev] 改 code 後重新 build 並重啟本地 docker
$(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
.PHONY: dev-restart
dev-restart: ## [dev] 只重啟(改了 etc/*.yaml 後不用 rebuild掛載即生效
$(DEV_COMPOSE) restart gateway node-worker
.PHONY: dev-ps
dev-ps: ## [dev] 看本地 docker 狀態
$(DEV_COMPOSE) ps
.PHONY: dev-logs
dev-logs: ## [dev] 追本地 docker 日誌
$(DEV_COMPOSE) logs -f
.PHONY: dev-init
dev-init: ## [dev] 重跑一次 DB 初始化(索引/權限/admin冪等
$(DEV_COMPOSE) run --rm init
.PHONY: dev
dev: ## [dev] 本地 docker 測試說明
@echo "本地測試(全 docker"
@echo " make dev-up # 一鍵起整套(前端 :8080、API :8890"
@echo " make dev-logs # 看日誌"
@echo " make dev-restart # 改 etc/*.yaml 後重啟(免 rebuild"
@echo " make dev-rebuild # 改 Go/前端 code 後重 build"
@echo " make dev-down # 停(保留資料)"
# --- 進階:不進 docker直接本機 go run / vite需自備 Go/Node---
.PHONY: local-infra
local-infra: ## [local] 只起 Mongo + Redisdocker其餘本機跑
$(DEV_COMPOSE) up -d mongo redis
$(DEV_COMPOSE) ps
.PHONY: local-backend
local-backend: ## [local] 本機 go run gateway (:8890)
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
.PHONY: local-worker
local-worker: ## [local] 本機 go run Go job worker (:8891)
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
.PHONY: local-node-worker
local-node-worker: ## [local] 本機跑 Node playwright worker (style-8d)
cd $(BACKEND_DIR)/worker && npm install && \
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
.PHONY: local-frontend
local-frontend: ## [local] 本機跑前端 dev server (:5173)
cd $(FRONTEND_DIR) && npm install && npm run dev
.PHONY: local-init
local-init: ## [local] 本機初始化 DB索引/權限)並建立 admin
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
# ============================================================
# 驗證 / 維護
# ============================================================
.PHONY: tidy
tidy: ## go mod tidy
cd $(BACKEND_DIR) && go mod tidy
.PHONY: setup-env
setup-env: ## 互動式產生 deploy/.env.prod自動 URL-encode MongoDB 密碼)
@$(DEPLOY_DIR)/setup.sh
.PHONY: gen-api
gen-api: ## 用 goctl 從 .api 重新產生 handler/logic/types/routes
cd $(BACKEND_DIR) && goctl api go -api generate/api/gateway.api -dir . -home generate/goctl -style go_zero
gen-api: ## 重新產生後端 API handler / logic / types
$(MAKE) -C $(BACKEND_DIR) gen-api
.PHONY: fmt
fmt: ## gofmt 後端
cd $(BACKEND_DIR) && gofmt -w .
fmt: ## 格式化後端 Go 程式
$(MAKE) -C $(BACKEND_DIR) fmt
.PHONY: test
test: ## 跑後端測試
cd $(BACKEND_DIR) && go test ./...
test: ## 執行後端測試
$(MAKE) -C $(BACKEND_DIR) test
.PHONY: init
init: ## 初始化系統(建索引、種權限、建立 admin讀 deploy/.env.prod
$(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
.PHONY: run
run: ## 啟動後端 API
$(MAKE) -C $(BACKEND_DIR) run
.PHONY: dev-all
dev-all: ## 啟動本機完整 dev stackAPI、Go workers、Node workers、web
./scripts/dev-all.sh
.PHONY: dev-all-stop
dev-all-stop: ## 停止本機 dev stack釋放 8890 / 9101 / 9102 / web 等埠)
./scripts/dev-all-stop.sh
.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: $(ENV_FILE) ## 後端 build/test + 前端 build + compose 語法
cd $(BACKEND_DIR) && go build ./... && go test ./...
cd $(FRONTEND_DIR) && npm ci && npm run build
$(COMPOSE) config >/dev/null && echo "docker compose config OK"
verify: test web-build ## 後端測試與前端建置
.PHONY: dev-infra
dev-infra: ## 啟動本機 dev 資料服務Mongo / Redis 容器,讀 deploy/.env.dev
@test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2
$(INFRA_DEV_COMPOSE) up -d
$(INFRA_DEV_COMPOSE) ps
.PHONY: dev-infra-down
dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume
$(INFRA_DEV_COMPOSE) down
.PHONY: dev-infra-recreate
dev-infra-recreate: ## 清除 dev 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_DEV_COMPOSE) down -v
$(INFRA_DEV_COMPOSE) up -d
$(INFRA_DEV_COMPOSE) ps
.PHONY: prod-infra
prod-infra: ## 啟動 prod 資料服務Mongo / Redis讀 deploy/.env.prod
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
.PHONY: prod-infra-down
prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-infra-recreate
prod-infra-recreate: ## 清除 prod 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
.PHONY: prod-install
prod-install: ## 一鍵部署 prod複製 .env.prod、build 本地 image、啟動 app、init需先啟動 infra
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
@echo ">>> 請確認 infra 已啟動make prod-infra" >&2
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps
.PHONY: prod-rebuild
prod-rebuild: ## 重新 build 本地 image 並重建 prod app stackinfra 不影響)
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) ps
.PHONY: prod-stop
prod-stop: ## 關閉 prod app stack保留 infra 與資料)
$(DEPLOY_COMPOSE) down
.PHONY: prod-stop-all
prod-stop-all: ## 關閉 prod app + infra保留資料 volume
$(DEPLOY_COMPOSE) down
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-recreate
prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動app + infra + init
$(DEPLOY_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps

File diff suppressed because it is too large Load Diff

BIN
backend/.DS_Store vendored

Binary file not shown.

24
backend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM golang:1.24-alpine AS build
WORKDIR /src
RUN apk add --no-cache ca-certificates git
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./gateway.go \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tool ./cmd/tool
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S haixun \
&& adduser -S -G haixun -h /app haixun
WORKDIR /app
COPY --from=build /out/gateway /app/gateway
COPY --from=build /out/worker /app/worker
COPY --from=build /out/tool /app/tool
COPY backend/etc /app/etc
USER haixun
EXPOSE 8890
CMD ["/app/gateway", "-f", "/app/etc/gateway.prod.yaml"]

47
backend/Makefile Normal file
View File

@ -0,0 +1,47 @@
.PHONY: gen-api fmt test run web-dev web-build
API_FILE := generate/api/gateway.api
GOCTL_HOME := generate/goctl
GOCTL ?= $(shell go env GOPATH)/bin/goctl
ENV_FILE ?= ../deploy/.env.dev
ifneq ($(wildcard $(ENV_FILE)),)
# 把 deploy/.env.dev 的值 export 出來(給 make run / native binary 用dev-all.sh 自行 source 故不走這條)
include $(ENV_FILE)
export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL
export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS
export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
endif
export HAIXUN_JOB_WORKER_TYPE ?= go-demo
export HAIXUN_JOB_WORKER_ID ?= local-go-demo
# 修改 generate/api/*.api 後執行,重新產生 routes / types / handlerlogic 已存在則保留實作)
gen-api:
$(GOCTL) api go -api $(API_FILE) -dir . -style go_zero -home $(GOCTL_HOME)
fmt:
go fmt ./...
test:
go test ./...
run:
mkdir -p bin
go build -o bin/gateway gateway.go
./bin/gateway -f etc/gateway.yaml
run-dev:
go run gateway.go -f etc/gateway.yaml
init:
go run ./cmd/tool/main.go init -f etc/gateway.yaml
web-dev:
cd web && npm install && npm run dev
web-build:
cd web && npm install && npm run build

View File

@ -12,6 +12,7 @@
- `auth`native email/password 登入、JWT access/refresh token、logout revoke。
- `member`:目前登入會員的 profile 讀寫。
- `permission`permission catalog 與目前會員權限查詢。
- `threads automation`Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。
暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
@ -23,6 +24,13 @@ go mod download
make run
```
正式前端:
```bash
make web-dev # Vite dev server :5173proxy 到 :8890
make web-build # TypeScript + Vite build
```
預設服務:
```text
@ -61,10 +69,10 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1要嚴格一點可調高
前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
```text
確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略
準備爬蟲 -> 抓取公開樣本 -> AI 8D -> 儲存策略
```
目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構;若公開頁無法讀到足夠樣本job 會標記為 `failed` 並顯示原因,不會停在等待狀態。
人設 8D 分析**不需** Chrome extension sessionNode worker 以匿名 Playwright 讀取對標帳號的公開個人頁。若公開頁無法讀到足夠樣本job 會標記為 `failed` 並顯示原因,不會停在等待狀態。AI 分析使用會員設定頁的研究用 provider / API key有選定經營帳號時沿用該帳號設定
## 專案結構
@ -72,6 +80,8 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1要嚴格一點可調高
haixun-backend/
gateway.go # go-zero server 入口
Makefile # gen-api / fmt / test / run
web/ # 正式巡樓 ConsoleVite + React + TypeScript
dev-console/ # 本機診斷用最小開發面板,不是產品 UI
etc/ # runtime config
generate/
api/ # goctl .api 定義
@ -87,6 +97,11 @@ haixun-backend/
auth/ # JWT token issue/refresh/logout + Redis revoke store
member/ # Native member profile + password hash
permission/ # Permission catalog + role permission mapping
publish_queue/ # Threads 發文 queue、guarded transition、retry/missed
publish_inventory/ # 自動補庫存 policy
publish_guard/ # 發文頻率護欄與 pause/resume
publish_queue_event/ # Queue 狀態轉移與告警事件
style_preset/ # 人設語調 preset、CTA、禁用詞
worker/ # 常駐背景 worker / scheduler / reaper
library/ # 最小 runtime library
response/ # 統一 JSON response envelope

View File

@ -15,6 +15,8 @@ import (
)
func main() {
bootstrap.LoadInfraEnv()
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
@ -37,7 +39,7 @@ func runInit(args []string) error {
configFile := fs.String("f", "etc/gateway.yaml", "config file")
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", ""), "bootstrap admin password")
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
if err := fs.Parse(args); err != nil {
return err

View File

@ -8,6 +8,7 @@ import (
"os/signal"
"syscall"
"haixun-backend/internal/bootstrap"
"haixun-backend/internal/config"
"haixun-backend/internal/svc"
@ -18,11 +19,12 @@ var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
func main() {
flag.Parse()
bootstrap.LoadInfraEnv()
var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv())
if !c.JobWorker.Enabled {
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
if !c.JobWorker.Enabled && !c.JobScheduler.Enabled && !c.JobReaper.Enabled && !c.ThreadsTokenRefresh.Enabled && !c.ThreadsPublishQueue.Enabled {
fmt.Fprintln(os.Stderr, "[worker] at least one worker, scheduler, reaper, token sweeper, or publish queue sweeper must be enabled")
os.Exit(1)
}
@ -30,10 +32,13 @@ func main() {
defer sc.Close(context.Background())
fmt.Printf(
"[worker] started type=%s (scheduler=%v reaper=%v)\n",
"[worker] started type=%s (job=%v scheduler=%v reaper=%v token_sweeper=%v publish_sweeper=%v)\n",
c.JobWorker.WorkerType,
c.JobWorker.Enabled,
c.JobScheduler.Enabled,
c.JobReaper.Enabled,
c.ThreadsTokenRefresh.Enabled,
c.ThreadsPublishQueue.Enabled,
)
ch := make(chan os.Signal, 1)

View File

@ -0,0 +1,37 @@
# 巡樓 Dev Console
重做正式前端前的**最小開發面板**:登入、人設 CRUD、8D 公開爬蟲任務與 job 輪詢。
## 啟動
```bash
# 1. 後端 API另開終端
cd backend
go run gateway.go -f etc/gateway.yaml
# 2. Node 8D worker另開終端人設 8D 需要)
cd backend/worker
npm install
npm run style-8d
# 3. Dev Console
cd backend/dev-console
npm install
npm run dev
```
瀏覽器開啟 http://127.0.0.1:5173 。API 透過 Vite proxy 轉到 `http://127.0.0.1:8890`,無需處理 CORS。
## 使用順序
1. 註冊或登入tenant 預設 `default`
2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存**
3. 建立人設 → 點選列表中的一筆
4. 填對標 Threads 帳號 → **開始 8D**
5. 自動輪詢 job完成後下方人設詳情會出現 `style_profile`
## 注意
- 8D 分析使用**研究用** provider / model + API key與 Chrome session 無關)
- 若尚無經營帳號Dev Console 會自動建立「Dev Console」帳號AI key 實際存會員 scope
- 僅供本機開發,不是產品 UI

File diff suppressed because one or more lines are too long

225
backend/dev-console/dist/index.html vendored Normal file
View File

@ -0,0 +1,225 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡樓 Dev Console</title>
<style>
:root {
--bg: #f4f6f8;
--card: #fff;
--ink: #1a2332;
--muted: #5a6578;
--line: #d8dee8;
--brand: #2a9d8f;
--danger: #c0392b;
--ok: #1e7a4c;
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
--sans: system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
header {
padding: 1rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 1.1rem; }
header p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
main {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.25rem 3rem;
display: grid;
gap: 1rem;
}
section {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
}
section h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
letter-spacing: 0.02em;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
label { font-size: 0.82rem; color: var(--muted); min-width: 5.5rem; }
input, select, textarea {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
min-width: 0;
}
input { flex: 1; }
button {
font: inherit;
padding: 0.45rem 0.85rem;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
cursor: pointer;
}
button:hover { border-color: var(--brand); color: var(--brand); }
button.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
button.primary:hover { filter: brightness(1.05); color: #fff; }
button.danger { color: var(--danger); }
.status { font-size: 0.82rem; color: var(--muted); }
.status.ok { color: var(--ok); }
.status.err { color: var(--danger); }
.list {
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.list li {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--line);
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
.list li:last-child { border-bottom: none; }
.list li:hover { background: #f0faf8; }
.list li.active { background: #e6f5f2; font-weight: 600; }
.list li .meta { color: var(--muted); font-size: 0.78rem; }
pre, .log {
margin: 0.5rem 0 0;
padding: 0.75rem;
background: #0f1419;
color: #d6e2f0;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.78rem;
overflow: auto;
max-height: 280px;
white-space: pre-wrap;
word-break: break-word;
}
.steps { margin: 0.5rem 0 0; font-size: 0.85rem; }
.steps div { padding: 0.2rem 0; }
.badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
background: #eef2f7;
}
.badge.running { background: #fff3cd; }
.badge.succeeded, .badge.completed { background: #d4edda; }
.badge.failed { background: #f8d7da; }
.badge.queued, .badge.pending { background: #e8eef5; }
textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size: 0.78rem; }
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 0.35rem; }
</style>
<script type="module" crossorigin src="/assets/index-56hNfpGK.js"></script>
</head>
<body>
<header>
<div>
<h1>巡樓 Dev Console</h1>
<p>暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好</p>
</div>
<div class="status" id="connStatus">尚未連線</div>
</header>
<main>
<section>
<h2>1. 登入</h2>
<div class="row">
<label for="tenantId">Tenant</label>
<input id="tenantId" value="default" />
</div>
<div class="row">
<label for="email">Email</label>
<input id="email" type="email" placeholder="you@example.com" />
</div>
<div class="row">
<label for="password">密碼</label>
<input id="password" type="password" placeholder="至少 8 碼" />
</div>
<div class="row">
<button class="primary" id="btnLogin">登入</button>
<button id="btnRegister">註冊</button>
<button id="btnLogout" class="danger">登出</button>
<span class="status" id="authStatus"></span>
</div>
</section>
<section>
<h2>2. 人設</h2>
<div class="row">
<label for="displayName">名稱</label>
<input id="displayName" placeholder="新人設" />
<button class="primary" id="btnCreatePersona">建立</button>
<button id="btnRefreshPersonas">重新載入</button>
</div>
<ul class="list" id="personaList"></ul>
<p class="hint">點選一筆人設後,下方可跑 8D 分析。</p>
</section>
<section>
<h2>3. 8D 風格分析(公開爬蟲,不需 session</h2>
<div class="row">
<label for="benchmark">對標帳號</label>
<input id="benchmark" placeholder="@username" />
<button class="primary" id="btnStart8d" disabled>開始 8D</button>
</div>
<p class="hint">需先啟動 Go API:8890與 Node workerstyle-8d。設定頁要有研究用 AI API key。</p>
<div class="status" id="jobHint"></div>
<div class="row" style="margin-top: 0.5rem">
<label for="jobId">Job ID</label>
<input id="jobId" placeholder="自動填入或手動貼上" />
<button id="btnPollJob">查詢一次</button>
<button id="btnAutoPoll">自動輪詢</button>
</div>
<div class="steps" id="jobSteps"></div>
<pre id="jobDetail">(尚無任務)</pre>
</section>
<section>
<h2>4. 人設詳情</h2>
<div class="row">
<button id="btnReloadPersona" disabled>重新載入選中人設</button>
<span class="status" id="personaMeta"></span>
</div>
<textarea id="personaDetail" readonly placeholder="選中人設後顯示 style_profile 等欄位"></textarea>
</section>
<section>
<h2>Log</h2>
<pre class="log" id="log"></pre>
</section>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

1141
backend/dev-console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
{
"name": "haixun-dev-console",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^6.2.0"
}
}

View File

@ -1,10 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: '127.0.0.1',
port: 5173,
proxy: {
'/api': {

View File

@ -1,176 +0,0 @@
# 全 Phase 新增功能說明
## Phase 1 — TA 精準度
### 1. Semantic Embedding Reranker
**ScanPost entity 新增欄位**`internal/model/scan_post/domain/entity/post.go`:
| 欄位 | 型別 | 用途 |
|------|------|------|
| `Embedding` | `[]float32` | OpenAI text-embedding-3-small 向量 |
| `SemanticScore` | `int` | 語意相似度分數0100 |
| `EngagementPredicted` | `int` | 預測互動率 |
| `AudienceQualityScore` | `int` | 受眾品質分數0100 |
| `AuthorID` | `string` | 作者 IDThreads user ID |
| `AuthorAvatar` | `string` | 作者頭像 URL |
| `AuthorFollowers` | `int` | 作者粉絲數 |
`ScanCandidate` struct 同步新增對應欄位(`internal/library/placement/dual_track.go`)。
### 2. Embedding Client
```go
// internal/library/embedding/client.go
func NewClient(apiKey, model string) *Client
func (c *Client) Generate(ctx context.Context, text string) ([]float32, error)
```
- 預設使用 OpenAI `text-embedding-3-small`(可降級 local BGE-M3
- Scorer: `CosineSimilarity(a, b []float32) float64`
### 3. Placement Score V2
```go
// internal/library/placement/dual_track.go
func computePlacementScore(post *ScanCandidate, member productContext, brand productMeta,
semanticScore, predictedEngagement, audienceQuality *int) int
```
整合三項 pointer 參數nil 表示欄位不存在,跳過該項):
- **語意相似度**`SemanticScore`Embedding cosine → 0100
- **預測互動率**`EngagementPredicted`like+reply → 對數正規化 0100
- **受眾品質**`AudienceQualityScore``computeAudienceQuality()` heuristic
權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。
```go
func computeAudienceQuality(followers int) int
```
Heuristicfollowers 在 50050k 之間品質最佳,過低或過高遞減。
### 4. Creator Lookalike
**端點**: `POST /api/v1/scan-post/lookalike`(需 JWT
```json
{ "source_post_id": "uuid", "brand_id": "...", "limit": 10 }
→ { "list": [{ "post_id", "author_id", "author_name", "similarity", ... }] }
```
**演算法**: cosine similarity門檻 ≥0.3),同 brand 內找 embedding 最接近的貼文回傳。
| 檔案 | |
|------|--|
| Handler | `internal/handler/scan_post/lookalike_handler.go` |
| Logic | `internal/logic/scan_post/lookalike_logic.go` |
| `cosineSimilarity` | `internal/logic/scan_post/lookalike_logic.go` — 全 Go 實作 |
---
## Phase 2 — 營運流程
### 2.1 Creator CRM
**完整四層 model** `crm_contact`
```
internal/model/crm_contact/
domain/entity/entity.go
domain/repository/repository.go
domain/usecase/usecase.go
usecase/usecase.go
repository/mongo.go
```
**端點**: `/api/v1/crm/contacts`(需 JWT
| Method | Path | 用途 |
|--------|------|------|
| GET | `/` | List`page`/`pageSize`/`brand_id`/`status`/`tag` |
| POST | `/` | 新增聯絡人(`author_id` + `author_name` |
| GET | `/:id` | 取得單筆 |
| PATCH | `/:id` | 更新(`notes`/`tags`/`status` |
| DELETE | `/:id` | 刪除 |
**CRMContact 狀態機**: `lead``contacted``responded``converted` / `archived`
### 2.2 Publish Analytics
**完整四層 model** `publish_analytics`
**Checkpoint 常數**`internal/model/publish_analytics/usecase/usecase.go`: `+1h`、`+24h`、`+7d`
**方法**:
```go
RecordCheckpoint(ctx, tenantID, ownerUID, summary) (*PublishAnalyticsSummary, error)
ListByMedia(ctx, tenantID, ownerUID, mediaID) ([]PublishAnalyticsSummary, error)
```
**Worker handler** `publish-analytics`step `check`
呼叫 Threads API `GET /{media-id}?fields=like_count,reply_count,repost_count,quote_count` → 存入 `publish_analytics`
### 2.3 Threads API Token 刷新
**底層 API**`internal/library/threadsapi/media.go`:
```go
RefreshAccessToken(ctx, currentToken) (*TokenRefreshResult, error)
// GET /v1.0/refresh_access_token?grant_type=th_refresh_token
```
**儲存 Token**:
```go
// Repository
SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*entity.Secrets, error)
// Usecase
SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*SecretsData, error)
```
**Worker handler** `refresh-threads-token`step `refresh`):讀取 secrets → refresh → 儲存。
### 2.4 Rate Limiter
```go
// internal/library/ratelimit/token_bucket.go
func NewTokenBucket(rate float64, burst int) *TokenBucket
func (tb *TokenBucket) Allow() bool
func (tb *TokenBucket) AllowN(n int) bool
```
- In-memory token bucket非 Redisthread-safe
- `rate` = tokens/sec`burst` = 最大 burst
---
## Infrastructure
### Job Templates
`internal/model/job/usecase/usecase.go` 新增兩個 template constants + 對應 `Ensure*` methods + `template()` functions
| Template Type | Steps | Timeout | Retry |
|---------------|-------|---------|-------|
| `refresh-threads-token` | `refresh`go | 120s | 3 次30/120/300s |
| `publish-analytics` | `check`go | 120s | 2 次60s |
### Worker Handlers 註冊
`internal/svc/service_context.go` 中註冊:
```go
jobworker.RegisterRefreshThreadsTokenHandler(runner, RefreshThreadsTokenDeps{...})
jobworker.RegisterPublishAnalyticsHandler(runner, PublishAnalyticsDeps{...})
```
### Worker Helper
```go
// internal/worker/job/expand_graph.go
func int64Field(payload map[string]any, key string) int64
```
從 job payload 讀 `int64`,支援 `float64` / `int64` / `int` / `json.Number`

View File

@ -1,512 +0,0 @@
# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge GraphBrave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
## 北極星
海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
流程 B 主線:
```text
Brave 知識圖譜擴散(周邊延伸)
→ 衍生雙維度搜尋 tag相關詞 + 近期求助詞)
→ 使用者手動勾選
→ 每個 tag 雙軌爬 Threads相關軌 + 近期軌7d 優先 / 30d 補充)
→ productFitScore 篩選
→ 島民協助撰寫獲客留言
```
## 已拍板決策
| 項目 | 決策 |
|------|------|
| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle不預設全選 |
| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
| Brave 預算 | 中等,每輪知識擴展 **1015 次查詢**(不足可 supplemental 1 輪) |
| 痛點 tag 候選 | 圖譜衍生 **≥12 候選**,其中痛點/求助類 **≥8** |
| 流程 A | 保留 `style-8d` 捷徑matrix + 留言收集疊加於 Phase 2 |
---
## 根因診斷(舊系統痛點少、關鍵字不準)
對照舊 Next.js[`lib/ai/prompts/research-map-placement.ts`](../../lib/ai/prompts/research-map-placement.ts)、[`lib/services/scan-tasks.ts`](../../lib/services/scan-tasks.ts)、[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
| 現象 | 根因 | 新系統對策 |
|------|------|------------|
| 痛點只抓到 12 個 | Placement 壓 `suggestedTags`**24**`PLACEMENT_QUERY_MAX = 8` | 圖譜衍生 ≥8 痛點 tag + supplemental 補充迴圈 |
| 關鍵字不夠精準 | AI 憑種子詞推測,無外部知識 | Brave `knowledge_expand` 建 TKG節點附 `evidence[]` |
| 只有「最相關」 | Recency 只是加分,無獨立近期軌 | **Tag 層**分 `relevance` / `recency`**Crawl 層**雙軌必跑 |
| 沒有周邊延伸 | Brave 只做 `site:threads.net` | `knowledge_expand` 做領域知識(成因、懷孕、換季…)再衍生 tag |
新後端原則見 [`AGENTS.md`](../AGENTS.md)**複製模式,不複製舊業務**——移植 Playwright/過濾規則,用 Mongo + Job 重建。
---
## 架構總覽
```mermaid
flowchart TB
subgraph input [輸入]
Seed["種子詞"]
ProductBrief["product_brief"]
Persona["人設"]
end
subgraph tkg [知識圖譜]
ExpandJob["expand-graph job"]
BraveK["Brave knowledge_expand"]
TKG["topic_knowledge_graphs"]
end
subgraph derive [Tag衍生]
DeriveFn["deriveSearchTagsFromGraph"]
RelQ["relevanceQueries"]
RecQ["recencyQueries"]
end
subgraph select [使用者選擇]
GraphUI["圖譜 UI 勾選節點/tag"]
end
subgraph scan [海巡]
ScanJob["scan job 每tag雙軌"]
Posts["scan_posts"]
end
subgraph outcome [驗收]
Fit["productFitScore"]
Outreach["outreach + 島民留言"]
end
Seed --> ExpandJob
ProductBrief --> ExpandJob
Persona --> ExpandJob
ExpandJob --> BraveK --> TKG
TKG --> DeriveFn
DeriveFn --> RelQ
DeriveFn --> RecQ
RelQ --> GraphUI
RecQ --> GraphUI
GraphUI --> ScanJob --> Posts --> Fit --> Outreach
```
---
## Tag 產生完整流水線
Tag **不是** AI 一次吐 24 個,而是五段流水線產出:
```mermaid
flowchart LR
S["1 種子詞+brief"] --> A["2 AI核心地圖"]
A --> B["3 Brave knowledge_expand"]
B --> G["4 合成TKG三層"]
G --> D["5 deriveSearchTagsFromGraph"]
D --> R["relevanceQueries"]
D --> C["recencyQueries"]
```
| 步驟 | 做什麼 | 產出 |
|------|--------|------|
| 1 | 讀 `seed_query`、`product_brief`、`target_audience` | 輸入包 |
| 2 | AI 產核心 questions/pillars/exclusions | 研究地圖骨架 |
| 3 | Brave 1015 次**一般網搜**(非 threadsOnly | snippets → 候選節點 |
| 4 | AI 合成 TKGL0/L1/L2+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
| 5 | 每節點壓成 28 字真人搜尋詞,分兩套 | `derivedTags` |
### 雙維度 Tag相關 + 近期都要)
每個圖譜節點衍生:
| 維度 | 用途 | 寫法範例 |
|------|------|----------|
| **`relevanceQueries`** | 相關軌:短詞、高命中 | `敏感肌`、`屏障受損` |
| **`recencyQueries`** | 近期軌:求助語境 + 時間窗 | `敏感肌 請問`、`換季泛紅 推薦` |
- `recencyQueries` 在 Brave `threads_discover` 時加 `after:{7天前日期}`(參考舊 [`scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) `buildPlacementKeywordQueries`
- 候選總量:**≥12 tag**(痛點/求助類 **≥8****使用者勾選後才 crawl**
---
## 痛點 Tag 保底機制
解決「只抓到一兩個痛點」:
```text
expand-graph 完成 → deriveSearchTagsFromGraph
IF 痛點/求助類 tag 數 < 8:
→ supplemental_round最多 1 次Brave +5 查詢)
→ 追加查詢例:{seed} 困擾、{seed} 求助、{L2節點} 請問、{seed} 推薦
→ AI 補節點 + 補 derivedTags
IF 仍 < 8:
→ job 標 warningUI + 島民提示「可重跑 expand 或手動加種子詞」
```
| 指標 | 舊系統 | 新系統 |
|------|--------|--------|
| Placement suggestedTags | 24 | 不沿用此上限 |
| 搜尋任務上限 | 8 | 候選 ≥12實 crawl = 勾選數 |
| 痛點類最低 | 無保證 | **≥8**(含 supplemental |
---
## Topic Knowledge GraphTKG
### Mongo collection`topic_knowledge_graphs`
`persona_id` + `seed_query`
```json
{
"seed": "敏感肌",
"nodes": [
{
"id": "n1",
"label": "敏感肌",
"nodeKind": "pain",
"type": "core",
"layer": 0,
"placementValue": "high",
"productFitScore": 95,
"selectedForScan": false,
"evidence": [],
"derivedTags": {
"relevance": ["敏感肌"],
"recency": ["敏感肌 請問", "敏感肌 推薦"]
}
},
{
"id": "n2",
"label": "懷孕嗅覺敏感",
"nodeKind": "cause",
"type": "cause",
"layer": 2,
"relation": "可能成因",
"placementValue": "medium",
"productFitScore": 40,
"selectedForScan": false,
"evidence": [{ "url": "...", "snippet": "..." }],
"derivedTags": {
"relevance": ["懷孕皮膚癢", "嗅覺敏感"],
"recency": ["懷孕 皮膚 癢 請益"]
}
},
{
"id": "n3",
"label": "屏障修復原理",
"nodeKind": "knowledge",
"type": "mechanism",
"layer": 1,
"productFitScore": 70,
"selectedForScan": false,
"derivedTags": {
"relevance": ["屏障受損"],
"recency": ["屏障受損 怎麼辦"]
}
}
],
"edges": [
{ "from": "n1", "to": "n2", "relation": "可能因" },
{ "from": "n1", "to": "n3", "relation": "機制" }
],
"braveSources": [{ "query": "敏感肌 懷孕 原因", "snippet": "...", "url": "..." }],
"painTagCount": 9,
"generatedAt": 0
}
```
### 三層擴散
```text
L0 核心:敏感肌
L1 直接相關:屏障受損、換季泛紅、刺癢
L2 周邊情境:懷孕荷爾蒙、嗅覺敏感、壓力熬夜、換洗臉產品過敏 …
```
### 節點語意
| 欄位 | 說明 |
|------|------|
| `nodeKind` | `pain`(痛點/求助)、`knowledge`(科普延伸)、`cause`、`symptom` |
| `placementValue` | 建議優先級,**不決定是否海巡** |
| `selectedForScan` | 使用者勾選後 `true`,才進 `scan` payload |
| `productFitScore` | 依 `product_brief`:產品解不解得了 |
| `derivedTags` | `relevance` + `recency` 兩套查詢詞 |
| `evidence[]` | L1/L2 必填Brave snippet 可追溯) |
- `knowledge` 節點:延伸話題/科普靈感,**預設不勾選**;若 snippet 含求助語境可升級為 `pain`
- `knowledge` 不強制進 placement crawl除非使用者勾選且 `productFitScore` 達標
---
## Brave 雙模式
| 模式 | `threadsOnly` | 用途 |
|------|---------------|------|
| `knowledge_expand` | `false` | 建 TKG找成因/周邊/知識 |
| `threads_discover` | `true` | 海巡時找 Threads 貼文 |
### L0/L1 查詢模板plan_queries上限 15/輪)
```text
{seed} 常見原因
{seed} 什麼情況會
{seed} 初期 症狀
{seed} 怎麼改善 困擾
{seed} 求助 推薦
```
### L2 周邊擴散查詢池(從 brief/受眾推導)
```text
{seed} 懷孕 相關
{seed} 壓力 熬夜
{seed} 換產品 過敏
{seed} 與 {受眾場景} 的關係
{L1節點} 原因
{L1節點} 困擾
```
Brave 回傳 title/snippet/url → AI 萃取節點與邊 → 寫入 TKG。實作`internal/library/knowledge/` + Brave adapter`BRAVE_SEARCH_API_KEY`;參考舊 [`lib/services/web-search.ts`](../../lib/services/web-search.ts))。
---
## 完整範例:敏感肌 Walkthrough
**輸入**
- 種子詞:`敏感肌`
- product_brief溫和修護、無香料、適合敏感/屏障受損肌
**Brave knowledge_expand節錄**
| 查詢 | snippet 線索 | 圖譜節點 |
|------|--------------|----------|
| `敏感肌 常見原因` | 屏障受損、過度清潔 | L1 symptom `屏障受損` |
| `敏感肌 懷孕` | 荷爾蒙、嗅覺/皮膚變敏感 | L2 cause `懷孕嗅覺敏感` |
| `換季 皮膚 泛紅` | 季節性刺激 | L1 symptom `換季泛紅` |
**衍生 tag候選勾選前不 crawl**
| 節點 | relevanceQuery | recencyQuery | productFit |
|------|----------------|--------------|------------|
| 敏感肌 | `敏感肌` | `敏感肌 請問` | 95 |
| 屏障受損 | `屏障受損` | `屏障受損 推薦` | 90 |
| 換季泛紅 | `換季泛紅` | `換季泛紅 請問` | 88 |
| 懷孕皮膚癢 | `懷孕皮膚癢` | `懷孕 皮膚 癢 請益` | 視產品而定 |
**使用者**:勾選 productFit 高的 4 個節點(可不勾懷孕若產品不適用)
**startScan**:每個勾選節點的 relevance + recency 詞都跑雙軌
| 軌道 | 行為 | 本例預期 |
|------|------|----------|
| 相關軌 | sort=relevance, limit≈12 | 高互動痛點貼文 |
| 近期軌 | 7d 優先,不足補 30d | 一週內求助帖 |
**合併** → gold / recent / relevant → `productFitScore` → 獲客台 → 島民 `generateOutreachReply` + fill
---
## 近期窗口
| 窗口 | 天數 | 行為 |
|------|------|------|
| **重點** | 7 天內 | 優先爬取、優先顯示、排序最高 |
| **補充** | 830 天 | 7 天內不足時才補,排序較低 |
| **排除** | >30 天 | 不進海巡與獲客清單 |
策略:
1. 每個勾選 tag 的**近期軌**先抓滿 7 天名額
2. 全輪痛點貼文不足目標時,自動放寬至 30 天
3. 獲客台預設篩「7 天內」,可切「含 30 天內補充」
---
## 雙軌海巡Tag + Crawl + UI 三層對齊)
**近期軌不是相關軌的副產品**——每個勾選 tag 的 relevance 與 recency 查詢都**必跑**。
| 層級 | 相關 | 近期 |
|------|------|------|
| **Tag** | `derivedTags.relevance` 短詞高命中 | `derivedTags.recency` 求助語境 + after 日期 |
| **Crawl** | 相關軌 sort=relevance, limit≈12 | 近期軌 7d 滿額 → 30d 補 |
| **UI** | 可篩 `priority=relevant` | 預設 7d + `priority=gold` 置頂 |
合併優先級:
1. 兩軌皆有 → `gold`
2. 僅近期軌 → `recent`
3. 僅相關軌 → `relevant`
過濾:移植 `hasPlacementIntent`、`looksLikeCasualChat`(舊 [`lib/topic-anchor.ts`](../../lib/topic-anchor.ts)、[`lib/scan-recency.ts`](../../lib/scan-recency.ts))。
### scan_posts 擴充欄位
- `placement_score`、`priority`gold/recent/relevant
- `product_fit_score`、`solved_by_product`
- `posted_at`、`search_tag`、`query_dimension`relevance/recency
- `graph_node_id`
- `replies[]`(可選,`scrape_replies: true`
---
## 產品匹配驗收
每篇海巡結果:
- **`productFitScore`**:痛點 vs `product_brief`
- **`solvedByProduct`**:獲客留言是否對應產品能力(生成時強制檢查)
獲客台 UI
- 預設排序7 天內 + 產品能解決
- 標示:可置入 / 需人工 / 超出產品範圍
- 獲客留言:**島民 fill 全文,不自動送出**
---
## 島民交接
### job.result.handoff
```json
{
"handoff": {
"flow": "placement",
"persona_id": "...",
"pain_tag_count": 9,
"summary": "12 候選 tag → 勾選 6 節點 → 38 篇;痛點 10核心 6 + 周邊 47 天內 8 篇",
"pain_breakdown": { "core": 6, "peripheral": 4, "recent_7d": 8 },
"top_peripheral_hits": ["懷孕皮膚癢", "換季泛紅"],
"next_route": "/personas/:id/outreach",
"needs_supplemental_expand": false,
"connection_required": false
}
}
```
JobMonitor → `islanderHandoffStore`[`buildIslanderContext`](../web/src/lib/islander/buildIslanderContext.ts) 注入【近期海巡交接】。
### Custom actions
| Action | 用途 |
|--------|------|
| `expandKnowledgeGraph` | 觸發 `expand-graph``supplemental=true` 補充迴圈 |
| `toggleGraphNode` | 勾選/取消節點 |
| `startScan` | `dual_track=true`,只爬 `selectedForScan` 節點 |
| `generateOutreachReply` | 產獲客留言 |
| `applyDraft` | fill 留言欄位 |
### 對話路徑(流程 B
```text
「幫我找敏感肌的痛點」
→ expand-graphBrave knowledge_expand + AI 合成 TKG
→ IF pain_tag_count < 8 島民要再補一輪 Brave supplemental_round
→ 研究頁:圖譜 + 雙維度 tag + productFitScore
→ 使用者手動勾選節點
→ startScan每詞雙軌相關 + 近期7d/30d
→ outreach → highlight gold/recent → generateOutreachReply + fill
```
---
## Job 模板
| Template | Steps | worker |
|----------|-------|--------|
| `expand-graph` | plan_queries → brave_knowledge → ai_synth → derive_tags → [supplemental?] → persist_tkg | go |
| `scan` | session → crawl_dual_track → replies? → store → filter → ai_fit → persist | node + go |
| `style-8d` | (既有) | node + go |
執行順序:**expand-graph → 勾選 tag → scan**。
---
## API 草案
```text
POST /api/v1/personas/:id/knowledge-graph/expand # ?supplemental=true
GET /api/v1/personas/:id/knowledge-graph
PATCH /api/v1/personas/:id/knowledge-graph/nodes # selectedForScan
POST /api/v1/personas/:id/scan-jobs # graph_id, selected_node_ids, dual_track
GET /api/v1/personas/:id/scan-posts # recent_7d, product_fit_min, priority
POST /api/v1/personas/:id/outreach-drafts/generate
```
Internal worker`POST /workers/scan-posts/batch`、Brave/AI 內部端點。
---
## 前端頁面
| 路徑 | 用途 | 島民 label |
|------|------|------------|
| `/personas/:id/research` | 圖譜、雙維度 tag、勾選 | 加入海巡、Brave 再擴展 |
| `/personas/:id/outreach` | 獲客貼文 + 留言 | 獲客留言、標記已處理 |
| `/personas/:id/matrix` | 流程 APhase 2 | 草稿內容 |
研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
---
## 實作分期
### Phase 0a — 知識圖譜 + Tag 流水線
- [ ] Go Brave adapter`knowledge_expand` / `threads_discover`
- [ ] `expand-graph` jobplan_queries → brave → ai_synth → derive_tags
- [ ] `supplemental_round`(痛點 tag < 8
- [ ] Mongo `topic_knowledge_graphs`(含 `derivedTags`、`painTagCount`
- [ ] `deriveSearchTagsFromGraph`relevance + recency 雙陣列)
- [ ] API expand / get / patch nodes
### Phase 0b — 島民 handoff
- [ ] handoff`pain_tag_count`、`needs_supplemental_expand`
- [ ] JobMonitor bridge
- [ ] custom actions + `ai.islander.system.md` 海巡專章
### Phase 1 — 雙軌 scan + 流程 B
- [ ] Node `crawl_dual_track`(每 tag 相關+近期7d/30d
- [ ] `productFitScore` + outreach UI
- [ ] **驗收**:敏感肌 → L2懷孕等→ 候選痛點 tag ≥8 → 勾選後貼文痛點 ≥8 → 7d 內 ≥5
### Phase 2 — 流程 A
- [ ] matrix + 留言收集 + 島民 fill
### Phase 3 — 自動化
- [ ] job_schedules、Brave 熔斷、Meta API 發留言
---
## 風險
| 議題 | 對策 |
|------|------|
| Brave 幻覺 | 節點必須有 `evidence[]` |
| 圖譜跑題 | exclusions + `productFitScore` |
| 查詢爆炸 | Brave ≤15/輪supplemental ≤5衍生 ≤20只爬勾選 |
| 醫療敏感 | `disclaimer`;留言不自動發 |
| 周邊節點產品不符 | 低 productFit 預設不勾;獲客台標 ✗ |
---
## 參考
- 舊海巡:[`lib/services/scan.ts`](../../lib/services/scan.ts)
- 舊網搜:[`lib/services/scan-web-discover.ts`](../../lib/services/scan-web-discover.ts)
- 舊研究地圖:[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
- Job 系統:[`docs/job-system-plan.md`](./job-system-plan.md)
- 島民:[`internal/library/prompt/files/ai.islander.system.md`](../internal/library/prompt/files/ai.islander.system.md)
- 既有 8D[`worker/style-8d-worker.ts`](../worker/style-8d-worker.ts)

View File

@ -1,307 +0,0 @@
# 相似帳號 / 對標帳號 / 找 TA — 改進計畫
## ⚠ 換手進度2026-06-28
### ✅ Phase 1 已完成(全部測試通過)
`make fmt && make test` 全 ok`make build-frontend` 通過。`go build ./...` 通過。
已完成項目:
- **1.1 名額放寬**`MaxSimilarAccounts` 5 → 10`backend/internal/library/viral/discover_accounts.go:15`)。
- **1.2 停止清空**`analyze_copy_mission.go` 不再 `SimilarAccounts = nil`,改用 `similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)` 保留前次(`backend/internal/worker/job/analyze_copy_mission.go:128`)。
- **1.3 合併取代覆蓋**`scan_viral.go` 改 `libviral.MergeSimilarAccounts(prev, new)``backend/internal/worker/job/scan_viral.go:195`)。
- **1.4 排序權重抽出**`reference_accounts.go` 新增 `ReferenceRankWeights` + `DefaultReferenceRankWeights` + `rankScore`log-scale follower bucket 1k/10k/100k/1M、topicHits 加權),排序用加權和;`postTopicRelevant` 改為 `topicTopicHits` 回傳 hits 並用 `normalisedTopicTerms` lower-case 取代 `topicTerms`
- **1.5 web search fallback**`scan_viral.go:170` `BuildReferenceAccountsFromScan` 後若 `len(referenceAccounts) < MaxSimilarAccounts``placement.WebSearchAvailable(research)` → 呼 `supplementSimilarAccountsFromWeb``scan_viral.go:369`),透過 `websearch.New(memberCtx.WebSearchConfig())` 建 client → `DiscoverSimilarAccounts``MergeSimilarAccounts` 串接。失敗安全web search 失敗不讓 job fail。
- **1.6 ProfileURL helper**:新檔 `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink(permalink, username)`,優先用 permalink 反推、fallback `https://www.threads.com/@<u>``.com` 為 canonical host。`reference_accounts.go:137`、`enrich_accounts.go`、`discover_accounts.go` 已改用。
- **1.7 Brave StartPublishedDate**`brave.SearchOptions` 加 `StartPublishedDate string` 欄位forward-compat目前 brave client 尚未傳給 API`websearch/client.go` braveAdapter 把 `opts.StartPublishedDate` 塞進 libbrave.SearchOptions。Exa 原本就有支援。
- **1.8 前端顯示**`frontend/src/types/copyMission.ts` `CopySimilarAccountData``matched_source?: string[]` + `similarAccountSourceTier`/`similarAccountSourceLabel` helper`frontend/src/lib/viralSignals.ts` 加 `similarAccountTone``CopyMissionDetailPage.tsx:837-878` row 加「來源」badgescan+web/sky/success、web/sky、scan/brand、「置信度」小字。
- **schema**`missionentity.SimilarAccount` 加 `MatchedSource []string``backend/internal/model/copy_mission/domain/entity/mission.go:27``domusecase.SimilarAccountSummary` 同步;`usecase.toSummary` 帶 matched`types.CopySimilarAccountData` 同步;`logic/copy_mission/mapper.go` entity↔API 兩處對映帶 matched`generate/api/copy_mission.api` 的 `CopySimilarAccountData` 同步(手動補,**未跑 `make gen-api`**——必要時下一手可跑 gen-api 重新產生以對齊)。
- 新檔:
- `backend/internal/library/viral/merge_accounts.go` `MergeSimilarAccounts` + `unionMatchedSource`
- `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink`
- `backend/internal/worker/job/similar_accounts.go` `similarAccountsFromSummary`summary→entity 轉換 helper給 worker 跨 rerun 保留 Previous
### 未做、給下一手
- **Phase 2**:相似帳號變一等公民(`Status`/`TopicRelevance`/`LastSeenAt` schema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。
- **Phase 3**TA 樣本(留言作者採樣,`AudienceSample` + `audience.go` + worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。
- **Phase 4**:跨任務記憶(後續,未規劃細節)。
- **重新 gen-api**`generate/api/copy_mission.api` 只有 `CopySimilarAccountData``matched_source` 一處types.go 已手動同步。若下一手要擴 API schemaPhase 2建議跑 `make gen-api` 重新產 handler/logic/types 確保對齊。
- **新測試**Phase 1 沒額外加新 test case。建議下一手補`viral/merge_accounts_test.go``MergeSimilarAccounts` merges、preserves prev、truncates to limit、unionMatchedSource dedup、`viral/reference_accounts_test.go` 新排序 casetopicHits 加權、log-scale follower bucket、`worker/job/scan_viral_test.go` web fallback casemock websearch client。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。
### 已動的檔案清單Phase 1
**Go 後端**
- `backend/internal/library/viral/discover_accounts.go`常數、ProfileURL helper、accountCandidate.permalink
- `backend/internal/library/viral/reference_accounts.go`ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms
- `backend/internal/library/viral/enrich_accounts.go`MatchedSource、ProfileURL helper、authorScore.permalink
- `backend/internal/library/viral/merge_accounts.go`**新檔**
- `backend/internal/library/threadsapi/url.go`**新檔**
- `backend/internal/library/websearch/client.go`braveAdapter 傳 StartPublishedDate
- `backend/internal/library/brave/client.go`SearchOptions 加 StartPublishedDate
- `backend/internal/worker/job/scan_viral.go`web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb
- `backend/internal/worker/job/analyze_copy_mission.go`(停止清空 SimilarAccounts
- `backend/internal/worker/job/similar_accounts.go`**新檔**similarAccountsFromSummary
- `backend/internal/model/copy_mission/domain/entity/mission.go`SimilarAccount 加 MatchedSource
- `backend/internal/model/copy_mission/domain/usecase/usecase.go`SimilarAccountSummary 加 MatchedSource
- `backend/internal/model/copy_mission/usecase/usecase.go`toSummary 帶 matched
- `backend/internal/types/types.go`CopySimilarAccountData 加 matched_source
- `backend/generate/api/copy_mission.api`CopySimilarAccountData 加 matched_source
- `backend/internal/logic/copy_mission/mapper.go`(兩處對映帶 matched
**前端**
- `frontend/src/types/copyMission.ts`CopySimilarAccountData 加 matched_source + helper
- `frontend/src/lib/viralSignals.ts`similarAccountTone
- `frontend/src/pages/CopyMissionDetailPage.tsx`row 顯示 source badge、置信度小字
### 設計選擇備忘
- `similarAccountsFromSummary` 只帶 summary 可見欄位(沒 `TopicRelevance/LastSeenAt/Status`,那些是 Phase 2。Phase 1 schema 只補 `MatchedSource`,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。
- 排序權重改成 log-scale follower bucket1k/10k/100k/1M cut避免 mega 帳號壓過 niche 高相關作者。`ReferenceRankWeights` 結構在 `reference_accounts.go` 內部,未注入 input未來要調整再加。
- `ProfileURL``https://www.threads.net/@``https://www.threads.com/@`threads.com 現在是 canonical host前端 `frontend/src/lib/threadsLinks.ts``threadsProfileUrl(username, profileUrl)` 已能接受 `profile_url` injected不需改
- `MergeSimilarAccounts` 串接 priorityscan+web> 舊(保舊不被清掉),命中兩條 source 的 username `MatchedSource` 會 union 含 scan 與 web**confidence 不會自動升 high**confidence 由 `BuildReferenceAccountsFromScan` 內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在 `MergeSimilarAccounts` 或 mapper 後處理;目前只透過前端 `MatchedSource` badge 視覺呈現。
- web search fallback 只在 `< MaxSimilarAccounts(=10)` 時觸發,避免每次 scan 都額外打 web 浪費 quota也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。
- Brave `StartPublishedDate` 欄位加了但**沒 map 到 Brave 的 `freshness` 參數**Brave API freshness 接受 `d/w/m/y` 而非 ISO date要做轉換。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的 `freshness` mapping。Exa 已有支援。
### 沒動的檔案(確認 boundary
- Placement / Persona / Brand schema 完全沒動。
- 沒碰 `internal/handler/routes.go`、`internal/svc/service_context.go`、`internal/response`、`internal/middleware`。
- 沒動 `internal/model/copy_mission/repository`、`internal/model/copy_mission/domain/repository`。
---
> 範圍:僅 CopyMission flow。PlacementB 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。
> 上限:`MaxSimilarAccounts` 自 5 放寬到 10。
> 遵循:`code/message/data` envelope、`page/pageSize`、`SSCCCDDD` 錯誤碼、UTC+0 nanoseconds、Redis `workerID` lock、不裸 `Update` job 狀態。
> 驗證:`go mod tidy && make fmt && go test ./...`;前端動到另執行 `make web-build`
---
## 背景threads-api-skill repo 對位
參考 repo `madebypan/threads-api-skill` 是**純發布工具包**容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup**沒有「找相似帳號 / 找對標 / 找 TA」功能**。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實:
- `me?fields=id,name,username,...` 取 self 資訊 → 目前 `threadsapi/client.go` 完全沒用,無法驗 token 對應 username。
- `keyword_search`TOP/RECENT50/page無 cursor→ 已實作;目前沒做翻頁,召回量受限。
- `media/{id}/replies` → 已實作,目前只當 outreach 標的,**沒把留言作者彙整成 TA 樣本**。
- Threads Graph API **沒有 endpoint 查任意別人的 follower / 受眾輪廓** → 「找 TA」必須繞道把留言作者/互動者彙整成潛在受眾樣本。
---
## 目前狀態viral 流程摸整)
- 唯一活著的相似帳號產生路徑:`scan_viral.go` job → `libviral.RunDiscover`(抓 threads 貼文)→ `BuildReferenceAccountsFromScan`by author 聚合 + verified/follower/engagement 排序strict gate→relaxed fallback→ 寫入 `Mission.ResearchMap.SimilarAccounts``reference_accounts.go:43`)。
- `DiscoverSimilarAccounts` / `EnrichSimilarAccounts` / `ToEntitySimilarAccounts` 是 dead code只有測試呼叫
- `analyze_copy_mission.go:138` 顯式 `researchMap.SimilarAccounts = nil` → 重新跑研究地圖 job 會清空相似帳號,前端沒警告。
- API 對相似帳號純唯讀:`UpdateCopyMissionReq``copy_mission.api:60-72`)沒 `similar_accounts` 欄位,使用者無法排除/釘選。
- 「找 TA」目前只是 LLM 產出的一段文字 `AudienceSummary`**沒有任何「找出受眾樣本帳號」的機制**,連結構都沒有。
- 驗證缺位:`ProfileURL` 一律 `https://www.threads.net/@<username>` 拼字串(`reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119`),不驗真。
- 純 Threads API 模式下 `AuthorVerified=false, FollowerCount=0` 恆成立,排序權重失衡。
- 排序權重寫死:`reference_accounts.go:108-119` 固定 verified→follower→engagement沒有主題 pillar 相似度、沒有跨任務記憶、沒有使用者可調。
- `postTopicRelevant``reference_accounts.go:166-180`)用裸 `strings.Contains`,中文長 label 命中精度低。
---
## Phase 1 — 資料品質低風險修正(先做)
### 1.1 名額放寬
- `backend/internal/library/viral/discover_accounts.go:14``MaxSimilarAccounts = 5` → `10`
- `scan_viral.go:177``Limit: libviral.MaxSimilarAccounts` 維持引用常數。
- 前端已用 list render不需改。
### 1.2 停止 `analyze_copy_mission` 清空相似帳號
- `backend/internal/worker/job/analyze_copy_mission.go:138`:刪 `researchMap.SimilarAccounts = nil`
- 確認 `model/copy_mission/usecase/usecase.go` dot-path 更新其他 research_map 欄位時不會覆蓋 `similar_accounts`worker 才會用 `Patch.ResearchMap` 整包,且 worker 現在會保留 SimilarAccounts
### 1.3 scan 結果改合併而非覆蓋
- `scan_viral.go:189-197``SimilarAccounts: referenceAccounts` → `SimilarAccounts: mergeSimilarAccounts(mission.ResearchMap.SimilarAccounts, referenceAccounts)`
- 新檔 `backend/internal/library/viral/merge_accounts.go`by username 合併新來的更新統計、舊有保留Phase 1 schema 還沒 `Status`,先純保舊不被清掉。
- `mergeSimilarAccounts(prev, new []missionentity.SimilarAccount) []missionentity.SimilarAccount`
- key = lowercase username新>舊(更新統計欄位),舊沒被新覆蓋就保留。
- 排序:保留新來秩序在前 N 個,舊的接在後面直到達 `MaxSimilarAccounts`
### 1.4 排序權重抽出 + 主題相關改用命中詞數
- `reference_accounts.go:108-119`:抽出 `ReferenceRankWeights` struct`VerifiedW/FollowerW/TotalEngagementW/BestEngagementW/TopicRelevanceW`,預設 `4/2/1/1/2`),排序 key = 加權和。
- `reference_accounts.go:166-180` `postTopicRelevant`:仍維持「命中詞數 >= 1 才納入」,但額外回傳 `topicHitCount` 給排序用。
- `referenceAuthorAgg``topicHits int``BuildReferenceAccountsFromScan` 內部迴圈累加。
-硼切詞暫不引入 jieba對 CJK 用 `strings.Fields` + 拆字元組(>=2 字才當 term即可避免新依賴。
### 1.5 web search 補洞(復活 dead code 線)
- `scan_viral.go:172` `BuildReferenceAccountsFromScan` 之後,若 `len(referenceAccounts) < 5``memberCtx.WebSearchEnabled()`
```go
extra, err := libviral.DiscoverSimilarAccounts(ctx, websearchClient, libviral.DiscoverAccountsInput{
SeedQuery: mission.SeedQuery,
Brief: mission.Brief,
Pillars: mission.ResearchMap.Pillars,
})
if err == nil && len(extra) > 0 {
referenceAccounts = libviral.EnrichSimilarAccounts(referenceAccounts, nil, libviral.MaxSimilarAccounts)
// 把 web 找到的也 by username 合併進去
}
```
- `enrich_accounts.go``Source` 改成集合概念:`viral.SimilarAccount` 與 `missionentity.SimilarAccount` 都加 `MatchedSource []string`,命中 web 也命中 scan = `confidence="high"`
- web search 失敗**不可讓 scan job 失敗**,只跳過並 log。
### 1.6 threadsapi profile URL helper
- 新檔 `backend/internal/library/threadsapi/url.go``ProfileURLFromPermalink(permalink, username) string`
- regex `threads\.(?:com|net)/@([^/]+)/post/` 從 permalink 抓 author root `https://www.threads.com/@<u>/`
- fallback `https://www.threads.com/@<username>`(注意 `www.threads.com` 而非 `www.threads.net` 較新且穩)。
- `reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119` 改用此 helper。
### 1.7 Brave adapter 補 StartPublishedDate
- `backend/internal/library/websearch/client.go:114-127` Brave adapter`SearchOptions.StartPublishedDate`(即便 Brave API 不支援)也讀進 `libbrave.SearchOptions`,讓未來 Brave 端支援時自動生效。實作細節libbrave.SearchOptions 若沒對應欄位就先無效果,但頂層 client 不能丟失資訊。
### 1.8 前端顯示新增資訊
- `frontend/src/pages/CopyMissionDetailPage.tsx:837-875` row`source` badge已有 `AccountSource` 文字時顯示 `brand`/`neutral`)、`confidence` 用 `text-muted` 小字。
- `frontend/src/lib/viralSignals.ts:42-48` `viralAccountRowClass`:依 Phase 1 schema 補 `MatchedSource` → 範含 `scan``web``--verified-hot` 樣式。
- `frontend/src/types/copyMission.ts``SimilarAccount` 加 `matched_source?: string[]`Phase 1 只補這欄位,舊資料缺少不影響渲染)。
- 執行 `make web-build`
---
## Phase 2 — 相似帳號變一等公民(動 APICopyMission only
### 2.1 schema 變更
`backend/internal/model/copy_mission/domain/entity/mission.go:23-35` `SimilarAccount` 加:
```go
Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted
TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
```
`internal/types/types.go` 同步(重新 gen-api
### 2.2 API 新增
`backend/generate/api/copy_mission.api` 加:
```
@handler patchCopyMissionSimilarAccount
patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes)
type PatchSimilarAccountReq {
Status string `json:"status"`
}
```
`make gen-api` 重新產 handler/logic/types。logic 走 dot-path
```go
out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status)
```
錯誤碼CopyMission 目前沒有專屬 scope**Phase 2 用 `Facade` (10)** 暫時頂著,待新增 `CopyMission Scope=41` 後再回頭改。**禁止裸寫數字**,一律 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`
### 2.3 worker 尊重 status
- `reference_accounts.go:54` 開頭:`if isExcluded(in.ExcludedUsernames, post.Author) { continue }`。
- `scan_viral.go:170`:把 `mission.ResearchMap.SimilarAccounts``status=excluded` 的 username 傳進 `ReferenceAccountInput.ExcludedUsernames`
- `merge_accounts.go``status=pinned` 永遠置頂,本次沒掃到也保留。
### 2.4 前端互動
- `CopyMissionDetailPage.tsx` row 加兩按鈕「釘選」「排除」,呼 `api.patch(.../similar-accounts/:username, {status})`,本地 optimistic update。
- 新檔 `frontend/src/lib/copyMissionSimilarAccounts.ts` 包 API client。
- `CopyMissionResearchOverview.tsx` 加「相似帳號」子區(從 detail 頁整合進來)。
---
## Phase 3 — TA 樣本(留言作者採樣,全新功能)
Threads API 拿不到任意別人的受眾 → 繞道:用 `MediaReplies``ReplyCandidate.Author` 聚合成潛在受眾樣本。
### 3.1 schema 變更
`backend/internal/model/copy_mission/domain/entity/mission.go` `ResearchMap` 加:
```go
AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
```
新 struct
```go
type AudienceSample struct {
Username string `bson:"username" json:"username"`
SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
}
```
`generate/api/copy_mission.api``CopyResearchMap``AudienceSamples []CopyAudienceSampleData``make gen-api`。
### 3.2 新 library
`backend/internal/library/viral/audience.go`
```go
func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample
```
- `appearance = by username.Count`
- 過濾:自己 mission 的 author即 SimilarAccount source排除、純連結/數字罐頭 reply 文字過濾(`len < 5 chars` or URL-only
- 排序 `appearance desc → replyLikeCount desc → postedAt desc`,前 15 筆。
- confidence前端呈現用不入 struct`appearance >= 3` high、`= 2` medium、`= 1` low。
### 3.3 worker 接入
`scan_viral.go``AttachReplies` 完成後:
```go
var allReplies []placement.ReplyCandidate
for _, p := range postsWithReplies {
allReplies = append(allReplies, p.Replies...)
}
samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{
Max: 15,
ExcludeAuthors: similarAccountUsernames,
})
researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples)
```
合併邏輯與 `merge_accounts.go` 同款。
### 3.4 API
- `GET /copy-missions/:id` 已含(在 `research_map.audience_samples`)。
- `PATCH /copy-missions/:id/audience-samples/:username` body `{status}`dot-path 更新 `research_map.audience_samples.<username>.status`
- `make gen-api` + 邏輯同 2.2。
### 3.5 前端
- `CopyMissionResearchOverview.tsx` 新區「TA 樣本」(在「相似帳號」之後):
- 每筆 `@username`(連 `threadsProfileUrl`)、出現 N 篇、最近一次樣本文字。
- 按鈕「收藏為長期受眾」status=pinned、「忽略」status=excluded
- 新檔 `frontend/src/lib/copyMissionAudience.ts` 包 API client。
- `frontend/src/index.css``.hx-audience-samples*` token沿用 `hx-copy-similar-accounts` 結構。
### 3.6 Phase 3 驗收
- `make web-build` 過、`go test ./...` 過。
- E2E開 Threads API 連線 → 跑 scan job → detail page 顯示「TA 樣本」最多 15 筆、@username 連結不 404、可釘選後重跑 scan 不消失。
---
## Phase 4 — 跨任務記憶(後續,本計畫不實作)
`ThreadsAccount` 層加 `KnownAccounts map[username]AccountProfile{ FirstSeenAt, LastSeenAt, Missions[], PersonaIds[], Tags[], AvgEngagement, Status }`scan job 結束 upsert。同一 username 在多 mission 出現 → confidence 累積、auto-suggest 為 persona benchmark已 excluded 跨任務繼承。
---
## 移交檢查表
### 要動的檔案Phase 1
**Go 後端**
- `internal/library/viral/discover_accounts.go`常數、ProfileURL helper
- `internal/library/viral/reference_accounts.go`排序權重、topicHits
- `internal/library/viral/enrich_accounts.go`MatchedSource 集合)
- `internal/library/viral/merge_accounts.go`(新檔)
- `internal/library/threadsapi/url.go`新檔profileURL helper
- `internal/library/websearch/client.go`Brave 補 StartPublishedDate
- `internal/worker/job/scan_viral.go`web 補洞、merge 取代覆蓋)
- `internal/worker/job/analyze_copy_mission.go:138`(停止清空)
- `internal/model/copy_mission/domain/entity/mission.go`SimilarAccount 加 MatchedSource
- `internal/types/types.go` 同步gen-api 或手動加)
**前端Phase 1.8**
- `src/pages/CopyMissionDetailPage.tsx`(顯示 source/confidence
- `src/lib/viralSignals.ts`MatchedSource badge 對應)
- `src/types/copyMission.ts`(加 matched_source
### 要動的檔案Phase 2-3後續 agent
- `model/copy_mission/domain/entity/mission.go`Status、TopicRelevance、LastSeenAt、AudienceSample、AudienceSamples
- `generate/api/copy_mission.api`(兩支 PATCH endpoint + types
- `internal/logic/copy_mission/patch_similar_account_logic.go` + `patch_audience_sample_logic.go`gen 產出後手寫)
- `internal/library/viral/audience.go`(新檔)
- `internal/worker/job/scan_viral.go`excluded 傳遞、audience samples、pinned 保留)
- `frontend/src/components/CopyMissionResearchOverview.tsx`(整合區)
- `frontend/src/lib/copyMissionSimilarAccounts.ts`、`copyMissionAudience.ts`(新)
- `frontend/src/index.css``.hx-audience-samples*`
### 錯誤碼配置
- Phase 2 patch similar 錯誤CopyMission 無專屬 scope → 先用 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`;待新增 `CopyMission=41` 後再回頭改。**禁止裸寫數字**。
### 失敗安全
- 任何 web search 呼叫失敗不能讓 scan job fail只跳過。
- `mergeSimilarAccounts` 必須處理 prev=nil。
- `AudienceSamples`Phase 3在無 Threads API token 時直接跳過整段,不報錯。

View File

@ -0,0 +1,133 @@
# Threads 自動營運補功能計畫
## 目前狀態
P1/P2 已落地到後端 API、worker 與正式前端:
- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。
- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。
- 草稿批量排程Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`
- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移sweeper 會標記 missedfailed/cancelled/missed 可 retry。
- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。
- P2內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。
## 目標
把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。
## P1自動補庫存
### 功能
- 每個 Threads account 可設定每日/每週目標篇數。
- 可設定 weekday/time slots 與 timezonetimezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。
- 指定內容來源persona、copy mission、brand scan posts、manual seed。
- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job完成後排入 queue。
### 後端草案
- 新增 model`internal/model/publish_inventory/`。
- 新增設定 entity
- `account_id`
- `target_daily_count`
- `low_stock_threshold`
- `lookahead_days`
- `timezone`
- `slots`
- `source_refs`
- `enabled`
- 新增 job step`refill_publish_inventory`。
- 補 API
- `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy`
- `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy`
- `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs`
## P1智慧時段
### 功能
- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。
- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。
- 前端顯示「推薦時段」與「低成效時段」。
### 後端草案
- 新增 summary usecase`publish_analytics` 聚合 account slot performance。
- 補 API
- `GET /api/v1/threads-accounts/:accountId/publish-slot-insights`
- 回傳:
- `slots[]`
- `avg_likes_1h/24h/7d`
- `avg_replies_1h/24h/7d`
- `sample_size`
- `recommendation`
## P1草稿批量排程
### 功能
- 使用者在 copy drafts 中選多篇,一次排到未來 slots。
- 支援根據智慧時段自動分配 `scheduled_at`
- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。
### 後端草案
- 補 API
- `POST /api/v1/personas/:personaId/copy-drafts/schedule`
- `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule`
- request
- `account_id`
- `draft_ids`
- `start_at`
- `timezone`
- `slots`
- `mode`: `manual` / `recommended`
## P1發文 retry / 告警 / 漏發偵測
### 功能
- failed queue item 可重試,保留失敗歷史。
- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。
- 連續失敗超過門檻時 pause account publish避免 token 或 rate limit 問題擴大。
### 後端草案
- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。
- `retry` 使用 guarded update只允許 `failed/cancelled` 回到 `scheduled`
- 補 API
- `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry`
- `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events`
- `GET /api/v1/threads-accounts/:accountId/publish-alerts`
## P1頻率護欄
### 功能
- 每帳號每日上限。
- 最小發文間隔。
- 連續失敗自動 pause。
- 手動解除 pause。
### 後端草案
- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`
- sweeper dispatch 前檢查 policy違反則延後排程不直接發文。
- 補 API
- `GET /api/v1/threads-accounts/:accountId/publish-guard-policy`
- `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy`
- `POST /api/v1/threads-accounts/:accountId/publish-guard/resume`
## P2
- 內容日曆:補 queue date range query前端做週/月視圖。
- 多帳號 dashboard補跨帳號 summary endpoint避免前端 N+1 requests。
- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。
- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。
## 驗證策略
- 所有列表維持 `page/pageSize``pagination/list`
- 所有時間欄位寫入 unix nanoseconds。
- queue/job 狀態轉移都使用 guarded update。
- 長任務必須 heartbeat取消走既有 cooperative cancel 語意。

View File

@ -1,45 +0,0 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
# 本地「docker 測試」設定make dev-up 使用)。
# 連線走 compose service namemongo / redis密碼對齊 deploy/.env.dev。
# 純本機 go run不進 docker請改用 etc/gateway.yaml連 127.0.0.1)。
Mongo:
URI: mongodb://haixun:haixun-dev-pass@mongo:27017/?authSource=admin
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
Password: haixun-dev-redis
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
# 僅本地開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須 false。
DevHeaderFallback: true
Secrets:
# 留空 = 機敏資料不加密(本地測試方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
EncryptionKey: ""
InternalWorker:
Secret: haixun-dev-worker-secret
# dev 用 gateway 內嵌 go worker勿另開 worker 容器,避免兩個 worker 搶 job
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -3,8 +3,6 @@ Host: 0.0.0.0
Port: 8890
Timeout: 120000
# 連線字串與所有 secret 都從環境變數注入systemd EnvironmentFile=/opt/haixun/etc/haixun.env
# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
@ -30,12 +28,28 @@ InternalWorker:
JobWorker:
Enabled: false
WorkerType: go
WorkerType: disabled
JobScheduler:
Enabled: true
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: true
Enabled: false
IntervalSeconds: 30
ThreadsOAuth:
AppID: "${THREADS_APP_ID}"
AppSecret: "${THREADS_APP_SECRET}"
RedirectURI: "${THREADS_REDIRECT_URI}"
SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
ThreadsTokenRefresh:
Enabled: false
IntervalSeconds: 3600
LeadDays: 7
ThreadsPublishQueue:
Enabled: false
IntervalSeconds: 60
BatchSize: 20

View File

@ -27,13 +27,30 @@ InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
Enabled: ${HAIXUN_JOB_WORKER_ENABLED}
WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
WorkerID: ${HAIXUN_JOB_WORKER_ID}
JobScheduler:
Enabled: false
IntervalSeconds: 60
Enabled: ${HAIXUN_JOB_SCHEDULER_ENABLED}
IntervalSeconds: ${HAIXUN_JOB_SCHEDULER_INTERVAL_SECONDS}
JobReaper:
Enabled: false
IntervalSeconds: 30
Enabled: ${HAIXUN_JOB_REAPER_ENABLED}
IntervalSeconds: ${HAIXUN_JOB_REAPER_INTERVAL_SECONDS}
ThreadsOAuth:
AppID: "${THREADS_APP_ID}"
AppSecret: "${THREADS_APP_SECRET}"
RedirectURI: "${THREADS_REDIRECT_URI}"
SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
ThreadsTokenRefresh:
Enabled: ${HAIXUN_THREADS_TOKEN_REFRESH_ENABLED}
IntervalSeconds: ${HAIXUN_THREADS_TOKEN_REFRESH_INTERVAL_SECONDS}
LeadDays: ${HAIXUN_THREADS_TOKEN_REFRESH_LEAD_DAYS}
ThreadsPublishQueue:
Enabled: ${HAIXUN_THREADS_PUBLISH_QUEUE_ENABLED}
IntervalSeconds: ${HAIXUN_THREADS_PUBLISH_QUEUE_INTERVAL_SECONDS}
BatchSize: ${HAIXUN_THREADS_PUBLISH_QUEUE_BATCH_SIZE}

View File

@ -5,31 +5,32 @@ Timeout: 120000
# 本機開發 worker 設定go worker。搭配 `make dev-infra` 的 Mongo/Redis。
Mongo:
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ""
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: haixun-dev-worker-secret
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
WorkerID: ${HAIXUN_JOB_WORKER_ID}
JobScheduler:
Enabled: false
@ -38,3 +39,13 @@ JobScheduler:
JobReaper:
Enabled: false
IntervalSeconds: 30
ThreadsTokenRefresh:
Enabled: false
IntervalSeconds: 3600
LeadDays: 7
ThreadsPublishQueue:
Enabled: false
IntervalSeconds: 60
BatchSize: 20

View File

@ -3,36 +3,35 @@ Host: 0.0.0.0
Port: 8890
Timeout: 120000
# 本機開發設定。預設搭配 `make dev-infra`infra/docker-compose.yml)跑的 Mongo/Redis
# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步
# 本機開發設定。預設搭配 `make dev-infra`infra/docker-compose.yml,讀 deploy/.env.dev跑的 Mongo/Redis。
# 帳密對應 deploy/.env.dev 的值;若改 .env.dev 密碼,這裡不需動(${} 會重讀)
Mongo:
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
# 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。
DevHeaderFallback: true
Secrets:
# 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
EncryptionKey: ""
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: haixun-dev-worker-secret
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
Enabled: false
WorkerType: disabled
JobScheduler:
Enabled: true
@ -41,3 +40,23 @@ JobScheduler:
JobReaper:
Enabled: true
IntervalSeconds: 30
# Meta Threads OAuthApp Dashboard → 用戶端 OAuth 設定須登記 Redirect URI
ThreadsOAuth:
AppID: "${THREADS_APP_ID}"
AppSecret: "${THREADS_APP_SECRET}"
RedirectURI: "${THREADS_REDIRECT_URI}"
# OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net
SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job
ThreadsTokenRefresh:
Enabled: true
IntervalSeconds: 3600
LeadDays: 7
# 排程發文佇列 sweeper掃描 scheduled_at 到期項目並 PublishText
ThreadsPublishQueue:
Enabled: true
IntervalSeconds: 60
BatchSize: 20

View File

@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"haixun-backend/internal/bootstrap"
"haixun-backend/internal/config"
"haixun-backend/internal/handler"
"haixun-backend/internal/svc"
@ -18,6 +19,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "config file")
func main() {
flag.Parse()
bootstrap.LoadInfraEnv()
var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv())
@ -28,6 +31,7 @@ func main() {
defer sc.Close(context.Background())
handler.RegisterHandlers(server, sc)
handler.RegisterExtraHandlers(server, sc)
fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
server.Start()

View File

@ -36,6 +36,7 @@ type (
ID string `json:"id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
PlacementURL string `json:"placement_url,omitempty"`
MatchTags []string `json:"match_tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
@ -65,12 +66,14 @@ type (
CreateBrandProductReq {
Label string `json:"label" validate:"required"`
ProductContext string `json:"product_context" validate:"required"`
PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
UpdateBrandProductReq {
Label *string `json:"label,optional"`
ProductContext *string `json:"product_context,optional"`
PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
@ -183,6 +186,7 @@ type (
NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"`
TestPatrol bool `json:"test_patrol,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
@ -219,6 +223,7 @@ type (
PublishedPermalink string `json:"published_permalink,omitempty"`
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
RecencyDays int `json:"recency_days,omitempty"`
Replies []ScanReplyData `json:"replies,omitempty"`
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
CreateAt int64 `json:"create_at"`
@ -235,6 +240,7 @@ type (
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
Regenerate bool `json:"regenerate,optional"`
}
OutreachDraftItemData {
@ -258,6 +264,11 @@ type (
Confirm bool `json:"confirm"`
}
UpdateOutreachDraftReq {
DraftIndex int `json:"draft_index"`
Text string `json:"text" validate:"required"`
}
PublishOutreachDraftData {
ScanPostID string `json:"scan_post_id"`
ReplyID string `json:"reply_id"`
@ -355,6 +366,12 @@ type (
PublishOutreachDraftReq
}
UpdateOutreachDraftHandlerReq {
BrandPath
DraftID string `path:"draftId" validate:"required"`
UpdateOutreachDraftReq
}
PatchScanPostOutreachHandlerReq {
BrandPath
PostID string `path:"postId"`
@ -428,6 +445,9 @@ service gateway {
@handler publishOutreachDraft
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler updateOutreachDraft
patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
@handler patchScanPostOutreach
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)

View File

@ -166,6 +166,9 @@ type (
CopyMissionInspirationReq {
Keyword string `json:"keyword,optional"`
ContentDirection string `json:"content_direction,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
AvoidTopics []string `json:"avoid_topics,optional"`
}
CopyMissionInspirationHandlerReq {
@ -267,6 +270,26 @@ type (
Message string `json:"message"`
}
CopyMissionScheduleCopyDraftsReq {
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
CopyMissionScheduleCopyDraftsReq
}
CopyMissionScheduleCopyDraftsData {
Scheduled int `json:"scheduled"`
List []PublishQueueItemData `json:"list"`
Message string `json:"message"`
}
CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
@ -275,11 +298,22 @@ type (
}
CopyMissionInspirationData {
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
ContentPlanID string `json:"content_plan_id,omitempty"`
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
Angles []string `json:"angles,omitempty"`
Mission string `json:"mission,omitempty"`
TargetAudience string `json:"target_audience,omitempty"`
OpeningType string `json:"opening_type,omitempty"`
BodyType string `json:"body_type,omitempty"`
Emotion string `json:"emotion,omitempty"`
CtaType string `json:"cta_type,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
@ -336,6 +370,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 (CopyMissionScheduleCopyDraftsData)
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)

View File

@ -39,6 +39,7 @@ type (
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
DevModeEnabled bool `json:"dev_mode_enabled"`
}
UpdateMemberPlacementSettingsReq {
@ -49,6 +50,7 @@ type (
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
}
MemberCapabilitiesData {

View File

@ -64,6 +64,23 @@ type (
StartPersonaStyleAnalysisReq
}
StartPersonaStyleAnalysisFromTextReq {
ReferenceTexts []string `json:"reference_texts,optional"`
RawText string `json:"raw_text,optional"`
SourceLabel string `json:"source_label,optional"`
}
StartPersonaStyleAnalysisFromTextData {
Persona PersonaData `json:"persona"`
PostCount int `json:"post_count"`
Message string `json:"message"`
}
StartPersonaStyleAnalysisFromTextHandlerReq {
PersonaPath
StartPersonaStyleAnalysisFromTextReq
}
StartPersonaViralScanJobReq {
Keywords []string `json:"keywords,optional"`
}
@ -113,17 +130,29 @@ type (
CopyDraftData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
ContentPlanID string `json:"content_plan_id,omitempty"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
FormulaID string `json:"formula_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
TopicTag string `json:"topic_tag,omitempty"`
Angle string `json:"angle,omitempty"`
Hook string `json:"hook,omitempty"`
Rationale string `json:"rationale,omitempty"`
ReferenceNotes string `json:"reference_notes,omitempty"`
Sources []string `json:"sources,omitempty"`
AiScore int `json:"ai_score,omitempty"`
FormulaScore int `json:"formula_score,omitempty"`
BrandFitScore int `json:"brand_fit_score,omitempty"`
RiskScore int `json:"risk_score,omitempty"`
SimilarityScore int `json:"similarity_score,omitempty"`
EngagementPotential int `json:"engagement_potential,omitempty"`
FreshnessScore int `json:"freshness_score,omitempty"`
ReviewSuggestion string `json:"review_suggestion,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"`
@ -156,11 +185,203 @@ type (
UpdateCopyDraftReq {
Text *string `json:"text,optional"`
TopicTag *string `json:"topic_tag,optional"`
Hook *string `json:"hook,optional"`
Angle *string `json:"angle,optional"`
Rationale *string `json:"rationale,optional"`
Status *string `json:"status,optional"`
}
TopicCandidateData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Name string `json:"name"`
Source string `json:"source,omitempty"`
Category string `json:"category,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
HeatScore int `json:"heat_score,omitempty"`
FitScore int `json:"fit_score,omitempty"`
InteractionScore int `json:"interaction_score,omitempty"`
ExtendScore int `json:"extend_score,omitempty"`
RiskScore int `json:"risk_score,omitempty"`
FinalScore float64 `json:"final_score,omitempty"`
RecommendedMissions []string `json:"recommended_missions,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ContentPlanData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
Topic string `json:"topic"`
Mission string `json:"mission"`
TargetAudience string `json:"target_audience,omitempty"`
Angle string `json:"angle,omitempty"`
OpeningType string `json:"opening_type,omitempty"`
BodyType string `json:"body_type,omitempty"`
Emotion string `json:"emotion,omitempty"`
EndingType string `json:"ending_type,omitempty"`
CtaType string `json:"cta_type,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
RequiresHumanReview bool `json:"requires_human_review,omitempty"`
Avoid []string `json:"avoid,omitempty"`
SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
FeedbackEventData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
ContentPlanID string `json:"content_plan_id,omitempty"`
DraftID string `json:"draft_id,omitempty"`
Decision string `json:"decision"`
Note string `json:"note,omitempty"`
Snapshot string `json:"snapshot,omitempty"`
CreateAt int64 `json:"create_at"`
}
KnowledgeSourceData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
SourceType string `json:"source_type"`
Title string `json:"title,omitempty"`
Filename string `json:"filename,omitempty"`
ParsedStatus string `json:"parsed_status,omitempty"`
ChunkCount int `json:"chunk_count,omitempty"`
CreateAt int64 `json:"create_at"`
}
KnowledgeChunkData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
SourceID string `json:"source_id"`
Content string `json:"content"`
Topics []string `json:"topics,omitempty"`
StyleTags []string `json:"style_tags,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
CreateAt int64 `json:"create_at"`
}
FormulaPoolData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Type string `json:"type"`
Name string `json:"name"`
Pattern string `json:"pattern,omitempty"`
UseCases []string `json:"use_cases,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Weight int `json:"weight,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
CreateKnowledgeSourceReq {
SourceType string `json:"source_type" validate:"required"`
Title string `json:"title,optional"`
Filename string `json:"filename,optional"`
Content string `json:"content,optional"`
ContentBase64 string `json:"content_base64,optional"`
}
CreateKnowledgeSourceHandlerReq {
PersonaPath
CreateKnowledgeSourceReq
}
ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` }
ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` }
ListFormulaPoolsData { List []FormulaPoolData `json:"list"` }
CreateFormulaPoolReq {
Type string `json:"type" validate:"required"`
Name string `json:"name" validate:"required"`
Pattern string `json:"pattern,optional"`
UseCases []string `json:"use_cases,optional"`
Avoid []string `json:"avoid,optional"`
Weight int `json:"weight,optional"`
}
CreateFormulaPoolHandlerReq {
PersonaPath
CreateFormulaPoolReq
}
ListTopicCandidatesData {
List []TopicCandidateData `json:"list"`
}
ListContentPlansData {
List []ContentPlanData `json:"list"`
}
CreateContentPlanReq {
TopicCandidateID string `json:"topic_candidate_id,optional"`
Topic string `json:"topic" validate:"required"`
Mission string `json:"mission" validate:"required"`
TargetAudience string `json:"target_audience,optional"`
Angle string `json:"angle,optional"`
OpeningType string `json:"opening_type,optional"`
BodyType string `json:"body_type,optional"`
Emotion string `json:"emotion,optional"`
EndingType string `json:"ending_type,optional"`
CtaType string `json:"cta_type,optional"`
RiskLevel string `json:"risk_level,optional"`
RequiresHumanReview bool `json:"requires_human_review,optional"`
Avoid []string `json:"avoid,optional"`
SelectedKnowledge []string `json:"selected_knowledge,optional"`
}
CreateContentPlanHandlerReq {
PersonaPath
CreateContentPlanReq
}
ContentPlanPath {
ID string `path:"id" validate:"required"`
ContentPlanID string `path:"contentPlanId" validate:"required"`
}
UpdateContentPlanReq {
Topic *string `json:"topic,optional"`
Mission *string `json:"mission,optional"`
TargetAudience *string `json:"target_audience,optional"`
Angle *string `json:"angle,optional"`
OpeningType *string `json:"opening_type,optional"`
BodyType *string `json:"body_type,optional"`
Emotion *string `json:"emotion,optional"`
EndingType *string `json:"ending_type,optional"`
CtaType *string `json:"cta_type,optional"`
RiskLevel *string `json:"risk_level,optional"`
RequiresHumanReview *bool `json:"requires_human_review,optional"`
Avoid []string `json:"avoid,optional"`
SelectedKnowledge []string `json:"selected_knowledge,optional"`
Status *string `json:"status,optional"`
}
UpdateContentPlanHandlerReq {
ContentPlanPath
UpdateContentPlanReq
}
CreateFeedbackEventReq {
ContentPlanID string `json:"content_plan_id,optional"`
DraftID string `json:"draft_id,optional"`
Decision string `json:"decision" validate:"required"`
Note string `json:"note,optional"`
Snapshot string `json:"snapshot,optional"`
}
CreateFeedbackEventHandlerReq {
PersonaPath
CreateFeedbackEventReq
}
UpdateCopyDraftHandlerReq {
CopyDraftPath
UpdateCopyDraftReq
@ -168,6 +389,7 @@ type (
PublishCopyDraftReq {
Text string `json:"text,optional"`
TopicTag string `json:"topic_tag,optional"`
Confirm bool `json:"confirm"`
}
@ -184,10 +406,216 @@ 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"`
}
ScheduleCopyDraftsData {
Scheduled int `json:"scheduled"`
List []PublishQueueItemData `json:"list"`
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"`
}
PrunePersonaCopyDraftsReq {
Keep int `json:"keep,optional"`
}
PrunePersonaCopyDraftsHandlerReq {
PersonaPath
PrunePersonaCopyDraftsReq
}
PrunePersonaCopyDraftsData {
Deleted int64 `json:"deleted"`
Kept int `json:"kept"`
Message string `json:"message"`
}
ListPersonaContentInboxReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
Status string `form:"status,optional"`
RangeStart int64 `form:"rangeStart,optional"`
RangeEnd int64 `form:"rangeEnd,optional"`
}
ListPersonaContentInboxHandlerReq {
PersonaPath
ListPersonaContentInboxReq
}
ContentInboxItemData {
CopyDraftData
ScheduledAt int64 `json:"scheduled_at,omitempty"`
Lifecycle string `json:"lifecycle"`
GroupDate int64 `json:"group_date"`
FormulaLabel string `json:"formula_label,omitempty"`
}
ListPersonaContentInboxData {
Pagination PaginationData `json:"pagination"`
List []ContentInboxItemData `json:"list"`
}
SchedulePersonaCopyDraftReq {
AccountID string `json:"account_id" validate:"required"`
TopicTag string `json:"topic_tag,optional"`
ScheduledAt int64 `json:"scheduled_at,optional"`
}
SchedulePersonaCopyDraftHandlerReq {
CopyDraftPath
SchedulePersonaCopyDraftReq
}
ContentFormulaPath {
ID string `path:"id" validate:"required"`
FormulaID string `path:"formulaId" validate:"required"`
}
GenerateFromContentFormulaReq {
AccountID string `json:"account_id" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
GenerateFromContentFormulaHandlerReq {
ContentFormulaPath
GenerateFromContentFormulaReq
}
GenerateFromContentFormulaData {
List []CopyDraftData `json:"list"`
Message string `json:"message"`
}
GeneratePersonaTopicMatrixReq {
Topic string `json:"topic" validate:"required"`
ContentPlanID string `json:"content_plan_id,optional"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
GeneratePersonaTopicMatrixHandlerReq {
PersonaPath
GeneratePersonaTopicMatrixReq
}
GeneratePersonaTopicMatrixData {
List []CopyDraftData `json:"list"`
Message string `json:"message"`
}
StartPersonaTopicMatrixJobHandlerReq {
PersonaPath
GeneratePersonaTopicMatrixReq
}
StartPersonaTopicMatrixJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
StartPersonaFormulaDraftJobReq {
AccountID string `json:"account_id" validate:"required"`
FormulaID string `json:"formula_id" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
StartPersonaFormulaDraftJobHandlerReq {
PersonaPath
StartPersonaFormulaDraftJobReq
}
StartPersonaFormulaDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
StartPersonaRewriteDraftJobReq {
AccountID string `json:"account_id" validate:"required"`
ReferenceText string `json:"reference_text" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
SaveLabel string `json:"save_label,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
StartPersonaRewriteDraftJobHandlerReq {
PersonaPath
StartPersonaRewriteDraftJobReq
}
StartPersonaRewriteDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
AiProvider string `json:"ai_provider"`
AiModel string `json:"ai_model"`
Message string `json:"message"`
}
)
@server(
@ -216,6 +644,9 @@ service gateway {
@handler startPersonaStyleAnalysis
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
@handler startPersonaStyleAnalysisFromText
post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData)
@handler startPersonaViralScanJob
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
@ -225,6 +656,39 @@ service gateway {
@handler listPersonaCopyDrafts
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
@handler listPersonaContentInbox
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
@handler listTopicCandidates
get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData)
@handler listContentPlans
get /:id/content-plans (PersonaPath) returns (ListContentPlansData)
@handler createContentPlan
post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData)
@handler updateContentPlan
patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData)
@handler createFeedbackEvent
post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData)
@handler createKnowledgeSource
post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData)
@handler listKnowledgeSources
get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData)
@handler listKnowledgeChunks
get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData)
@handler listFormulaPools
get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData)
@handler createFormulaPool
post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData)
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@ -234,6 +698,39 @@ service gateway {
@handler publishPersonaCopyDraft
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
@handler schedulePersonaDrafts
post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
@handler schedulePersonaCopyDraft
post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData)
@handler generateFromContentFormula
post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
@handler generatePersonaTopicMatrix
post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData)
@handler startPersonaTopicMatrixJob
post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData)
@handler startPersonaFormulaDraftJob
post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData)
@handler startPersonaRewriteDraftJob
post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData)
@handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
@handler prunePersonaCopyDrafts
post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData)
@handler listStylePresets
get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
@handler upsertStylePreset
put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData)
@handler deleteStylePreset
delete /:id/style-presets/:presetId (StylePresetPath)
}

View File

@ -83,6 +83,12 @@ type (
PublishOutreachDraftReq
}
UpdatePlacementTopicOutreachDraftHandlerReq {
PlacementTopicPath
DraftID string `path:"draftId" validate:"required"`
UpdateOutreachDraftReq
}
PatchPlacementTopicScanPostOutreachHandlerReq {
PlacementTopicPath
PostID string `path:"postId"`
@ -116,6 +122,25 @@ type (
PlacementTopicPath
UpsertBrandScanScheduleReq
}
StartPlacementTopicOutreachDraftJobReq {
ScanPostID string `json:"scan_post_id,optional"`
ScanPostIDs []string `json:"scan_post_ids,optional"`
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
Regenerate bool `json:"regenerate,optional"`
}
StartPlacementTopicOutreachDraftJobHandlerReq {
PlacementTopicPath
StartPlacementTopicOutreachDraftJobReq
}
StartPlacementTopicOutreachDraftJobsData {
Jobs []StartBrandScanJobData `json:"jobs"`
Message string `json:"message"`
}
)
@server(
@ -159,9 +184,15 @@ service gateway {
@handler generatePlacementTopicOutreachDrafts
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
@handler startPlacementTopicOutreachDraftJob
post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
@handler publishPlacementTopicOutreachDraft
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler updatePlacementTopicOutreachDraft
patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
@handler patchPlacementTopicScanPostOutreach
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)

View File

@ -9,6 +9,7 @@ type (
PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
BrowserConnected bool `json:"browser_connected"`
ApiConnected bool `json:"api_connected"`
APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"`
Status string `json:"status"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
@ -19,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"`
@ -137,6 +144,711 @@ type (
Streams bool `json:"streams"`
Error string `json:"error,optional"`
}
StartThreadsOAuthQuery {
AccountID string `form:"account_id,optional"`
}
StartThreadsOAuthData {
AuthorizeURL string `json:"authorize_url"`
State string `json:"state"`
AccountID string `json:"account_id"`
}
ThreadsOAuthCallbackQuery {
Code string `form:"code,optional"`
State string `form:"state,optional"`
Error string `form:"error,optional"`
ErrorReason string `form:"error_reason,optional"`
ErrorDescription string `form:"error_description,optional"`
}
ThreadsOAuthLogItem {
At int64 `json:"at"`
Level string `json:"level"`
Stage string `json:"stage"`
AccountID string `json:"account_id,omitempty"`
Message string `json:"message"`
}
ListThreadsOAuthLogsData {
List []ThreadsOAuthLogItem `json:"list"`
}
ThreadsOAuthConfigData {
RedirectURI string `json:"redirect_uri"`
SuccessRedirect string `json:"success_redirect"`
Enabled bool `json:"enabled"`
RequiresHTTPS bool `json:"requires_https"`
}
ThreadsAPISmokeTestItem {
Scope string `json:"scope"`
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Count int `json:"count"`
}
RunThreadsAPISmokeTestData {
List []ThreadsAPISmokeTestItem `json:"list"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Total int `json:"total"`
}
RunThreadsAPIPlaygroundReq {
Action string `json:"action" validate:"required"`
Query string `json:"query,optional"`
Username string `json:"username,optional"`
MediaID string `json:"media_id,optional"`
ReplyID string `json:"reply_id,optional"`
ReplyToID string `json:"reply_to_id,optional"`
Text string `json:"text,optional"`
Permalink string `json:"permalink,optional"`
HintText string `json:"hint_text,optional"`
SkipPrime bool `json:"skip_prime,optional"`
LocationQuery string `json:"location_query,optional"`
Limit int `json:"limit,optional"`
Hide bool `json:"hide,optional"`
SearchType string `json:"search_type,optional"`
}
RunThreadsAPIPlaygroundHandlerReq {
ThreadsAccountPath
RunThreadsAPIPlaygroundReq
}
ThreadsAPIPlaygroundData {
Action string `json:"action"`
Ok bool `json:"ok"`
Message string `json:"message"`
Data string `json:"data,optional"`
}
ListThreadsPostPerformanceQuery {
Limit int `form:"limit,optional"`
}
ListThreadsPostPerformanceHandlerReq {
ThreadsAccountPath
ListThreadsPostPerformanceQuery
}
ThreadsPostMetricCapability {
Scope string `json:"scope"`
Name string `json:"name"`
Metrics []string `json:"metrics"`
Trackable bool `json:"trackable"`
Note string `json:"note,optional"`
}
ThreadsInsightMetricItem {
Name string `json:"name"`
Value int `json:"value"`
}
ThreadsAccountInsightsBlock {
Status string `json:"status"`
Message string `json:"message,optional"`
Metrics []ThreadsInsightMetricItem `json:"metrics,optional"`
}
ThreadsPostPerformanceItem {
MediaID string `json:"media_id"`
Text string `json:"text,optional"`
Permalink string `json:"permalink,optional"`
Timestamp string `json:"timestamp,optional"`
MediaType string `json:"media_type,optional"`
MediaURL string `json:"media_url,optional"`
ThumbnailURL string `json:"thumbnail_url,optional"`
TopicTag string `json:"topic_tag,optional"`
Shortcode string `json:"shortcode,optional"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Views int `json:"views,optional"`
Shares int `json:"shares,optional"`
InsightsStatus string `json:"insights_status,optional"`
InsightsMessage string `json:"insights_message,optional"`
}
ThreadsPostPerformanceData {
AccountID string `json:"account_id"`
Username string `json:"username,optional"`
ThreadsUserID string `json:"threads_user_id,optional"`
FetchedAt int64 `json:"fetched_at"`
Capabilities []ThreadsPostMetricCapability `json:"capabilities"`
AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"`
Posts []ThreadsPostPerformanceItem `json:"posts"`
}
PublishQueueItemPath {
ID string `path:"id" validate:"required"`
QID string `path:"qid" validate:"required"`
}
ListPublishQueueQuery {
Status string `form:"status,optional"`
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ListPublishQueueHandlerReq {
ThreadsAccountPath
ListPublishQueueQuery
}
CreatePublishQueueReq {
Text string `json:"text" validate:"required"`
TopicTag string `json:"topic_tag,optional"`
ScheduledAt int64 `json:"scheduled_at,optional"`
}
CreatePublishQueueHandlerReq {
ThreadsAccountPath
CreatePublishQueueReq
}
PatchPublishQueueReq {
Text *string `json:"text,optional"`
TopicTag *string `json:"topic_tag,optional"`
ScheduledAt *int64 `json:"scheduled_at,optional"`
}
PatchPublishQueueHandlerReq {
PublishQueueItemPath
PatchPublishQueueReq
}
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"`
TopicTag string `json:"topic_tag,omitempty"`
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"`
}
PublishQueueListData {
Pagination PaginationData `json:"pagination"`
List []PublishQueueItemData `json:"list"`
}
ListThreadsPublishHealthQuery {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ListThreadsPublishHealthHandlerReq {
ThreadsAccountPath
ListThreadsPublishHealthQuery
}
ThreadsPublishHealthSummary {
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"`
}
ThreadsPublishHealthCheckpoint {
Checkpoint string `json:"checkpoint"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count"`
QuoteCount int `json:"quote_count"`
Views int `json:"views,optional"`
CheckedAt int64 `json:"checked_at"`
}
ThreadsPublishHealthItem {
MediaID string `json:"media_id"`
Permalink string `json:"permalink,optional"`
Text string `json:"text,optional"`
PublishedAt int64 `json:"published_at"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Views int `json:"views,optional"`
Shares int `json:"shares,optional"`
Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"`
}
ThreadsPublishHealthData {
Summary ThreadsPublishHealthSummary `json:"summary"`
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"`
}
GenerateOwnPostReplyDraftReq {
MediaID string `json:"media_id"`
PersonaID string `json:"persona_id,optional"`
ReplyToID string `json:"reply_to_id,optional"`
PostText string `json:"post_text,optional"`
ReplyText string `json:"reply_text,optional"`
ThreadPostText string `json:"thread_post_text,optional"`
ParentReplyText string `json:"parent_reply_text,optional"`
}
GenerateOwnPostReplyDraftHandlerReq {
ThreadsAccountPath
GenerateOwnPostReplyDraftReq
}
GenerateOwnPostReplyDraftData {
Text string `json:"text"`
}
MentionInboxItemData {
MediaID string `json:"media_id"`
AuthorUsername string `json:"author_username,omitempty"`
Text string `json:"text,omitempty"`
Permalink string `json:"permalink,omitempty"`
Shortcode string `json:"shortcode,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
MediaType string `json:"media_type,omitempty"`
IsReply bool `json:"is_reply"`
IsQuotePost bool `json:"is_quote_post"`
HasReplies bool `json:"has_replies"`
RootPostID string `json:"root_post_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ThreadPostText string `json:"thread_post_text,omitempty"`
ParentReplyText string `json:"parent_reply_text,omitempty"`
ReplyReady bool `json:"reply_ready"`
ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
SyncedAt int64 `json:"synced_at"`
}
SyncMentionInboxReq {
Limit int `json:"limit,optional"`
MaxPages int `json:"max_pages,optional"`
}
SyncMentionInboxHandlerReq {
ThreadsAccountPath
SyncMentionInboxReq
}
SyncMentionInboxData {
Synced int `json:"synced"`
Ready int `json:"ready"`
Total int64 `json:"total"`
}
ListMentionInboxQuery {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ListMentionInboxHandlerReq {
ThreadsAccountPath
ListMentionInboxQuery
}
ListMentionInboxData {
List []MentionInboxItemData `json:"list"`
Pagination PaginationData `json:"pagination"`
ReadyTotal int64 `json:"ready_total"`
NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
}
PublishMentionReplyReq {
Text string `json:"text"`
}
PublishMentionReplyPath {
ID string `path:"id" validate:"required"`
MediaID string `path:"media_id" validate:"required"`
}
PublishMentionReplyHandlerReq {
PublishMentionReplyPath
PublishMentionReplyReq
}
PublishMentionReplyData {
MediaID string `json:"media_id,omitempty"`
Permalink string `json:"permalink,omitempty"`
}
GenerateOwnPostFormulaReq {
MediaID string `json:"media_id"`
PersonaID string `json:"persona_id,optional"`
PostText string `json:"post_text,optional"`
TopicTag string `json:"topic_tag,optional"`
MediaType string `json:"media_type,optional"`
Timestamp string `json:"timestamp,optional"`
LikeCount int `json:"like_count,optional"`
ReplyCount int `json:"reply_count,optional"`
Views int `json:"views,optional"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Shares int `json:"shares,optional"`
Force bool `json:"force,optional"`
}
GenerateOwnPostFormulaHandlerReq {
ThreadsAccountPath
GenerateOwnPostFormulaReq
}
OwnPostFormulaReviewData {
MediaID string `json:"media_id"`
ReviewedAt int64 `json:"reviewed_at"`
FromCache bool `json:"from_cache,omitempty"`
Summary string `json:"summary"`
Wins []string `json:"wins"`
Improvements []string `json:"improvements"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips"`
Avoid []string `json:"avoid"`
}
GenerateOwnPostFormulaData {
OwnPostFormulaReviewData
}
ListOwnPostFormulasHandlerReq {
ThreadsAccountPath
}
ListOwnPostFormulasData {
List []OwnPostFormulaReviewData `json:"list"`
}
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"`
}
ContentFormulaSourceMetricsData {
LikeCount int `json:"like_count,omitempty"`
ReplyCount int `json:"reply_count,omitempty"`
Views int `json:"views,omitempty"`
RepostCount int `json:"repost_count,omitempty"`
QuoteCount int `json:"quote_count,omitempty"`
Shares int `json:"shares,omitempty"`
}
ContentFormulaData {
ID string `json:"id"`
AccountID string `json:"account_id"`
Label string `json:"label"`
SourceType string `json:"source_type"`
SourceRef string `json:"source_ref,omitempty"`
SourcePostText string `json:"source_post_text,omitempty"`
SourceAuthor string `json:"source_author,omitempty"`
SourcePermalink string `json:"source_permalink,omitempty"`
SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
Summary string `json:"summary"`
Wins []string `json:"wins,omitempty"`
Improvements []string `json:"improvements,omitempty"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Tags []string `json:"tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListContentFormulasReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
SourceType string `form:"source_type,optional"`
Tag string `form:"tag,optional"`
}
ListContentFormulasHandlerReq {
ThreadsAccountPath
ListContentFormulasReq
}
ListContentFormulasData {
Pagination PaginationData `json:"pagination"`
List []ContentFormulaData `json:"list"`
}
ContentFormulaItemPath {
ID string `path:"id" validate:"required"`
FormulaID string `path:"formulaId" validate:"required"`
}
GetContentFormulaHandlerReq {
ContentFormulaItemPath
}
AnalyzeContentFormulaReq {
SourceType string `json:"source_type" validate:"required"`
PersonaID string `json:"persona_id,optional"`
PostText string `json:"post_text,optional"`
MediaID string `json:"media_id,optional"`
ScanPostID string `json:"scan_post_id,optional"`
Keyword string `json:"keyword,optional"`
SaveLabel string `json:"save_label,optional"`
AuthorName string `json:"author_name,optional"`
Permalink string `json:"permalink,optional"`
LikeCount int `json:"like_count,optional"`
ReplyCount int `json:"reply_count,optional"`
Views int `json:"views,optional"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Shares int `json:"shares,optional"`
ForceRefresh bool `json:"force_refresh,optional"`
}
AnalyzeContentFormulaHandlerReq {
ThreadsAccountPath
AnalyzeContentFormulaReq
}
AnalyzeContentFormulaData {
Formula ContentFormulaData `json:"formula"`
Message string `json:"message"`
}
SearchContentFormulaPostsReq {
Keyword string `json:"keyword" validate:"required"`
SearchType string `json:"search_type,optional"`
Limit int `json:"limit,optional"`
}
SearchContentFormulaPostsHandlerReq {
ThreadsAccountPath
SearchContentFormulaPostsReq
}
ContentFormulaSearchPostData {
Text string `json:"text"`
Author string `json:"author"`
Permalink string `json:"permalink,omitempty"`
MediaID string `json:"media_id,omitempty"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
EngagementScore int `json:"engagement_score"`
}
SearchContentFormulaPostsData {
List []ContentFormulaSearchPostData `json:"list"`
Message string `json:"message"`
}
PatchContentFormulaReq {
Label *string `json:"label,optional"`
Formula *string `json:"formula,optional"`
PostTemplate *string `json:"post_template,optional"`
HookPattern *string `json:"hook_pattern,optional"`
Structure *string `json:"structure,optional"`
Tags []string `json:"tags,optional"`
}
PatchContentFormulaHandlerReq {
ContentFormulaItemPath
PatchContentFormulaReq
}
DeleteContentFormulaData {
FormulaID string `json:"formula_id"`
Message string `json:"message"`
}
ImportOwnPostFormulaReq {
MediaID string `json:"media_id" validate:"required"`
Label string `json:"label,optional"`
}
ImportOwnPostFormulaHandlerReq {
ThreadsAccountPath
ImportOwnPostFormulaReq
}
)
@server(
@ -159,6 +871,9 @@ service gateway {
@handler updateThreadsAccount
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
@handler deleteThreadsAccount
delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData)
@handler activateThreadsAccount
post /:id/activate (ThreadsAccountPath)
@ -179,4 +894,135 @@ service gateway {
@handler listThreadsAccountAiProviderModels
post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData)
@handler startThreadsOauth
get /oauth/start (StartThreadsOAuthQuery) returns (StartThreadsOAuthData)
@handler listThreadsOauthLogs
get /oauth/logs returns (ListThreadsOAuthLogsData)
@handler getThreadsOauthConfig
get /oauth/config returns (ThreadsOAuthConfigData)
@handler disconnectThreadsApi
post /:id/api/disconnect (ThreadsAccountPath) returns (ThreadsAccountData)
@handler runThreadsApiSmokeTest
post /:id/api/smoke-test (ThreadsAccountPath) returns (RunThreadsAPISmokeTestData)
@handler runThreadsApiPlayground
post /:id/api/playground (RunThreadsAPIPlaygroundHandlerReq) returns (ThreadsAPIPlaygroundData)
@handler listThreadsPostPerformance
get /:id/post-performance (ListThreadsPostPerformanceHandlerReq) returns (ThreadsPostPerformanceData)
@handler createPublishQueueItem
post /:id/publish-queue (CreatePublishQueueHandlerReq) returns (PublishQueueItemData)
@handler listPublishQueueItems
get /:id/publish-queue (ListPublishQueueHandlerReq) returns (PublishQueueListData)
@handler getPublishQueueItem
get /:id/publish-queue/:qid (PublishQueueItemPath) returns (PublishQueueItemData)
@handler patchPublishQueueItem
patch /:id/publish-queue/:qid (PatchPublishQueueHandlerReq) returns (PublishQueueItemData)
@handler cancelPublishQueueItem
post /:id/publish-queue/:qid/cancel (PublishQueueItemPath) returns (PublishQueueItemData)
@handler deletePublishQueueItem
delete /:id/publish-queue/:qid (PublishQueueItemPath)
@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 generateOwnPostReplyDraft
post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData)
@handler syncMentionInbox
post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData)
@handler listMentionInbox
get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData)
@handler publishMentionReply
post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData)
@handler generateOwnPostFormula
post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData)
@handler listOwnPostFormulas
get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData)
@handler getPublishDashboardSummary
get /publish-dashboard-summary returns (PublishDashboardSummaryData)
@handler listContentFormulas
get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData)
@handler getContentFormula
get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData)
@handler analyzeContentFormula
post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData)
@handler searchContentFormulaPosts
post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData)
@handler patchContentFormula
patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData)
@handler deleteContentFormula
delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData)
@handler importOwnPostToContentFormula
post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData)
}
@server(
group: threads_account
prefix: /api/v1/threads-accounts
tags: "ThreadsAccount"
summary: "Threads OAuth callback (public redirect from Meta)."
)
service gateway {
@handler threadsOauthCallback
get /oauth/callback (ThreadsOAuthCallbackQuery)
}

View File

@ -4,6 +4,7 @@ go 1.23
require (
github.com/go-playground/validator/v10 v10.27.0
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.14.0
@ -27,7 +28,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/grafana/pyroscope-go v1.2.7 // indirect

View File

@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -36,6 +36,18 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO
return nil, false, err
}
existing.Roles = []string{"admin"}
if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash), []byte(opts.Password)); err != nil {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
if hashErr != nil {
return nil, false, app.For(code.Member).SysInternal("rehash password failed").WithCause(hashErr)
}
if updateErr := repo.UpdatePassword(ctx, tenantID, existing.UID, string(hash)); updateErr != nil {
return nil, false, updateErr
}
existing.PasswordHash = string(hash)
}
return existing, false, nil
}
if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound {

View File

@ -68,6 +68,16 @@ func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID
return app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) UpdatePassword(_ context.Context, tenantID, uid, passwordHash string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
item.PasswordHash = passwordHash
return nil
}
}
return app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
@ -123,4 +133,72 @@ func TestEnsureAdminMemberUpgradesExisting(t *testing.T) {
if len(member.Roles) != 1 || member.Roles[0] != "admin" {
t.Fatalf("roles=%v", member.Roles)
}
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
t.Fatalf("password should have been synced: %v", err)
}
}
func TestEnsureAdminMemberSyncsPasswordWhenChanged(t *testing.T) {
oldHash, err := bcrypt.GenerateFromPassword([]byte("Old-Pass-1!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
memberKey("default", "admin@haixun.local"): {
TenantID: "default",
UID: "uid-1",
Email: "admin@haixun.local",
Roles: []string{"admin"},
PasswordHash: string(oldHash),
},
}}
_, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "New-Pass-2@",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if created {
t.Fatal("expected created=false")
}
member := repo.byEmail[memberKey("default", "admin@haixun.local")]
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Old-Pass-1!")); err == nil {
t.Fatal("password should have been updated, but old hash still matches")
}
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("New-Pass-2@")); err != nil {
t.Fatalf("new password hash does not match: %v", err)
}
}
func TestEnsureAdminMemberKeepsPasswordWhenSame(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("Same-Pass-1!"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
memberKey("default", "admin@haixun.local"): {
TenantID: "default",
UID: "uid-1",
Email: "admin@haixun.local",
Roles: []string{"admin"},
PasswordHash: string(hash),
},
}}
_, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "Same-Pass-1!",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if created {
t.Fatal("expected created=false")
}
member := repo.byEmail[memberKey("default", "admin@haixun.local")]
if member.PasswordHash != string(hash) {
t.Fatal("password hash should not change when password is the same")
}
}

View File

@ -0,0 +1,67 @@
package bootstrap
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// LoadInfraEnv reads local env files into process env (only keys not already set).
// Search order: HAIXUN_ENV_FILE, deploy/.env.dev (for native dev run).
func LoadInfraEnv() {
if path := strings.TrimSpace(os.Getenv("HAIXUN_ENV_FILE")); path != "" {
_ = loadEnvFile(path)
return
}
cwd, err := os.Getwd()
if err != nil {
return
}
candidates := []string{
filepath.Join(cwd, "deploy", ".env.dev"),
filepath.Join(cwd, "..", "deploy", ".env.dev"),
filepath.Join(cwd, "..", "..", "deploy", ".env.dev"),
}
for _, path := range candidates {
if loadEnvFile(path) {
return
}
}
}
func loadEnvFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
value = strings.TrimSpace(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
_ = os.Setenv(key, value)
}
return true
}

View File

@ -0,0 +1,36 @@
package bootstrap
import (
"os"
"path/filepath"
"testing"
)
func TestLoadEnvFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
content := `# comment
HAIXUN_JWT_ACCESS_SECRET=from-file
THREADS_APP_ID="1566509781118049"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HAIXUN_JWT_REFRESH_SECRET", "preset")
unset := []string{"HAIXUN_JWT_ACCESS_SECRET", "THREADS_APP_ID"}
for _, key := range unset {
_ = os.Unsetenv(key)
}
if !loadEnvFile(path) {
t.Fatal("loadEnvFile returned false")
}
if got := os.Getenv("HAIXUN_JWT_ACCESS_SECRET"); got != "from-file" {
t.Fatalf("access secret = %q", got)
}
if got := os.Getenv("HAIXUN_JWT_REFRESH_SECRET"); got != "preset" {
t.Fatalf("refresh secret should stay preset, got %q", got)
}
if got := os.Getenv("THREADS_APP_ID"); got != "1566509781118049" {
t.Fatalf("threads app id = %q", got)
}
}

View File

@ -8,19 +8,31 @@ import (
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
brandrepo "haixun-backend/internal/model/brand/repository"
contentformularepo "haixun-backend/internal/model/content_formula/repository"
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
contentopsrepo "haixun-backend/internal/model/content_ops/repository"
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
crmcontactrepo "haixun-backend/internal/model/crm_contact/repository"
jobrepo "haixun-backend/internal/model/job/repository"
jobusecase "haixun-backend/internal/model/job/usecase"
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
memberrepo "haixun-backend/internal/model/member/repository"
mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
personarepo "haixun-backend/internal/model/persona/repository"
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
settingrepo "haixun-backend/internal/model/setting/repository"
stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
)
@ -97,6 +109,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
{"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes},
{"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes},
{"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes},
{"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes},
{"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes},
{"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes},
{"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes},
{"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes},
{"crm_contacts", crmcontactrepo.NewMongoRepository(db).EnsureIndexes},
}
for _, repo := range repos {
if err := repo.fn(ctx); err != nil {
@ -105,6 +128,26 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
}
report.IndexesEnsured = true
jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil)
jobTemplates := []struct {
name string
fn func(context.Context) error
}{
{"demo", jobUseCase.EnsureDemoTemplate},
{"style_8d", jobUseCase.EnsureStyle8DTemplate},
{"expand_graph", jobUseCase.EnsureExpandGraphTemplate},
{"placement_scan", jobUseCase.EnsurePlacementScanTemplate},
{"scan_viral", jobUseCase.EnsureScanViralTemplate},
{"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate},
{"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate},
{"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate},
}
for _, template := range jobTemplates {
if err := template.fn(ctx); err != nil {
return nil, fmt.Errorf("seed %s job template: %w", template.name, err)
}
}
permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
return nil, fmt.Errorf("seed permissions catalog: %w", err)

View File

@ -52,6 +52,25 @@ type BraveConf struct {
APIKey string `json:",optional"`
}
type ThreadsOAuthConf struct {
AppID string `json:",optional"`
AppSecret string `json:",optional"`
RedirectURI string `json:",optional"`
SuccessRedirect string `json:",optional"`
}
type ThreadsTokenRefreshConf struct {
Enabled bool `json:",default=true"`
IntervalSeconds int `json:",default=3600"`
LeadDays int `json:",default=7"`
}
type ThreadsPublishQueueConf struct {
Enabled bool `json:",default=true"`
IntervalSeconds int `json:",default=60"`
BatchSize int `json:",default=20"`
}
type Config struct {
rest.RestConf
Mongo MongoConf `json:",optional"`
@ -63,4 +82,7 @@ type Config struct {
JobScheduler JobSchedulerConf `json:",optional"`
JobReaper JobReaperConf `json:",optional"`
Brave BraveConf `json:",optional"`
ThreadsOAuth ThreadsOAuthConf `json:",optional"`
ThreadsTokenRefresh ThreadsTokenRefreshConf `json:",optional"`
ThreadsPublishQueue ThreadsPublishQueueConf `json:",optional"`
}

View File

@ -1,9 +1,9 @@
package scan_post
package brand
import (
"net/http"
"haixun-backend/internal/logic/scan_post"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@ -11,9 +11,9 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
func UpdateOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.LookalikeReq
var req types.UpdateOutreachDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
@ -22,8 +22,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 := brand.NewUpdateOutreachDraftLogic(r.Context(), svcCtx)
data, err := l.UpdateOutreachDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScheduleCopyMissionDraftsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx)
data, err := l.ScheduleCopyMissionDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,14 +1,16 @@
// 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -6,12 +6,11 @@ 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"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {

View File

@ -1,25 +0,0 @@
package crm
import (
"net/http"
"haixun-backend/internal/logic/crm"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CrmContactIDPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx)
err := l.DeleteCrmContact(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -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 CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateContentPlanHandlerReq
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.NewCreateContentPlanLogic(r.Context(), svcCtx)
data, err := l.CreateContentPlan(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateFeedbackEventHandlerReq
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.NewCreateFeedbackEventLogic(r.Context(), svcCtx)
data, err := l.CreateFeedbackEvent(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateFormulaPoolHandlerReq
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.NewCreateFormulaPoolLogic(r.Context(), svcCtx)
data, err := l.CreateFormulaPool(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateKnowledgeSourceHandlerReq
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.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx)
data, err := l.CreateKnowledgeSource(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StylePresetPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx)
err := l.DeleteStylePreset(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"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 GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateFromContentFormulaHandlerReq
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.NewGenerateFromContentFormulaLogic(r.Context(), svcCtx)
data, err := l.GenerateFromContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"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 GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GeneratePersonaTopicMatrixHandlerReq
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.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx)
data, err := l.GeneratePersonaTopicMatrix(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListContentPlansLogic(r.Context(), svcCtx)
data, err := l.ListContentPlans(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListFormulaPoolsLogic(r.Context(), svcCtx)
data, err := l.ListFormulaPools(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListKnowledgeChunksLogic(r.Context(), svcCtx)
data, err := l.ListKnowledgeChunks(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListKnowledgeSourcesLogic(r.Context(), svcCtx)
data, err := l.ListKnowledgeSources(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"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 ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListPersonaContentInboxHandlerReq
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.NewListPersonaContentInboxLogic(r.Context(), svcCtx)
data, err := l.ListPersonaContentInbox(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListStylePresetsLogic(r.Context(), svcCtx)
data, err := l.ListStylePresets(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaPath
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.NewListTopicCandidatesLogic(r.Context(), svcCtx)
data, err := l.ListTopicCandidates(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"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 PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PrunePersonaCopyDraftsHandlerReq
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.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx)
data, err := l.PrunePersonaCopyDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"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 SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SchedulePersonaCopyDraftHandlerReq
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.NewSchedulePersonaCopyDraftLogic(r.Context(), svcCtx)
data, err := l.SchedulePersonaCopyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SchedulePersonaDraftsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx)
data, err := l.SchedulePersonaDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"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 StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaFormulaDraftJobHandlerReq
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.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaFormulaDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaRewriteDraftJobHandlerReq
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.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaRewriteDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"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 StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaStyleAnalysisFromTextHandlerReq
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.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx)
data, err := l.StartPersonaStyleAnalysisFromText(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package persona
import (
"net/http"
"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 StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPersonaTopicMatrixJobHandlerReq
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.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx)
data, err := l.StartPersonaTopicMatrixJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateContentPlanHandlerReq
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.NewUpdateContentPlanLogic(r.Context(), svcCtx)
data, err := l.UpdateContentPlan(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package persona
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpsertStylePresetHandlerReq
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.NewUpsertStylePresetLogic(r.Context(), svcCtx)
data, err := l.UpsertStylePreset(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package placement_topic
import (
"net/http"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPlacementTopicOutreachDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPlacementTopicOutreachDraftJobHandlerReq
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 := placement_topic.NewStartPlacementTopicOutreachDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPlacementTopicOutreachDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package placement_topic
import (
"net/http"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdatePlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdatePlacementTopicOutreachDraftHandlerReq
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 := placement_topic.NewUpdatePlacementTopicOutreachDraftLogic(r.Context(), svcCtx)
data, err := l.UpdatePlacementTopicOutreachDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,37 @@
package handler
import (
"net/http"
"haixun-backend/internal/handler/static"
threads_account "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
// RegisterExtraHandlers mounts routes that are not generated by goctl.
func RegisterExtraHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/downloads/haixun-threads-sync.zip",
Handler: static.ExtensionDownloadHandler(),
},
},
rest.WithPrefix("/api/v1"),
)
// Legacy Meta redirect URI (deploy/.env.dev 曾誤設為 /api/threads/oauth/callback)。
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/oauth/callback",
Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
},
},
rest.WithPrefix("/api/threads"),
)
}

View File

@ -10,14 +10,12 @@ import (
auth "haixun-backend/internal/handler/auth"
brand "haixun-backend/internal/handler/brand"
copy_mission "haixun-backend/internal/handler/copy_mission"
crm "haixun-backend/internal/handler/crm"
job "haixun-backend/internal/handler/job"
member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal"
permission "haixun-backend/internal/handler/permission"
persona "haixun-backend/internal/handler/persona"
placement_topic "haixun-backend/internal/handler/placement_topic"
scan_post "haixun-backend/internal/handler/scan_post"
setting "haixun-backend/internal/handler/setting"
threads_account "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc"
@ -164,6 +162,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/outreach-drafts/:draftId",
Handler: brand.UpdateOutreachDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@ -278,6 +281,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",
@ -574,6 +582,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id",
Handler: persona.DeletePersonaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/:formulaId/generate",
Handler: persona.GenerateFromContentFormulaHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-inbox",
Handler: persona.ListPersonaContentInboxHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-plans",
Handler: persona.ListContentPlansHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-plans",
Handler: persona.CreateContentPlanHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/content-plans/:contentPlanId",
Handler: persona.UpdateContentPlanHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/copy-drafts",
@ -594,16 +627,106 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/:draftId/publish",
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/:draftId/schedule",
Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/generate",
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/prune",
Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/schedule",
Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/feedback-events",
Handler: persona.CreateFeedbackEventHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/formula-draft/jobs",
Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/formula-pools",
Handler: persona.ListFormulaPoolsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/formula-pools",
Handler: persona.CreateFormulaPoolHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/knowledge-chunks",
Handler: persona.ListKnowledgeChunksHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/knowledge-sources",
Handler: persona.CreateKnowledgeSourceHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/knowledge-sources",
Handler: persona.ListKnowledgeSourcesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/rewrite-draft/jobs",
Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/style-analysis",
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/style-analysis-from-text",
Handler: persona.StartPersonaStyleAnalysisFromTextHandler(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.MethodGet,
Path: "/:id/topic-candidates",
Handler: persona.ListTopicCandidatesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/topic-matrix/generate",
Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/topic-matrix/jobs",
Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/viral-scan-jobs",
@ -673,6 +796,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-draft-jobs",
Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/outreach-drafts/:draftId",
Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@ -752,54 +885,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/settings"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/",
Handler: crm.ListCrmContactsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/",
Handler: crm.CreateCrmContactHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id",
Handler: crm.GetCrmContactHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id",
Handler: crm.UpdateCrmContactHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id",
Handler: crm.DeleteCrmContactHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/crm/contacts"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/lookalike",
Handler: scan_post.LookalikeHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/scan-post"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -824,6 +909,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",
@ -844,6 +934,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/ai-settings/providers/:provider/models",
Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/api/disconnect",
Handler: threads_account.DisconnectThreadsApiHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/api/playground",
Handler: threads_account.RunThreadsApiPlaygroundHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/api/smoke-test",
Handler: threads_account.RunThreadsApiSmokeTestHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/connection",
@ -854,13 +959,209 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/connection",
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-formulas",
Handler: threads_account.ListContentFormulasHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.GetContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.PatchContentFormulaHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.DeleteContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/analyze",
Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/import-own-post",
Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/search-posts",
Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/diagnostics",
Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/mention-inbox",
Handler: threads_account.ListMentionInboxHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/mention-inbox/:media_id/reply",
Handler: threads_account.PublishMentionReplyHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/mention-inbox/sync",
Handler: threads_account.SyncMentionInboxHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/own-post-formula",
Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/own-post-formulas",
Handler: threads_account.ListOwnPostFormulasHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/own-post-reply-draft",
Handler: threads_account.GenerateOwnPostReplyDraftHandler(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",
Handler: threads_account.CreatePublishQueueItemHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/publish-queue",
Handler: threads_account.ListPublishQueueItemsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/publish-queue/:qid",
Handler: threads_account.GetPublishQueueItemHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/publish-queue/:qid",
Handler: threads_account.PatchPublishQueueItemHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id/publish-queue/:qid",
Handler: threads_account.DeletePublishQueueItemHandler(serverCtx),
},
{
Method: http.MethodPost,
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",
Handler: threads_account.ImportThreadsAccountSessionHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/oauth/config",
Handler: threads_account.GetThreadsOauthConfigHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/oauth/logs",
Handler: threads_account.ListThreadsOauthLogsHandler(serverCtx),
},
{
Method: http.MethodGet,
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"),
)
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/oauth/callback",
Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1/threads-accounts"),
)
}

View File

@ -0,0 +1,42 @@
package static
import (
"net/http"
"os"
"path/filepath"
)
func ExtensionDownloadHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path, ok := resolveExtensionZipPath()
if !ok {
http.Error(w, "extension package not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`)
http.ServeFile(w, r, path)
}
}
func resolveExtensionZipPath() (string, bool) {
candidates := []string{
filepath.Join("web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("..", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("extension", "haixun-threads-sync.zip"),
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join(wd, "backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join(wd, "..", "extension", "haixun-threads-sync.zip"),
)
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 {
return candidate, true
}
}
return "", false
}

View File

@ -0,0 +1,29 @@
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 AnalyzeContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AnalyzeContentFormulaHandlerReq
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.NewAnalyzeContentFormulaLogic(r.Context(), svcCtx)
data, err := l.AnalyzeContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CancelPublishQueueItemHandler(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.NewCancelPublishQueueItemLogic(r.Context(), svcCtx)
data, err := l.CancelPublishQueueItem(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreatePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreatePublishQueueHandlerReq
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.NewCreatePublishQueueItemLogic(r.Context(), svcCtx)
data, err := l.CreatePublishQueueItem(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
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 DeleteContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetContentFormulaHandlerReq
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.NewDeleteContentFormulaLogic(r.Context(), svcCtx)
data, err := l.DeleteContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func DeletePublishQueueItemHandler(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.NewDeletePublishQueueItemLogic(r.Context(), svcCtx)
err := l.DeletePublishQueueItem(&req)
response.Write(r.Context(), w, nil, err)
}
}

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