feat/rebone #3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
72708
|
||||
2857
.run/logs/api.log
2857
.run/logs/api.log
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
1217
.run/logs/worker.log
1217
.run/logs/worker.log
File diff suppressed because it is too large
Load Diff
|
|
@ -1 +0,0 @@
|
|||
72713
|
||||
|
|
@ -1 +0,0 @@
|
|||
72714
|
||||
|
|
@ -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 流程
|
||||
|
|
|
|||
294
Makefile
294
Makefile
|
|
@ -1,200 +1,128 @@
|
|||
# 巡樓 monorepo Makefile
|
||||
# 兩種模式:
|
||||
# dev - 本機開發(docker 起 Mongo/Redis,go 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
|
||||
BACKEND_DIR := backend
|
||||
WEB_DIR := backend/web
|
||||
INFRA_DIR := infra
|
||||
DEPLOY_DIR := deploy
|
||||
|
||||
# --- docker compose(prod 全棧)---
|
||||
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 開 :8890,web 開 :8080,go 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 + Redis(docker),其餘本機跑
|
||||
$(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 stack(API、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 stack(infra 不影響)
|
||||
$(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
Binary file not shown.
|
|
@ -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"]
|
||||
|
|
@ -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 / handler(logic 已存在則保留實作)
|
||||
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
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
- `auth`:native email/password 登入、JWT access/refresh token、logout revoke。
|
||||
- `member`:目前登入會員的 profile 讀寫。
|
||||
- `permission`:permission catalog 與目前會員權限查詢。
|
||||
- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。
|
||||
|
||||
暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
|
||||
|
||||
|
|
@ -23,6 +24,13 @@ go mod download
|
|||
make run
|
||||
```
|
||||
|
||||
正式前端:
|
||||
|
||||
```bash
|
||||
make web-dev # Vite dev server :5173,proxy 到 :8890
|
||||
make web-build # TypeScript + Vite build
|
||||
```
|
||||
|
||||
預設服務:
|
||||
|
||||
```text
|
||||
|
|
@ -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 session;Node 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/ # 正式巡樓 Console(Vite + React + TypeScript)
|
||||
dev-console/ # 本機診斷用最小開發面板,不是產品 UI
|
||||
etc/ # runtime config
|
||||
generate/
|
||||
api/ # goctl .api 定義
|
||||
|
|
@ -87,6 +97,11 @@ haixun-backend/
|
|||
auth/ # JWT token issue/refresh/logout + Redis revoke store
|
||||
member/ # Native member profile + password hash
|
||||
permission/ # Permission catalog + role permission mapping
|
||||
publish_queue/ # Threads 發文 queue、guarded transition、retry/missed
|
||||
publish_inventory/ # 自動補庫存 policy
|
||||
publish_guard/ # 發文頻率護欄與 pause/resume
|
||||
publish_queue_event/ # Queue 狀態轉移與告警事件
|
||||
style_preset/ # 人設語調 preset、CTA、禁用詞
|
||||
worker/ # 常駐背景 worker / scheduler / reaper
|
||||
library/ # 最小 runtime library
|
||||
response/ # 統一 JSON response envelope
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 worker(style-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
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "haixun-dev-console",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
@ -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` | 語意相似度分數(0–100) |
|
||||
| `EngagementPredicted` | `int` | 預測互動率 |
|
||||
| `AudienceQualityScore` | `int` | 受眾品質分數(0–100) |
|
||||
| `AuthorID` | `string` | 作者 ID(Threads 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 → 0–100
|
||||
- **預測互動率**(`EngagementPredicted`):like+reply → 對數正規化 0–100
|
||||
- **受眾品質**(`AudienceQualityScore`):`computeAudienceQuality()` heuristic
|
||||
|
||||
權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。
|
||||
|
||||
```go
|
||||
func computeAudienceQuality(followers int) int
|
||||
```
|
||||
|
||||
Heuristic:followers 在 500–50k 之間品質最佳,過低或過高遞減。
|
||||
|
||||
### 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(非 Redis),thread-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`。
|
||||
|
|
@ -1,512 +0,0 @@
|
|||
# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
|
||||
|
||||
> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge Graph(Brave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
|
||||
|
||||
## 北極星
|
||||
|
||||
海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
|
||||
|
||||
流程 B 主線:
|
||||
|
||||
```text
|
||||
Brave 知識圖譜擴散(周邊延伸)
|
||||
→ 衍生雙維度搜尋 tag(相關詞 + 近期求助詞)
|
||||
→ 使用者手動勾選
|
||||
→ 每個 tag 雙軌爬 Threads(相關軌 + 近期軌,7d 優先 / 30d 補充)
|
||||
→ productFitScore 篩選
|
||||
→ 島民協助撰寫獲客留言
|
||||
```
|
||||
|
||||
## 已拍板決策
|
||||
|
||||
| 項目 | 決策 |
|
||||
|------|------|
|
||||
| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
|
||||
| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle,不預設全選) |
|
||||
| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
|
||||
| Brave 預算 | 中等,每輪知識擴展 **10–15 次查詢**(不足可 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)):
|
||||
|
||||
| 現象 | 根因 | 新系統對策 |
|
||||
|------|------|------------|
|
||||
| 痛點只抓到 1–2 個 | Placement 壓 `suggestedTags` 至 **2~4**;`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 一次吐 2~4 個,而是五段流水線產出:
|
||||
|
||||
```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 10–15 次**一般網搜**(非 threadsOnly) | snippets → 候選節點 |
|
||||
| 4 | AI 合成 TKG(L0/L1/L2)+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
|
||||
| 5 | 每節點壓成 2~8 字真人搜尋詞,分兩套 | `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 標 warning;UI + 島民提示「可重跑 expand 或手動加種子詞」
|
||||
```
|
||||
|
||||
| 指標 | 舊系統 | 新系統 |
|
||||
|------|--------|--------|
|
||||
| Placement suggestedTags | 2~4 | 不沿用此上限 |
|
||||
| 搜尋任務上限 | 8 | 候選 ≥12,實 crawl = 勾選數 |
|
||||
| 痛點類最低 | 無保證 | **≥8**(含 supplemental) |
|
||||
|
||||
---
|
||||
|
||||
## Topic Knowledge Graph(TKG)
|
||||
|
||||
### 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 天內 | 優先爬取、優先顯示、排序最高 |
|
||||
| **補充** | 8~30 天 | 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 + 周邊 4);7 天內 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-graph(Brave 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` | 流程 A(Phase 2) | 草稿內容 |
|
||||
|
||||
研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
|
||||
|
||||
---
|
||||
|
||||
## 實作分期
|
||||
|
||||
### Phase 0a — 知識圖譜 + Tag 流水線
|
||||
|
||||
- [ ] Go Brave adapter(`knowledge_expand` / `threads_discover`)
|
||||
- [ ] `expand-graph` job:plan_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)
|
||||
|
|
@ -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 加「來源」badge(scan+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 schema(Phase 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` 新排序 case(topicHits 加權、log-scale follower bucket)、`worker/job/scan_viral_test.go` web fallback case(mock 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 bucket(1k/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` 串接 priority:新(scan+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。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 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/RECENT,50/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 — 相似帳號變一等公民(動 API,CopyMission 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 時直接跳過整段,不報錯。
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# Threads 自動營運補功能計畫
|
||||
|
||||
## 目前狀態
|
||||
|
||||
P1/P2 已落地到後端 API、worker 與正式前端:
|
||||
|
||||
- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。
|
||||
- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。
|
||||
- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。
|
||||
- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。
|
||||
- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。
|
||||
- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。
|
||||
|
||||
## 目標
|
||||
|
||||
把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。
|
||||
|
||||
## P1:自動補庫存
|
||||
|
||||
### 功能
|
||||
|
||||
- 每個 Threads account 可設定每日/每週目標篇數。
|
||||
- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。
|
||||
- 指定內容來源:persona、copy mission、brand scan posts、manual seed。
|
||||
- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 新增 model:`internal/model/publish_inventory/`。
|
||||
- 新增設定 entity:
|
||||
- `account_id`
|
||||
- `target_daily_count`
|
||||
- `low_stock_threshold`
|
||||
- `lookahead_days`
|
||||
- `timezone`
|
||||
- `slots`
|
||||
- `source_refs`
|
||||
- `enabled`
|
||||
- 新增 job step:`refill_publish_inventory`。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy`
|
||||
- `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy`
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs`
|
||||
|
||||
## P1:智慧時段
|
||||
|
||||
### 功能
|
||||
|
||||
- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。
|
||||
- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。
|
||||
- 前端顯示「推薦時段」與「低成效時段」。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-slot-insights`
|
||||
- 回傳:
|
||||
- `slots[]`
|
||||
- `avg_likes_1h/24h/7d`
|
||||
- `avg_replies_1h/24h/7d`
|
||||
- `sample_size`
|
||||
- `recommendation`
|
||||
|
||||
## P1:草稿批量排程
|
||||
|
||||
### 功能
|
||||
|
||||
- 使用者在 copy drafts 中選多篇,一次排到未來 slots。
|
||||
- 支援根據智慧時段自動分配 `scheduled_at`。
|
||||
- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 補 API:
|
||||
- `POST /api/v1/personas/:personaId/copy-drafts/schedule`
|
||||
- `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule`
|
||||
- request:
|
||||
- `account_id`
|
||||
- `draft_ids`
|
||||
- `start_at`
|
||||
- `timezone`
|
||||
- `slots`
|
||||
- `mode`: `manual` / `recommended`
|
||||
|
||||
## P1:發文 retry / 告警 / 漏發偵測
|
||||
|
||||
### 功能
|
||||
|
||||
- failed queue item 可重試,保留失敗歷史。
|
||||
- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。
|
||||
- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。
|
||||
- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。
|
||||
- 補 API:
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry`
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events`
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-alerts`
|
||||
|
||||
## P1:頻率護欄
|
||||
|
||||
### 功能
|
||||
|
||||
- 每帳號每日上限。
|
||||
- 最小發文間隔。
|
||||
- 連續失敗自動 pause。
|
||||
- 手動解除 pause。
|
||||
|
||||
### 後端草案
|
||||
|
||||
- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。
|
||||
- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。
|
||||
- 補 API:
|
||||
- `GET /api/v1/threads-accounts/:accountId/publish-guard-policy`
|
||||
- `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy`
|
||||
- `POST /api/v1/threads-accounts/:accountId/publish-guard/resume`
|
||||
|
||||
## P2
|
||||
|
||||
- 內容日曆:補 queue date range query,前端做週/月視圖。
|
||||
- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。
|
||||
- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。
|
||||
- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。
|
||||
|
||||
## 驗證策略
|
||||
|
||||
- 所有列表維持 `page/pageSize` 與 `pagination/list`。
|
||||
- 所有時間欄位寫入 unix nanoseconds。
|
||||
- queue/job 狀態轉移都使用 guarded update。
|
||||
- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
Name: haixun-backend
|
||||
Host: 0.0.0.0
|
||||
Port: 8890
|
||||
Timeout: 120000
|
||||
|
||||
# 本地「docker 測試」設定(make dev-up 使用)。
|
||||
# 連線走 compose service name(mongo / 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 OAuth(App 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,10 @@ type (
|
|||
}
|
||||
|
||||
CopyMissionInspirationReq {
|
||||
Keyword string `json:"keyword,optional"`
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
@ -155,10 +184,202 @@ type (
|
|||
}
|
||||
|
||||
UpdateCopyDraftReq {
|
||||
Text *string `json:"text,optional"`
|
||||
Hook *string `json:"hook,optional"`
|
||||
Angle *string `json:"angle,optional"`
|
||||
Status *string `json:"status,optional"`
|
||||
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 {
|
||||
|
|
@ -167,8 +388,9 @@ type (
|
|||
}
|
||||
|
||||
PublishCopyDraftReq {
|
||||
Text string `json:"text,optional"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Text string `json:"text,optional"`
|
||||
TopicTag string `json:"topic_tag,optional"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
PublishCopyDraftHandlerReq {
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ type (
|
|||
ThreadsUserID string `json:"threads_user_id,omitempty"`
|
||||
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"`
|
||||
Status string `json:"status"`
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ go 1.23
|
|||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
|
|
@ -27,7 +28,6 @@ require (
|
|||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/grafana/pyroscope-go v1.2.7 // indirect
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
|
|||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -52,15 +52,37 @@ 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"`
|
||||
Redis RedisConf `json:",optional"`
|
||||
Auth AuthConf `json:",optional"`
|
||||
Secrets SecretsConf `json:",optional"`
|
||||
InternalWorker InternalWorkerConf `json:",optional"`
|
||||
JobWorker JobWorkerConf `json:",optional"`
|
||||
JobScheduler JobSchedulerConf `json:",optional"`
|
||||
JobReaper JobReaperConf `json:",optional"`
|
||||
Brave BraveConf `json:",optional"`
|
||||
Mongo MongoConf `json:",optional"`
|
||||
Redis RedisConf `json:",optional"`
|
||||
Auth AuthConf `json:",optional"`
|
||||
Secrets SecretsConf `json:",optional"`
|
||||
InternalWorker InternalWorkerConf `json:",optional"`
|
||||
JobWorker JobWorkerConf `json:",optional"`
|
||||
JobScheduler JobSchedulerConf `json:",optional"`
|
||||
JobReaper JobReaperConf `json:",optional"`
|
||||
Brave BraveConf `json:",optional"`
|
||||
ThreadsOAuth ThreadsOAuthConf `json:",optional"`
|
||||
ThreadsTokenRefresh ThreadsTokenRefreshConf `json:",optional"`
|
||||
ThreadsPublishQueue ThreadsPublishQueueConf `json:",optional"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue